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