active_sanction 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -6,6 +6,7 @@ require "sorbet-runtime"
6
6
  require "time"
7
7
  require "active_sanction/error"
8
8
  require "active_sanction/index"
9
+ require "active_sanction/instrumentation"
9
10
  require "active_sanction/match_result"
10
11
  require "active_sanction/query"
11
12
  require "active_sanction/scorer"
@@ -131,6 +132,14 @@ module ActiveSanction
131
132
  sig { returns(Symbol) }
132
133
  attr_reader :backend
133
134
 
135
+ # Where the `:screen` event goes, or nil for nothing listening. Read once
136
+ # at construction and frozen with everything else here, which is the rule
137
+ # the class comment states for the whole query path: a subscriber swapped
138
+ # halfway through a batch cannot make half of it instrumented. See
139
+ # Instrumentation.
140
+ sig { returns(T.untyped) }
141
+ attr_reader :instrumenter
142
+
134
143
  class << self
135
144
  extend T::Sig
136
145
 
@@ -152,14 +161,29 @@ module ActiveSanction
152
161
  # covers all three, and both report the name clear.
153
162
  sig do
154
163
  params(store: T.untyped, sources: T.untyped, weights: T.untyped, candidate_limit: T.untyped,
155
- backend: T.untyped).returns(Matcher)
164
+ backend: T.untyped, instrumenter: T.untyped).returns(Matcher)
156
165
  end
157
166
  def build(store = nil, sources: nil, weights: nil, candidate_limit: nil,
158
- backend: MatchResult::DEFAULT_BACKEND)
167
+ backend: MatchResult::DEFAULT_BACKEND, instrumenter: nil)
159
168
  store ||= ActiveSanction.config.storage
169
+ listening = instrumenter.nil? ? ActiveSanction.config.instrumenter : instrumenter
170
+ built = Instrumentation.instrument(listening, :"index.build", { store: store.class.name }) do |event|
171
+ index_over(store, sources, event)
172
+ end
173
+ new(index: built.fetch(:index), snapshots: built.fetch(:checksums), verified: built.fetch(:attested),
174
+ weights: weights, candidate_limit: candidate_limit, backend: backend, instrumenter: listening)
175
+ end
176
+
177
+ private
178
+
179
+ # Every list this matcher will hold, read one at a time and released
180
+ # before the next is opened, and what it cost.
181
+ sig { params(store: T.untyped, sources: T.untyped, event: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
182
+ def index_over(store, sources, event)
160
183
  builder = Index::Builder.new
161
184
  checksums = T.let({}, T::Hash[Symbol, String])
162
185
  attested = T.let([], T::Array[Symbol])
186
+ entities = 0
163
187
  requested(store, sources).each do |key|
164
188
  snapshot = store.fetch_snapshot(key)
165
189
  checksums[key] = snapshot.checksum
@@ -168,12 +192,27 @@ module ActiveSanction
168
192
  # different list.
169
193
  attested << key if snapshot.trusted?
170
194
  snapshot.entities.each { |entity| builder.add(entity) }
195
+ entities += snapshot.record_count
171
196
  end
172
- new(index: builder.build, snapshots: checksums, verified: attested, weights: weights,
173
- candidate_limit: candidate_limit, backend: backend)
197
+ index = builder.build
198
+ measure(event, index, checksums, entities)
199
+ { index: index, checksums: checksums, attested: attested }
174
200
  end
175
201
 
176
- private
202
+ # What a host watches at boot and after every sync: how long an index
203
+ # took to build, how much of it there is, and roughly what it weighs.
204
+ # `bytes` is an estimate and says so -- see Index#profile, which is
205
+ # where the assumptions behind the number are written down, and where
206
+ # the entity count deliberately does not come from.
207
+ sig do
208
+ params(event: T.untyped, index: Index, checksums: T::Hash[Symbol, String], entities: Integer).void
209
+ end
210
+ def measure(event, index, checksums, entities)
211
+ event[:sources] = checksums.keys
212
+ event[:snapshots] = checksums
213
+ event[:entities] = entities
214
+ index.profile.each { |name, value| event[name] = value }
215
+ end
177
216
 
178
217
  # The lists to index, in a deterministic order, or the exception that
179
218
  # says why there are none.
@@ -204,10 +243,10 @@ module ActiveSanction
204
243
  # one name against several list versions, or a spec.
205
244
  sig do
206
245
  params(index: Index, snapshots: T.untyped, weights: T.untyped, candidate_limit: T.untyped,
207
- backend: T.untyped, verified: T.untyped).void
246
+ backend: T.untyped, verified: T.untyped, instrumenter: T.untyped).void
208
247
  end
209
248
  def initialize(index:, snapshots:, weights: nil, candidate_limit: nil, backend: MatchResult::DEFAULT_BACKEND,
210
- verified: nil)
249
+ verified: nil, instrumenter: nil)
211
250
  @index = index
212
251
  @snapshots = T.let(snapshots!(snapshots), T::Hash[Symbol, String])
213
252
  @verified = T.let(verified!(verified), T::Array[Symbol])
@@ -216,6 +255,7 @@ module ActiveSanction
216
255
  @weights = T.let(Scorer::Weights.build(weights), Scorer::Weights)
217
256
  @candidate_limit = T.let(candidate_limit!(candidate_limit), Integer)
218
257
  @backend = T.let(backend.to_s.to_sym, Symbol)
258
+ @instrumenter = T.let(instrumenter.nil? ? ActiveSanction.config.instrumenter : instrumenter, T.untyped)
219
259
  freeze
220
260
  end
221
261
 
@@ -303,13 +343,31 @@ module ActiveSanction
303
343
  # weights, one instant, one backend. Only the snapshot checksum varies,
304
344
  # and only because a run may cover several lists.
305
345
  stamp = { query: query, weights: weights, backend: backend, screened_at: screened_at }
306
- scored(query)
307
- .sort_by { |result| [-result.score, result.source.to_s, result.entity.id] }
308
- .first(query.limit)
309
- .map do |result|
310
- MatchResult.from_scorer(result, snapshot_id: snapshots.fetch(result.source),
311
- verified: verified.include?(result.source), **stamp)
312
- end
346
+ Instrumentation.instrument(instrumenter, :screen) do |event|
347
+ results = scored(query, event)
348
+ .sort_by { |result| [-result.score, result.source.to_s, result.entity.id] }
349
+ .first(query.limit)
350
+ .map do |result|
351
+ MatchResult.from_scorer(result, snapshot_id: snapshots.fetch(result.source),
352
+ verified: verified.include?(result.source), **stamp)
353
+ end
354
+ describe(event, query, results)
355
+ results
356
+ end
357
+ end
358
+
359
+ # What one query cost and what it consulted. `snapshots` is the whole
360
+ # checksum map rather than a count, because the question an audit asks of
361
+ # a screening event is which list *versions* answered it -- the same
362
+ # question every MatchResult is stamped with, and the only one a result
363
+ # set of zero cannot answer for itself.
364
+ sig { params(event: T.untyped, query: Query, results: T::Array[MatchResult]).void }
365
+ def describe(event, query, results)
366
+ event[:results] = results.size
367
+ event[:threshold] = query.threshold
368
+ event[:limit] = query.limit
369
+ event[:sources] = query.sources || sources
370
+ event[:snapshots] = snapshots
313
371
  end
314
372
 
315
373
  # Every entity the index retrieved, scored once.
@@ -321,10 +379,11 @@ module ActiveSanction
321
379
  # retrieves the same record under several spellings, and rescoring one
322
380
  # that has already failed the threshold is the most expensive way to
323
381
  # arrive at the same no.
324
- sig { params(query: Query).returns(T::Array[Scorer::Result]) }
325
- def scored(query)
382
+ sig { params(query: Query, event: T.untyped).returns(T::Array[Scorer::Result]) }
383
+ def scored(query, event)
326
384
  seen = T.let({}, T::Hash[[Symbol, String], T.nilable(Scorer::Result)])
327
- index.candidates(query.form, limit: candidate_limit, sources: query.sources).each do |candidate|
385
+ retrieved = index.candidates(query.form, limit: candidate_limit, sources: query.sources)
386
+ retrieved.each do |candidate|
328
387
  # Keyed by list as well as id, because the same person really is two
329
388
  # records when two governments list them, and both belong in a report.
330
389
  key = [candidate.source, candidate.entity.id]
@@ -332,6 +391,12 @@ module ActiveSanction
332
391
 
333
392
  seen[key] = Scorer.call(query.subject, candidate, weights: weights, threshold: query.threshold)
334
393
  end
394
+ # Names retrieved, and the entities they came down to. The two differ by
395
+ # however many aliases of one record the query looked like, and a
396
+ # candidate cap is a cap on the first rather than the second -- which is
397
+ # the number to watch when tuning it.
398
+ event[:candidates] = retrieved.size
399
+ event[:scored] = seen.size
335
400
  seen.values.compact
336
401
  end
337
402
 
@@ -8,6 +8,7 @@ require "active_sanction/error"
8
8
  require "active_sanction/entity"
9
9
  require "active_sanction/snapshot"
10
10
  require "active_sanction/fetcher"
11
+ require "active_sanction/instrumentation"
11
12
  require "active_sanction/parsers"
12
13
  require "active_sanction/payload_cache"
13
14
  require "active_sanction/sources"
@@ -82,17 +83,25 @@ module ActiveSanction
82
83
  sig { returns(T.untyped) }
83
84
  attr_reader :logger
84
85
 
86
+ # Where this adapter's `:parse` event goes, or nil for nothing
87
+ # listening. See Instrumentation.
88
+ sig { returns(T.untyped) }
89
+ attr_reader :instrumenter
90
+
85
91
  # `cache: nil` turns off payload caching, which costs one thing worth
86
92
  # knowing: a multi-file source can no longer answer a sync where some of
87
93
  # its files changed and others came back 304, so the unchanged ones are
88
94
  # downloaded again in full.
89
95
  sig do
90
- params(fetcher: Fetcher, cache: T.nilable(PayloadCache), logger: T.untyped).void
96
+ params(fetcher: Fetcher, cache: T.nilable(PayloadCache), logger: T.untyped,
97
+ instrumenter: T.untyped).void
91
98
  end
92
- def initialize(fetcher: Fetcher.new, cache: PayloadCache.new, logger: ActiveSanction.config.logger)
99
+ def initialize(fetcher: Fetcher.new, cache: PayloadCache.new, logger: ActiveSanction.config.logger,
100
+ instrumenter: ActiveSanction.config.instrumenter)
93
101
  @fetcher = T.let(fetcher, Fetcher)
94
102
  @cache = T.let(cache, T.nilable(PayloadCache))
95
103
  @logger = T.let(logger, T.untyped)
104
+ @instrumenter = T.let(instrumenter, T.untyped)
96
105
  @results = T.let({}, T::Hash[Symbol, Fetcher::Result])
97
106
  end
98
107
 
@@ -137,6 +146,20 @@ module ActiveSanction
137
146
  "#{self.class} must implement #parse(raw) and return an Array of ActiveSanction::Entity"
138
147
  end
139
148
 
149
+ # What the last #parse could not read: a Parsers::Warning per row that
150
+ # was skipped or could not be mapped, kept rather than raised. Every
151
+ # shipped adapter overrides this with the parser's own warnings plus
152
+ # whatever it noticed itself, which is what the adapter rules require of
153
+ # a new one.
154
+ #
155
+ # Empty here rather than abstract, because an adapter that genuinely
156
+ # cannot fail to read a row should not have to say so, and because the
157
+ # `:parse` event counts these for every source and a count that is
158
+ # sometimes a NoMethodError is not a metric. See Doctor, which reads the
159
+ # warnings themselves rather than the count.
160
+ sig { returns(T::Array[Parsers::Warning]) }
161
+ def warnings = []
162
+
140
163
  # Fetches, parses, and checksums -- or returns nil when the publisher
141
164
  # says nothing has changed, which is the outcome to expect on most runs
142
165
  # and the reason conditional GET exists.
@@ -157,8 +180,15 @@ module ActiveSanction
157
180
  # or, for a source that declares a single file, as the bytes themselves.
158
181
  sig { params(payloads: T.untyped, files: T.untyped).returns(Snapshot) }
159
182
  def snapshot(payloads = nil, **files)
160
- Snapshot.new(source: key, entities: parse(parse_argument(payloads || files)),
161
- fetched_at: Time.now.utc, source_version: source_version)
183
+ raw = parse_argument(payloads || files)
184
+ entities = Instrumentation.instrument(instrumenter, :parse,
185
+ { source: declared_key, bytes: byte_count(raw) }) do |event|
186
+ parsed = parse(raw)
187
+ event[:records] = parsed.size
188
+ event[:warnings] = warnings.size
189
+ parsed
190
+ end
191
+ Snapshot.new(source: key, entities: entities, fetched_at: Time.now.utc, source_version: source_version)
162
192
  rescue ActiveSanction::Error => e
163
193
  raise e.in_source(declared_key)
164
194
  end
@@ -250,7 +280,19 @@ module ActiveSanction
250
280
 
251
281
  sig { params(name: Symbol, address: String, force: T::Boolean).returns(Fetcher::Result) }
252
282
  def fetch_file(name, address, force)
253
- fetcher.fetch(address, key: file_key(name), force: force).success!
283
+ fetcher.fetch(address, key: file_key(name), force: force, source: declared_key).success!
284
+ end
285
+
286
+ # How many bytes #parse was handed, across every file of a multi-file
287
+ # source. Taken before the parse rather than after, so the `:parse`
288
+ # event still says how large the document was when the parse is what
289
+ # raised.
290
+ sig { params(raw: T.untyped).returns(Integer) }
291
+ def byte_count(raw)
292
+ return raw.bytesize if raw.is_a?(String)
293
+ return raw.to_h.each_value.sum { |payload| payload.to_s.bytesize } if raw.respond_to?(:to_h)
294
+
295
+ raw.to_s.bytesize
254
296
  end
255
297
 
256
298
  sig { params(name: Symbol).returns(T.untyped) }
@@ -283,7 +325,7 @@ module ActiveSanction
283
325
  sig { params(name: Symbol).returns(Fetcher::Result) }
284
326
  def refetch(name)
285
327
  logger&.info("[active_sanction] #{key} #{name} unchanged but not cached; fetching in full")
286
- result = fetcher.fetch(url(name), key: file_key(name), force: true).success!
328
+ result = fetcher.fetch(url(name), key: file_key(name), force: true, source: declared_key).success!
287
329
  return result if result.changed?
288
330
 
289
331
  raise MissingPayload,