active_sanction 1.0.1 → 1.1.1

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.
@@ -144,6 +144,28 @@ module ActiveSanction
144
144
  # numbers again on another machine.
145
145
  POSTINGS_BUDGET = T.let(5_000, Integer)
146
146
 
147
+ # One posting: an Integer in an Array slot, which on a 64-bit CRuby is a
148
+ # tagged immediate and costs the slot and nothing else. See #profile,
149
+ # which is where these three are used and where their crudeness is
150
+ # admitted.
151
+ #
152
+ # @api private
153
+ POSTING_BYTES = T.let(8, Integer)
154
+
155
+ # One distinct feature: a short String -- a token, a trigram, a metaphone
156
+ # key -- plus its slot in a posting Hash and the empty Array header the
157
+ # slot points at. Measured as an order of magnitude rather than counted.
158
+ #
159
+ # @api private
160
+ FEATURE_BYTES = T.let(120, Integer)
161
+
162
+ # One indexed name: an Entry and the Normalizer::Form it holds, both small
163
+ # objects of references. The Entity and Name they point at belong to the
164
+ # snapshot and are not counted here.
165
+ #
166
+ # @api private
167
+ ENTRY_BYTES = T.let(200, Integer)
168
+
147
169
  # Entries, in the order they were indexed. The posting lists hold
148
170
  # positions in this array.
149
171
  sig { returns(T::Array[Entry]).checked(:tests) }
@@ -247,6 +269,45 @@ module ActiveSanction
247
269
  }
248
270
  end
249
271
 
272
+ # How big this index is, as the `index.build` event reports it: names,
273
+ # distinct features, postings, and an estimate of what it all weighs.
274
+ #
275
+ # Deliberately not #stats, and the difference is the one number #stats
276
+ # carries that this does not. Counting distinct entities means walking
277
+ # every entry and uniquing 47,000 ids, which is a fine thing to do in a
278
+ # benchmark and not a thing to do at the end of every index build -- it
279
+ # allocates at exactly the moment the heap is already at its peak, with
280
+ # the snapshot, the builder and the finished index all still alive. The
281
+ # event reports entity count from the snapshots it read instead, where it
282
+ # is already known.
283
+ #
284
+ # **`bytes` is an estimate, and deliberately a crude one.** Ruby offers no
285
+ # way to ask what an object graph weighs that does not either walk every
286
+ # object in the heap or lie, and an index is tens of millions of small
287
+ # objects. What it counts is the three things that dominate: a posting is
288
+ # an Integer in an Array slot, a distinct feature is a String plus its
289
+ # slot in a Hash, and an entry is a small object holding references. What
290
+ # it deliberately does not count is the Entities and Names themselves --
291
+ # those are the snapshot's, shared with it rather than owned here, and
292
+ # counting them would report the same megabytes twice to a host watching
293
+ # both.
294
+ #
295
+ # Good to within a factor a dashboard cares about, which is what "is the
296
+ # index growing?" and "will another list fit?" actually need. Anything
297
+ # finer wants a real heap profiler.
298
+ sig { returns(T::Hash[Symbol, Integer]).checked(:tests) }
299
+ def profile
300
+ keys = @tokens.size + @trigrams.size + @phonetics.size
301
+ # `each_value` rather than a two-parameter block over the Hash: iterating
302
+ # a Hash as pairs allocates a two-element Array per distinct feature,
303
+ # which is twenty thousand short-lived objects at the exact moment the
304
+ # heap is at its peak -- the snapshot, the builder and the finished
305
+ # index all still alive. #stats can afford that and a build cannot.
306
+ postings = [@tokens, @trigrams, @phonetics].sum { |space| space.each_value.sum(&:size) }
307
+ { names: entries.size, keys: keys, postings: postings,
308
+ bytes: (postings * POSTING_BYTES) + (keys * FEATURE_BYTES) + (entries.size * ENTRY_BYTES) }
309
+ end
310
+
250
311
  sig { returns(String) }
251
312
  def inspect = "#<#{self.class} #{size} names>"
252
313
 
@@ -0,0 +1,122 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Instrumentation
8
+ # One thing this library did, after it finished doing it.
9
+ #
10
+ # ActiveSanction.configure do |c|
11
+ # c.instrumenter = ->(event) { StatsD.timing("sanctions.#{event.name}", event.duration_ms) }
12
+ # end
13
+ #
14
+ # event.name # => :fetch
15
+ # event.duration # => 2.418, seconds
16
+ # event[:source] # => :ofac_sdn
17
+ # event[:bytes] # => 12_845_056
18
+ #
19
+ # A subscriber is handed a finished event and never a running one. That is
20
+ # the difference between this and a block-based instrumenter, and it is
21
+ # deliberate: a subscriber that cannot wrap the work cannot retry it,
22
+ # cannot swallow its exception, and cannot leave a `begin` half-entered if
23
+ # it raises. The cost is that nothing here can time a stage from the
24
+ # outside -- which nothing needs to, since every event already carries its
25
+ # own duration.
26
+ #
27
+ # ### Every event carries a duration and enough to correlate it
28
+ #
29
+ # There are no anonymous timings. `duration` is monotonic seconds, taken
30
+ # across the stage this event describes, and `started_at` is the wall
31
+ # clock at its start -- both, because one answers "how long did it take"
32
+ # and the other answers "when, in the log I am reading beside this". Every
33
+ # event but `screen` names a `source`; `screen` names the snapshot
34
+ # checksums it consulted, which is the same question asked of a query.
35
+ #
36
+ # ### An event is emitted for work that raised
37
+ #
38
+ # With `error` set to the exception, and with whichever payload keys the
39
+ # stage had filled in before it failed. A publisher that starts timing out
40
+ # is exactly what a host is watching for, and an instrumentation layer
41
+ # that only reports successes cannot see it. See Instrumentation.
42
+ #
43
+ # Frozen, and its payload with it, so a subscriber cannot edit what the
44
+ # next one is handed.
45
+ class Event
46
+ extend T::Sig
47
+
48
+ # The stage this event describes -- one of Instrumentation::EVENTS.
49
+ sig { returns(Symbol) }
50
+ attr_reader :name
51
+
52
+ # What the stage measured, keyed as documented for each event name in
53
+ # docs/api_stability.md. Frozen.
54
+ sig { returns(T::Hash[Symbol, T.untyped]) }
55
+ attr_reader :payload
56
+
57
+ # The wall clock when the stage started, UTC. For lining an event up
58
+ # against a log; `duration` is what to measure with.
59
+ sig { returns(Time) }
60
+ attr_reader :started_at
61
+
62
+ # Seconds the stage took, from a monotonic clock, so it is not moved by
63
+ # a clock adjustment mid-stage.
64
+ sig { returns(Float) }
65
+ attr_reader :duration
66
+
67
+ sig do
68
+ params(name: Symbol, payload: T::Hash[Symbol, T.untyped], started_at: Time, duration: Float).void
69
+ end
70
+ def initialize(name:, payload:, started_at:, duration:)
71
+ @name = T.let(name, Symbol)
72
+ @payload = T.let(payload.freeze, T::Hash[Symbol, T.untyped])
73
+ @started_at = T.let(started_at, Time)
74
+ @duration = T.let(duration, Float)
75
+ freeze
76
+ end
77
+
78
+ # One payload key, or nil for one this event does not carry.
79
+ sig { params(key: Symbol).returns(T.untyped) }
80
+ def [](key) = payload[key]
81
+
82
+ # Which list this was about, or nil for an event that is not about one
83
+ # -- `screen`, which is about a query, and `sync`, which is about a run.
84
+ sig { returns(T.nilable(Symbol)) }
85
+ def source = payload[:source]
86
+
87
+ # The exception the stage raised, or nil for one that finished. An event
88
+ # carrying one is a partial measurement: the keys the stage had not
89
+ # reached are absent rather than zero.
90
+ sig { returns(T.nilable(StandardError)) }
91
+ def error = payload[:error]
92
+
93
+ sig { returns(T::Boolean) }
94
+ def failed? = !error.nil?
95
+
96
+ # The wall clock when the stage finished. Derived from `started_at` and
97
+ # the monotonic duration rather than read again, so the two cannot
98
+ # disagree about how long this took.
99
+ sig { returns(Time) }
100
+ def finished_at = started_at + duration
101
+
102
+ # Milliseconds, which is what a metrics backend usually wants.
103
+ sig { returns(Float) }
104
+ def duration_ms = (duration * 1_000).round(3).to_f
105
+
106
+ # The whole event as one Hash, for a subscriber that forwards it
107
+ # somewhere structured. The payload is spread in at the top level, and
108
+ # its keys win over nothing -- `name`, `started_at` and `duration` are
109
+ # not payload keys on any event this library emits.
110
+ sig { returns(T::Hash[Symbol, T.untyped]) }
111
+ def to_h
112
+ { name: name, started_at: started_at, duration: duration }.merge(payload)
113
+ end
114
+
115
+ sig { returns(String) }
116
+ def to_s = "#{name} #{format("%.3f", duration)}s#{" #{source}" if source}#{" failed" if failed?}"
117
+
118
+ sig { returns(String) }
119
+ def inspect = "#<#{self.class} #{name} #{payload.inspect} #{format("%.3f", duration)}s>"
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,122 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/error"
7
+
8
+ module ActiveSanction
9
+ module Instrumentation
10
+ # Republishes every event into `ActiveSupport::Notifications`, for a host
11
+ # that already has subscribers, log tags and a dashboard pointed there.
12
+ #
13
+ # # config/initializers/active_sanction.rb
14
+ # ActiveSanction.configure do |c|
15
+ # c.instrumenter = ActiveSanction::Instrumentation::Notifications.new
16
+ # end
17
+ #
18
+ # ActiveSupport::Notifications.subscribe("screen.active_sanction") do |event|
19
+ # Rails.logger.info("screened in #{event.duration.round(1)}ms: #{event.payload[:results]} hits")
20
+ # end
21
+ #
22
+ # Names are `<event>.active_sanction` -- `fetch.active_sanction`,
23
+ # `index.build.active_sanction`, and so on -- which is the namespacing
24
+ # convention every ActiveSupport subscriber already expects, so
25
+ # `subscribe(/\.active_sanction\z/)` picks up all six.
26
+ #
27
+ # ### It is an adapter, not a dependency
28
+ #
29
+ # **Nothing in this gem requires ActiveSupport**, and this file does not
30
+ # either -- it names `::ActiveSupport::Notifications` and never loads it.
31
+ # Zero required runtime dependencies is a promise this library keeps for
32
+ # the API container it is going to run in, and a notification adapter is
33
+ # not a reason to break it. Building one in a process that has not loaded
34
+ # ActiveSupport raises ConfigurationError rather than quietly instrumenting
35
+ # nothing, because a subscriber that is not recording is a dashboard that
36
+ # is wrong rather than missing.
37
+ #
38
+ # ### Events arrive finished
39
+ #
40
+ # `publish` rather than `instrument`: this library has already done the
41
+ # work and timed it, and re-wrapping a finished event in a block would put
42
+ # an ActiveSupport subscriber around a stage it cannot influence while
43
+ # reporting a duration measured somewhere else. Subscribers see a normal
44
+ # `ActiveSupport::Notifications::Event` with real start and finish times.
45
+ #
46
+ # A stage that raised carries the two keys ActiveSupport's own subscribers
47
+ # look for -- `:exception`, the `[class, message]` pair, and
48
+ # `:exception_object` -- beside this library's `:error`, so a Rails host's
49
+ # existing error reporting sees it without being taught anything.
50
+ class Notifications
51
+ extend T::Sig
52
+
53
+ # The suffix every published name carries.
54
+ NAMESPACE = T.let("active_sanction", String)
55
+
56
+ sig { returns(String) }
57
+ attr_reader :namespace
58
+
59
+ # Whatever the events are published through -- `ActiveSupport::Notifications`
60
+ # itself unless a host named its own notifier.
61
+ sig { returns(T.untyped) }
62
+ attr_reader :notifier
63
+
64
+ # @param namespace [String] the suffix published names carry. Change it
65
+ # only to keep two installations of this gem apart in one process.
66
+ # @param notifier [#publish, nil] defaults to `ActiveSupport::Notifications`,
67
+ # resolved now rather than per event so that a process without it
68
+ # fails here, at the line that configured it.
69
+ sig { params(namespace: String, notifier: T.untyped).void }
70
+ def initialize(namespace: NAMESPACE, notifier: nil)
71
+ @namespace = T.let(namespace.to_s, String)
72
+ @notifier = T.let(notifier || default_notifier, T.untyped)
73
+ return if @notifier.respond_to?(:publish)
74
+
75
+ raise ConfigurationError, "an ActiveSupport::Notifications adapter needs a notifier answering " \
76
+ "#publish, got #{@notifier.class}"
77
+ end
78
+
79
+ # Publishes one finished event. Called by Instrumentation, which has
80
+ # already isolated it: an exception raised in here is reported and
81
+ # dropped rather than reaching the sync that emitted the event.
82
+ sig { params(event: Event).returns(T.untyped) }
83
+ def call(event)
84
+ notifier.publish("#{event.name}.#{namespace}", event.started_at, event.finished_at, event_id,
85
+ payload_for(event))
86
+ end
87
+
88
+ sig { returns(String) }
89
+ def inspect = "#<#{self.class} #{notifier.class} *.#{namespace}>"
90
+
91
+ private
92
+
93
+ sig { returns(T.untyped) }
94
+ def default_notifier
95
+ unless defined?(::ActiveSupport::Notifications)
96
+ raise ConfigurationError,
97
+ "ActiveSupport::Notifications is not loaded. This gem does not depend on ActiveSupport and " \
98
+ "will not require it -- load it yourself, or set c.instrumenter to any object answering " \
99
+ "#call(event)."
100
+ end
101
+
102
+ ::ActiveSupport::Notifications
103
+ end
104
+
105
+ # ActiveSupport's own per-thread instrumenter id, so an event published
106
+ # here is correlated with the ones a host's own `instrument` calls
107
+ # publish on the same thread.
108
+ sig { returns(String) }
109
+ def event_id
110
+ notifier.respond_to?(:instrumenter) ? notifier.instrumenter.id : "#{Process.pid}-#{Thread.current.object_id}"
111
+ end
112
+
113
+ sig { params(event: Event).returns(T::Hash[Symbol, T.untyped]) }
114
+ def payload_for(event)
115
+ error = event.error
116
+ return event.payload if error.nil?
117
+
118
+ event.payload.merge(exception: [error.class.name, error.message], exception_object: error)
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,245 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/error"
7
+ require "active_sanction/instrumentation/event"
8
+ require "active_sanction/instrumentation/notifications"
9
+
10
+ module ActiveSanction
11
+ # Structured events from every stage, so a host can measure this library
12
+ # without monkeypatching it.
13
+ #
14
+ # ActiveSanction.configure do |c|
15
+ # c.instrumenter = ->(event) do
16
+ # StatsD.timing("sanctions.#{event.name}", event.duration_ms, tags: ["source:#{event.source}"])
17
+ # end
18
+ # end
19
+ #
20
+ # Six events, and they are the operational questions a compliance
21
+ # installation is actually asked: is the data fresh, did a fetch fail, how
22
+ # long did screening take, which source is degrading.
23
+ #
24
+ # | Event | Emitted by | Asks |
25
+ # |---|---|---|
26
+ # | `:fetch` | Fetcher | Did bytes move, and what did the publisher answer? |
27
+ # | `:parse` | Sources::Base | How many records came out, and how many rows could not be read? |
28
+ # | `:store` | Sync, Client#import | Which list version was written, and how big is it? |
29
+ # | `:"index.build"` | Matcher.build | What did building the index cost, and how much is resident? |
30
+ # | `:screen` | Matcher | How long did a query take, and what did it consult? |
31
+ # | `:sync` | Sync | What did a whole run do? |
32
+ #
33
+ # The payload keys of each are enumerated in
34
+ # [`docs/api_stability.md`](../../docs/api_stability.md) and are public API:
35
+ # a dashboard built on them is held to the same promise as a method call, and
36
+ # a key is not removed or repurposed without the deprecation path. Keys may
37
+ # be **added** to an event, so a subscriber reads the keys it knows and
38
+ # ignores the rest.
39
+ #
40
+ # ### A subscriber is anything answering `#call(event)`
41
+ #
42
+ # A lambda, a Method, an object with a `call`. There is no registry and no
43
+ # base class to inherit, because the whole interface is one method and a
44
+ # registry would be a second thing to configure. A host wanting several
45
+ # subscribers composes them itself -- `->(event) { subscribers.each { |s| s.call(event) } }` --
46
+ # which is one line and is exactly what a fan-out registry here would be.
47
+ #
48
+ # Rails hosts have one already: see Notifications, which republishes every
49
+ # event into `ActiveSupport::Notifications` under `<name>.active_sanction`.
50
+ # It is an adapter rather than a dependency -- nothing here requires
51
+ # ActiveSupport, and the class refuses to build in a process that has not
52
+ # loaded it.
53
+ #
54
+ # ### Nothing is listening by default, and that costs nothing
55
+ #
56
+ # `instrumenter` defaults to nil, and a nil instrumenter is not a no-op
57
+ # object that gets called and returns -- it is a branch taken before
58
+ # anything is allocated. `.instrument` with no instrumenter calls the block
59
+ # with a payload that discards writes and returns, so a stage that fills in
60
+ # fifteen fields allocates no Hash and builds no Event. This is what keeps
61
+ # the #37 benchmarks where they were.
62
+ #
63
+ # ### A subscriber must be safe to call from several threads
64
+ #
65
+ # `sync!(concurrency: 3)` fetches from three publishers at once, and the
66
+ # `:fetch`, `:parse` and `:store` events of those three arrive on three
67
+ # threads. Nothing here serializes them -- a lock around a subscriber would
68
+ # make instrumentation a source of contention in the one place this library
69
+ # deliberately fans out. A subscriber that appends to a plain Array wants a
70
+ # Mutex of its own; one that hands an event to a metrics client is already
71
+ # fine, because those are.
72
+ #
73
+ # The `:screen` event is the same statement from the other direction: a
74
+ # Matcher is screened from every thread a host has, so a subscriber counting
75
+ # queries is counting them concurrently.
76
+ #
77
+ # ### A raising subscriber cannot break a sync
78
+ #
79
+ # Instrumentation is a measurement of the work and is never part of it.
80
+ # A subscriber that raises has its exception caught, reported once, and
81
+ # dropped; the stage it was measuring carries on and returns what it was
82
+ # going to return. The converse is equally deliberate: **instrumentation
83
+ # never swallows the library's own exceptions**. An event is emitted for a
84
+ # stage that raised, carrying `error:`, and then the exception continues
85
+ # exactly as if nothing were listening.
86
+ #
87
+ # @see Event
88
+ # @see Notifications
89
+ module Instrumentation
90
+ extend T::Sig
91
+
92
+ # Every event name this library emits. Enumerated so a host can assert
93
+ # against the list rather than discovering a name in production, and
94
+ # frozen because it is the published vocabulary -- see the class comment
95
+ # on what may and may not change about it.
96
+ EVENTS = T.let(%i[fetch parse store index.build screen sync].freeze, T::Array[Symbol])
97
+
98
+ # What a stage's block is handed when nothing is listening.
99
+ #
100
+ # A stage writes its measurements into the payload as it goes --
101
+ # `event[:records] = parsed.size` -- and those writes have to go
102
+ # somewhere even when there is no subscriber to read them. Somewhere is
103
+ # here, and it is one frozen object shared by every call rather than a
104
+ # Hash allocated per stage, which is what makes an uninstrumented
105
+ # screening call cost a branch.
106
+ #
107
+ # @api private
108
+ class Discard
109
+ extend T::Sig
110
+
111
+ sig { params(_key: Symbol, value: T.untyped).returns(T.untyped) }
112
+ def []=(_key, value)
113
+ value
114
+ end
115
+
116
+ sig { params(_key: Symbol).returns(NilClass) }
117
+ def [](_key) = nil
118
+
119
+ sig { returns(T::Hash[Symbol, T.untyped]) }
120
+ def to_h = {}
121
+ end
122
+ private_constant :Discard
123
+
124
+ DISCARD = T.let(Discard.new.freeze, Discard)
125
+ private_constant :DISCARD
126
+
127
+ @failures = T.let({}, T::Hash[String, TrueClass])
128
+ @mutex = T.let(Mutex.new, Mutex)
129
+
130
+ class << self
131
+ extend T::Sig
132
+
133
+ # Times a stage, hands the finished Event to `instrumenter`, and returns
134
+ # whatever the stage returned.
135
+ #
136
+ # Instrumentation.instrument(instrumenter, :parse, { source: :ofac_sdn }) do |event|
137
+ # entities = parse(raw)
138
+ # event[:records] = entities.size
139
+ # entities
140
+ # end
141
+ #
142
+ # The block is handed the payload so it can record what is only known
143
+ # once the work is done, which is most of what is worth recording. What
144
+ # it is handed when nobody is listening discards those writes -- so the
145
+ # block reads the same either way and the uninstrumented path allocates
146
+ # nothing.
147
+ #
148
+ # A stage that raises still emits, with `error:` set, and then the
149
+ # exception goes on. A subscriber that raises does not.
150
+ #
151
+ # @param instrumenter [#call, nil] nil means nothing is listening
152
+ # @param name [Symbol] one of EVENTS
153
+ # @param payload [Hash, nil] what is known before the stage runs
154
+ # @api private
155
+ sig do
156
+ params(instrumenter: T.untyped, name: Symbol, payload: T.nilable(T::Hash[Symbol, T.untyped]),
157
+ block: T.proc.params(payload: T.untyped).returns(T.untyped)).returns(T.untyped)
158
+ end
159
+ def instrument(instrumenter, name, payload = nil, &block)
160
+ return block.call(DISCARD) if instrumenter.nil?
161
+
162
+ fields = payload.nil? ? {} : payload.dup
163
+ started_at = Time.now.utc
164
+ began = Process.clock_gettime(Process::CLOCK_MONOTONIC)
165
+ begin
166
+ block.call(fields)
167
+ rescue StandardError => e
168
+ fields[:error] = e
169
+ raise
170
+ ensure
171
+ emit(instrumenter, name, fields, started_at,
172
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - began).to_f)
173
+ end
174
+ end
175
+
176
+ # Publishes one already-measured event. For a stage whose duration is
177
+ # known without wrapping it -- Sync measures each source itself, because
178
+ # a run is several sources deep and the timings have to agree with the
179
+ # Report it returns.
180
+ #
181
+ # @api private
182
+ sig do
183
+ params(instrumenter: T.untyped, name: Symbol, payload: T::Hash[Symbol, T.untyped], started_at: Time,
184
+ duration: Float).void
185
+ end
186
+ def emit(instrumenter, name, payload, started_at, duration)
187
+ return if instrumenter.nil?
188
+
189
+ deliver(instrumenter, Event.new(name: name, payload: payload, started_at: started_at, duration: duration))
190
+ end
191
+
192
+ # Forget which subscriber failures have already been reported, so the
193
+ # next one is reported again. For a spec that asserts a broken
194
+ # subscriber is reported; nothing in a running application should call
195
+ # it.
196
+ sig { void }
197
+ def reset!
198
+ @mutex.synchronize { @failures.clear }
199
+ end
200
+
201
+ private
202
+
203
+ # The isolation. A subscriber is a host's code running inside our stack,
204
+ # and a metrics client with a full queue or a typo in a tag must not be
205
+ # able to fail a sanctions sync.
206
+ sig { params(instrumenter: T.untyped, event: Event).void }
207
+ def deliver(instrumenter, event)
208
+ instrumenter.call(event)
209
+ rescue StandardError => e
210
+ report(instrumenter, event, e)
211
+ end
212
+
213
+ # Dropped, but never silently: a subscriber that is not recording
214
+ # anything is a dashboard that is quietly wrong, which is worse than one
215
+ # that is visibly missing.
216
+ #
217
+ # Reported once per subscriber, event name and exception class, the way
218
+ # Deprecation reports once per call site and for the same reason -- a
219
+ # subscriber that raises on `:screen` raises on every query, and a
220
+ # service at any volume would spend more of its log on this than on its
221
+ # own work. Call .reset! to hear about it again.
222
+ sig { params(instrumenter: T.untyped, event: Event, error: StandardError).void }
223
+ def report(instrumenter, event, error)
224
+ return unless first_time?("#{instrumenter.class}/#{event.name}/#{error.class}")
225
+
226
+ message = "[active_sanction] instrumenter #{instrumenter.class} raised on the #{event.name} event " \
227
+ "(#{error.class}: #{error.message}); the event was dropped and the work carried on. " \
228
+ "Further failures of this shape are not reported."
229
+ logger = ActiveSanction.config.logger
230
+ return Kernel.warn(message) if logger.nil?
231
+
232
+ logger.respond_to?(:warn) ? logger.warn(message) : logger.info(message)
233
+ end
234
+
235
+ sig { params(key: String).returns(T::Boolean) }
236
+ def first_time?(key)
237
+ @mutex.synchronize do
238
+ next false if @failures.key?(key)
239
+
240
+ @failures[key] = true
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end