active_sanction 1.0.0 → 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
+ 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
@@ -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