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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +120 -0
- data/CONTRIBUTING.md +69 -5
- data/README.md +1 -0
- data/docs/api_stability.md +47 -2
- data/lib/active_sanction/client.rb +11 -2
- data/lib/active_sanction/configuration.rb +39 -0
- data/lib/active_sanction/fetcher.rb +38 -12
- data/lib/active_sanction/index.rb +61 -0
- data/lib/active_sanction/instrumentation/event.rb +122 -0
- data/lib/active_sanction/instrumentation/notifications.rb +122 -0
- data/lib/active_sanction/instrumentation.rb +245 -0
- data/lib/active_sanction/matcher.rb +82 -17
- data/lib/active_sanction/sources/base.rb +48 -6
- data/lib/active_sanction/storage/meta.rb +18 -0
- data/lib/active_sanction/sync.rb +54 -6
- data/lib/active_sanction/version.rb +1 -1
- data/lib/active_sanction.rb +1 -0
- metadata +5 -2
|
@@ -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
|
-
|
|
173
|
-
|
|
197
|
+
index = builder.build
|
|
198
|
+
measure(event, index, checksums, entities)
|
|
199
|
+
{ index: index, checksums: checksums, attested: attested }
|
|
174
200
|
end
|
|
175
201
|
|
|
176
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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)
|
|
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
|
|