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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6c9a31bd72dfdc43d5614499a19aab837b55996b9644ae145b89da18b93bea6
4
- data.tar.gz: 40cbea7b7fcf458307171ca953fb61864dce06da7733ece2ac17e4d27a43215d
3
+ metadata.gz: ecdae0214290d15cba0bf24838c8e4d2d2e004eaecb344c29aaa8c07d54da36b
4
+ data.tar.gz: c72d3f5baa535d081aae672de2013585e38c85c1cceb5c2a697d6cecc7d5f98a
5
5
  SHA512:
6
- metadata.gz: 892209a20496c2eea1185776a83725cb89ae6c7b40ba2385becf7870b78d8e1a6cf88734e29afec40153418dfa73ac275a3270b0bb16a80e56c3de6605e58652
7
- data.tar.gz: e3330620e4c4f6c31c6a7e0c58b53bda9972628e9d6965798d56f666f7301b83ccc2edb42931d15b18b161c9480df96b5147edf0e94b77461e90e0958944ed46
6
+ metadata.gz: 5fc3e4e17279f0220f903792d952d4aa1adfa1068504512f51cbacb2247cc4ae71710b38c4de6374810e0c48bcc03052a7149eff9a69dda9ebc02389e27c8f05
7
+ data.tar.gz: 34df5f7d1103073a4bd50956abc77e1e87a9a3c5a213be9c18a46a6e41ccbdd5db64fcf0c7b7437d4c52e33f6876ea581ba57e04a44baa95d3ecec1666a8e315
data/CHANGELOG.md CHANGED
@@ -17,6 +17,113 @@ screening decision would come out as today.
17
17
 
18
18
  Nothing yet.
19
19
 
20
+ ## [1.1.1] - 2026-09-14
21
+
22
+ ### Added
23
+
24
+ - **The conformance groups ship** (#144). `require "active_sanction/testing"` loads the two
25
+ shared example groups that define what a source adapter and a storage adapter must do, so
26
+ an adapter written in your own application is held to the same contract the built-in ones
27
+ are:
28
+
29
+ ```ruby
30
+ # spec/spec_helper.rb
31
+ require "active_sanction/testing"
32
+
33
+ # spec/internal_watchlist_spec.rb
34
+ RSpec.describe MyCompany::InternalWatchlist do
35
+ it_behaves_like "a sanction source", fixture: "internal_watchlist/list.csv"
36
+ end
37
+ ```
38
+
39
+ They were always the stated contract — `docs/api_stability.md` calls them "the executable
40
+ statement of what [the extension points] require" — and they lived under `spec/`, which is
41
+ excluded from the packaged gem. The audience for the promise was the one group of people
42
+ who could not run it. Fixture paths resolve under `spec/fixtures` unless
43
+ `ActiveSanction::Testing.fixture_root` says otherwise, and an absolute path is taken as it
44
+ stands.
45
+
46
+ **`require "active_sanction"` does not load any of it**, so nothing reaches a production
47
+ process; requiring it without RSpec says so rather than failing somewhere stranger. The
48
+ group names and the options they take are public API from here, and a group gaining an
49
+ example is a minor with a note here naming it.
50
+
51
+ - **Preparing a release is a button** (#146). `Prepare release` in the Actions tab takes a
52
+ version, bumps `VERSION`, moves the accrued `Unreleased` section under a dated heading,
53
+ fixes the changelog's link definitions, regenerates the site data that carries the
54
+ version, and opens a pull request. `release.yml` verifies all of that and writes none of
55
+ it, so until now it was a hand-edit somebody had to remember — and hand-edits to this
56
+ particular shape have already gone wrong silently once, when two open pull requests
57
+ merged cleanly in sequence and filed a feature under a patch release that did not contain
58
+ it.
59
+
60
+ The edits are [`bin/prepare_release`](bin/prepare_release), with a spec over them, so the
61
+ workflow is reviewable as Ruby and `bin/prepare_release 1.2.3` does the same thing on a
62
+ laptop. It refuses a version that does not come after the current one, and refuses to
63
+ prepare a release out of an empty `Unreleased` — which `release.yml` also refuses, but
64
+ only after the gem is published. Nothing is tagged or pushed to `main`: the output is a
65
+ pull request, and the version number stays a judgment a person makes.
66
+
67
+ - **`Client#supports?`** (#144), so a caller can ask what an implementation does rather than
68
+ find out by rescuing `NoMethodError`. This client supports all of
69
+ `Client::CAPABILITIES`; the method is for the implementations that are not this class —
70
+ one answering screening questions against data somebody else keeps fresh has no `sync!`
71
+ to offer. An unrecognised capability is `false` rather than an error, deliberately unlike
72
+ `Configuration`: the caller asking is usually written against a newer version than the one
73
+ answering, and wants a fallback path rather than an exception.
74
+
75
+ ## [1.1.0] - 2026-09-14
76
+
77
+ **A minor, because it adds.** Nothing that existed changed: no behaviour under `lib/`
78
+ moved, `MATCHER_VERSION` stays at `1`, and a name scores today exactly what it scored
79
+ under 1.0.0. Instrumentation is additive and off unless a host asks for it.
80
+
81
+ ### Added
82
+
83
+ - **Instrumentation: six structured events, so a host can measure this library without
84
+ monkeypatching it** (#59). `ActiveSanction.configure { |c| c.instrumenter = ... }` takes
85
+ anything answering `#call(event)` and is handed a finished
86
+ `ActiveSanction::Instrumentation::Event` for each of `:fetch`, `:parse`, `:store`,
87
+ `:"index.build"`, `:screen` and `:sync` — every one carrying a duration and the ids
88
+ needed to correlate it, with no anonymous timings. The event names and every payload key
89
+ are public API, enumerated in [`docs/api_stability.md`](docs/api_stability.md) and on the
90
+ site's [instrumentation reference](https://babystep.tech/active_sanction/reference/instrumentation/),
91
+ and covered by the deprecation path from here.
92
+
93
+ **It is a measurement of the work and never part of it.** A subscriber that raises has its
94
+ exception caught, reported once through the configured logger, and dropped; the sync it
95
+ was watching finishes and returns the report it was going to return. The converse holds
96
+ too: a stage that raises emits its event with `error:` set and then the exception
97
+ continues exactly as if nothing were listening.
98
+
99
+ **Nothing is listening by default, and that costs nothing.** `nil` is a branch taken
100
+ before anything is allocated rather than a no-op object that gets called, so an
101
+ uninstrumented screening call builds no event and allocates no payload — which is the
102
+ only way a per-query event could be affordable at all. Measured rather than asserted:
103
+ building a matcher over 47,051 names allocates 262 more objects than before, out of
104
+ 5.79 million, and `rake benchmark:latency` reports the same p50 either side of the change
105
+ (12.1–12.8 ms against a run-to-run spread that was already that wide).
106
+
107
+ Rails hosts get `ActiveSanction::Instrumentation::Notifications`, which republishes every
108
+ event into `ActiveSupport::Notifications` under `<event>.active_sanction`. It is an
109
+ adapter and not a dependency: **nothing in this gem requires ActiveSupport**, and building
110
+ one in a process that has not loaded it raises `ConfigurationError` rather than quietly
111
+ instrumenting nothing.
112
+
113
+ The issue asked for `ActiveSanction.instrumenter = ...`, and this is a configuration
114
+ setting instead. #55 ended process-global configuration deliberately, and a module-level
115
+ writer would have rebuilt the default client — dropping the matcher it had indexed every
116
+ stored list into — as a side effect of naming a subscriber. A `Matcher` takes its
117
+ instrumenter at build and freezes it with its weights, so a subscriber swapped halfway
118
+ through a batch cannot make half of it instrumented; that is the rule every other setting
119
+ on the query path already follows.
120
+
121
+ - **`Sources::Base#warnings`**, defaulting to none. Every shipped adapter already exposed
122
+ it and the adapter rules already required it of a new one, but the base class never said
123
+ so — and the `:parse` event counts warnings for every source, which a count that is
124
+ sometimes a `NoMethodError` cannot do. An optional hook with a default implementation, so
125
+ no adapter outside this repository has to change.
126
+
20
127
  ## [1.0.1] - 2026-09-14
21
128
 
22
129
  A packaging and release-tooling release. **Nothing about screening changes**: no behaviour
@@ -600,6 +707,8 @@ summarized here because they are what a reader of a first release most needs:
600
707
  - Recall at the default threshold is 0.939 overall on the labeled set, and every record this
601
708
  version misses is named in the committed accuracy report.
602
709
 
603
- [Unreleased]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.0.1...main
710
+ [Unreleased]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.1.1...main
711
+ [1.1.1]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.1.0...v1.1.1
712
+ [1.1.0]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.0.1...v1.1.0
604
713
  [1.0.1]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.0.0...v1.0.1
605
714
  [1.0.0]: https://github.com/Babystep-Technologies/active_sanction/releases/tag/v1.0.0
data/CONTRIBUTING.md CHANGED
@@ -234,10 +234,27 @@ comment restating the name would be noise.
234
234
 
235
235
  `bundle exec rake install` installs the gem locally.
236
236
 
237
- A release is a tag. Bump `VERSION` in
238
- [`lib/active_sanction/version.rb`](lib/active_sanction/version.rb), move the
239
- `Unreleased` section of [`CHANGELOG.md`](CHANGELOG.md) under the new version
240
- with its date, and merge that.
237
+ A release is a tag, and two facts have to be true on `main` before one can
238
+ exist: `VERSION` says the version, and `CHANGELOG.md` has a section for it.
239
+ `release.yml` checks both and writes neither.
240
+
241
+ **Putting them there is a button too.** Open
242
+ [`Prepare release`](https://github.com/Babystep-Technologies/active_sanction/actions/workflows/prepare-release.yml),
243
+ press **Run workflow**, enter `1.2.3`. It bumps `VERSION`, moves the accrued
244
+ `Unreleased` section under a dated `## [1.2.3]` heading, leaves a fresh empty
245
+ `Unreleased` behind, fixes the link definitions at the foot of the changelog,
246
+ regenerates the one site data file that carries the version, and opens a pull
247
+ request. CI runs on it like any other.
248
+
249
+ The edits are [`bin/prepare_release`](bin/prepare_release), so
250
+ `bin/prepare_release 1.2.3` does exactly the same thing on your machine if you
251
+ would rather commit it yourself. It refuses a version that does not come after
252
+ the current one, and refuses to prepare a release out of an empty `Unreleased`
253
+ — which `release.yml` also refuses, but only in the job that writes the GitHub
254
+ release, long after the gem is published.
255
+
256
+ **Read the moved changelog section before merging.** It becomes the release
257
+ note verbatim.
241
258
 
242
259
  Then the tag, which you can let the workflow write or write yourself.
243
260
 
data/README.md CHANGED
@@ -288,7 +288,7 @@ A source registered from outside this gem is a first-class source: a bank's inte
288
288
 
289
289
  There is no scaffold generator, deliberately. Roughly eight adapters at maturity do not repay one that has to be kept in step with `Sources::Base`, the conformance spec and the parser toolkits, and that goes stale silently when it is not; the document plus the closest existing adapter to copy does the same job with none of the upkeep.
290
290
 
291
- Every source adapter is held to one shared example group, `"a sanction source"` (`spec/support/shared_examples/sanction_source.rb`), which checks what everything downstream of an adapter assumes and cannot check for itself — a declared key, jurisdiction, authority and URL; `Entity` objects with unique, deterministic ids; dates as `PartialDate`; a round trip through `#to_h`; the publisher's own text kept in `remarks`. It is the floor and not the ceiling, so every adapter still writes its own spec on top.
291
+ Every source adapter is held to one shared example group, `"a sanction source"` — shipped, so an adapter written outside this repository is held to it too: `require "active_sanction/testing"`. It checks what everything downstream of an adapter assumes and cannot check for itself — a declared key, jurisdiction, authority and URL; `Entity` objects with unique, deterministic ids; dates as `PartialDate`; a round trip through `#to_h`; the publisher's own text kept in `remarks`. It is the floor and not the ceiling, so every adapter still writes its own spec on top.
292
292
 
293
293
  ## How it works
294
294
 
@@ -691,7 +691,7 @@ There is one option: `remarks: false`, for a list that publishes no free text of
691
691
  its own anywhere — Canada is the only launch source that qualifies. It has to be
692
692
  asked for, so that dropping a remark by accident stays a failure.
693
693
 
694
- The group lives in `spec/support/shared_examples/sanction_source.rb` and checks
694
+ The group lives in `lib/active_sanction/testing/sanction_source.rb`, reaches a suite through `require "active_sanction/testing"`, and checks
695
695
  what everything downstream assumes and cannot check for itself: that the adapter
696
696
  declares a key, a jurisdiction, an authority and a URL and registers itself; that
697
697
  `#parse` returns Entities with unique, deterministic ids, a canonical type and at
@@ -4,7 +4,7 @@ What this gem promises not to break, and what it reserves the right to change
4
4
  in any release.
5
5
 
6
6
  **The public surface is enumerated below, not inferred.** A constant being
7
- reachable does not make it public; nearly 500 of them are reachable and 137 are
7
+ reachable does not make it public; over 500 of them are reachable and 142 are
8
8
  promised. Everything else is marked `@api private` in the source, is hidden
9
9
  from the rendered documentation, and may be renamed, moved or deleted in a
10
10
  patch release without a note anywhere. If you need something that is not on
@@ -96,10 +96,11 @@ watchlist, a store backed by somebody's own database — and those authors are
96
96
  not reading this repository's release notes. So the required methods of each,
97
97
  their arguments and what they must return do not change within a major version,
98
98
  and the shared conformance groups
99
- ([`spec/active_sanction/sources/conformance_spec.rb`](../spec/active_sanction/sources/conformance_spec.rb),
100
- [`spec/active_sanction/storage/conformance_spec.rb`](../spec/active_sanction/storage/conformance_spec.rb))
99
+ ([`lib/active_sanction/testing/sanction_source.rb`](../lib/active_sanction/testing/sanction_source.rb),
100
+ [`lib/active_sanction/testing/storage_adapter.rb`](../lib/active_sanction/testing/storage_adapter.rb))
101
101
  are the executable statement of what they require. An adapter that passes them
102
- today passes them for the life of the major version.
102
+ today passes them for the life of the major version — and **they ship**, so an
103
+ adapter outside this repository can run them: `require "active_sanction/testing"`.
103
104
 
104
105
  New *optional* hooks may be added — a method with a default implementation on
105
106
  the base class is not a break, because an adapter that does not define it goes
@@ -107,7 +108,7 @@ on working.
107
108
 
108
109
  ## Contracts that are not constants
109
110
 
110
- Four promises here are about behaviour rather than about a name, and none of
111
+ Five promises here are about behaviour rather than about a name, and none of
111
112
  them is enforceable by the surface spec.
112
113
 
113
114
  **The error hierarchy.** Within a major version an error does not move to a
@@ -128,6 +129,35 @@ anyone may produce or consume one, in any language.
128
129
  store written against one schema version keeps being readable; the file layout
129
130
  underneath a shipped store is not public and may change.
130
131
 
132
+ **The instrumentation events.** `ActiveSanction::Instrumentation::EVENTS`
133
+ names the six, and each one's payload keys are promised the same way a method
134
+ signature is: within a major version a key is not removed, renamed, or made to
135
+ mean something else, and a dashboard written against one keeps working. Keys
136
+ may be **added** to an event — that is how a new measurement ships without a
137
+ major version — so a subscriber reads the keys it knows and ignores the rest,
138
+ and must not assume the set is closed.
139
+
140
+ Every event carries `name`, `started_at` and `duration`, plus the keys below.
141
+ `error` is present only when the stage raised, in which case the keys it had
142
+ not reached yet are **absent rather than zero**.
143
+
144
+ | Event | Payload keys |
145
+ |---|---|
146
+ | `:fetch` | `source`, `key`, `url`, `forced`, `conditional`, `status`, `not_modified`, `bytes` |
147
+ | `:parse` | `source`, `bytes`, `records`, `warnings` |
148
+ | `:store` | `source`, `snapshot_id`, `entities`, `store`, `imported` (on an import only) |
149
+ | `:"index.build"` | `store`, `sources`, `snapshots`, `entities`, `names`, `keys`, `postings`, `bytes` |
150
+ | `:screen` | `candidates`, `scored`, `results`, `threshold`, `limit`, `sources`, `snapshots` |
151
+ | `:sync` | `sources`, `forced`, `concurrency`, `outcomes`, `updated`, `unchanged`, `failed`, `records` |
152
+
153
+ Two things about them are contracts rather than incidental. **A `:fetch` event
154
+ is one HTTP round trip** — a file served out of the payload cache after a 304
155
+ costs no request and emits nothing, and a source re-fetching one because its
156
+ cache was empty emits a second event rather than amending the first. And
157
+ **`bytes` on `:"index.build"` is an estimate**, documented as one on
158
+ `Index#profile`; it is good to within a factor a dashboard cares about and is
159
+ not a heap measurement.
160
+
131
161
  **A `MatchResult` is reproducible.** The snapshot checksum, matcher version,
132
162
  weights and query it stamps are what let a screening decision be re-derived
133
163
  years later. Fields may be added to that stamp; the meaning of an existing one
@@ -138,6 +168,12 @@ does not change under it.
138
168
  Named here because their absence from the list is a decision rather than an
139
169
  oversight:
140
170
 
171
+ - **The instrumenter contract's other half.** A subscriber is anything
172
+ answering `#call(event)`, and that is public. `Instrumentation.instrument`
173
+ and `.emit`, which the library's own stages call, are not: where an event is
174
+ emitted from is an implementation detail of the stage, and a host that
175
+ wanted to emit one of these names itself would be publishing a measurement
176
+ of something this library did not do.
141
177
  - **The matching internals** — `Index`, `Similarity`, `Phonetics`, and the
142
178
  scorer's `Adjustments` and `NameScore`. These are where accuracy work
143
179
  happens, and accuracy work that had to preserve a signature would stop.
@@ -158,8 +194,10 @@ oversight:
158
194
  themselves are public — they are how an adapter is written — but the classes
159
195
  that do the reading underneath them are not.
160
196
  - **Anything under `spec/`, `benchmark/`, `canary/` or `bin/`.** None of it
161
- ships in the gem. The one exception in spirit is the two conformance groups,
162
- which do not ship either but which an adapter author is expected to run.
197
+ ships in the gem. The two conformance groups used to be the exception in
198
+ spirit expected to be run by adapter authors who had no way to get them —
199
+ and they are now under `lib/active_sanction/testing/` and public, which is
200
+ what the section above is about.
163
201
 
164
202
  ## The enumerated public surface
165
203
 
@@ -172,6 +210,7 @@ ActiveSanction
172
210
  ActiveSanction::VERSION
173
211
  ActiveSanction::MATCHER_VERSION
174
212
  ActiveSanction::Client
213
+ ActiveSanction::Client::CAPABILITIES
175
214
  ActiveSanction::Configuration
176
215
  ActiveSanction::Deprecation
177
216
  ActiveSanction::Configuration::DEFAULT_CACHE_DIRNAME
@@ -193,6 +232,16 @@ ActiveSanction::Configuration::DEFAULT_USER_AGENT
193
232
  ActiveSanction::Configuration::DEFAULT_XML_BACKEND
194
233
  ```
195
234
 
235
+ ### Instrumentation
236
+
237
+ ```
238
+ ActiveSanction::Instrumentation
239
+ ActiveSanction::Instrumentation::EVENTS
240
+ ActiveSanction::Instrumentation::Event
241
+ ActiveSanction::Instrumentation::Notifications
242
+ ActiveSanction::Instrumentation::Notifications::NAMESPACE
243
+ ```
244
+
196
245
  ### The canonical record
197
246
 
198
247
  ```
@@ -255,6 +304,29 @@ ActiveSanction::Parsers::Warning
255
304
  ActiveSanction::Parsers::ColumnShape
256
305
  ```
257
306
 
307
+ ### Testing your own adapter
308
+
309
+ ```
310
+ ActiveSanction::Testing
311
+ ActiveSanction::Testing::DEFAULT_FIXTURE_ROOT
312
+ ActiveSanction::Testing::StorageAdapterDefaults
313
+ ```
314
+
315
+ Loaded by `require "active_sanction/testing"`, never by `require
316
+ "active_sanction"`. The two shared example group **names** — `"a sanction
317
+ source"` and `"a storage adapter"` — are public on the same terms as the
318
+ constants: a group is not renamed or removed within a major version, and the
319
+ options it accepts do not change meaning under an adapter that passes it
320
+ today.
321
+
322
+ New examples may be **added** to a group, and that is not a breaking change
323
+ even though it can turn a passing adapter red. It is the same promise the
324
+ extension points make read from the other side: what the group checks is what
325
+ `Sources::Base` and `Storage::Base` required all along, and an adapter that
326
+ fails a newly added example was always violating the contract — the group
327
+ merely started saying so. Additions land in a minor, with a `CHANGELOG.md`
328
+ entry naming them.
329
+
258
330
  ### Storage, and what a fetch remembers
259
331
 
260
332
  ```
@@ -5,6 +5,7 @@ require "sorbet-runtime"
5
5
 
6
6
  require "active_sanction/configuration"
7
7
  require "active_sanction/error"
8
+ require "active_sanction/instrumentation"
8
9
  require "active_sanction/match_result"
9
10
 
10
11
  module ActiveSanction
@@ -78,6 +79,15 @@ module ActiveSanction
78
79
  class Client
79
80
  extend T::Sig
80
81
 
82
+ # What a client may be asked to do. Every one of them is a public method
83
+ # on this class, and this class supports all of them; see #supports?, and
84
+ # the note there on why an unknown name is false rather than an error.
85
+ #
86
+ # A name is not removed from this list within a major version, and a new
87
+ # one may be added -- which is the same promise the enumerated public
88
+ # surface makes, for the same reason.
89
+ CAPABILITIES = T.let(%i[screen screen_all sync diff rescreen doctor export import].freeze, T::Array[Symbol])
90
+
81
91
  # The settings this client answers from, frozen. Reading one is how a host
82
92
  # asks what a client is: `client.configuration.user_agent`.
83
93
  sig { returns(Configuration) }
@@ -157,7 +167,8 @@ module ActiveSanction
157
167
  @lock.synchronize do
158
168
  @matcher ||= with_configuration do
159
169
  Matcher.build(storage, sources: configuration.sources, weights: configuration.scorer_weights,
160
- candidate_limit: configuration.candidate_limit, backend: backend)
170
+ candidate_limit: configuration.candidate_limit, backend: backend,
171
+ instrumenter: configuration.instrumenter)
161
172
  end
162
173
  end
163
174
  end
@@ -286,7 +297,14 @@ module ActiveSanction
286
297
  sig { params(path: T.untyped, verify_with: T.untyped).returns(Snapshot) }
287
298
  def import(path, verify_with: nil)
288
299
  snapshot = ::File.open(path.to_s, "rb") { |io| Snapshot::Bundle.read(io, verify_with: verify_with) }
289
- with_configuration { storage.write_snapshot(snapshot) }
300
+ # The same `:store` event a sync emits, because it is the same fact: a
301
+ # list version was written to this store, and a host watching data
302
+ # freshness should not have to know which of the two ways it arrived.
303
+ fields = { source: snapshot.source, snapshot_id: snapshot.checksum, entities: snapshot.record_count,
304
+ store: storage.class.name, imported: true }
305
+ Instrumentation.instrument(configuration.instrumenter, :store, fields) do
306
+ with_configuration { storage.write_snapshot(snapshot) }
307
+ end
290
308
  reload!
291
309
  snapshot
292
310
  end
@@ -321,6 +339,34 @@ module ActiveSanction
321
339
  sig { returns(T::Boolean) }
322
340
  def loaded? = !@lock.synchronize { @matcher }.nil?
323
341
 
342
+ # Whether this client does a thing, asked rather than assumed:
343
+ #
344
+ # client.supports?(:sync) # => true
345
+ # client.supports?(:diff) # => true
346
+ #
347
+ # This client supports all of CAPABILITIES -- it fetches, parses, stores
348
+ # and screens on the machine it runs on, and there is nothing in the list
349
+ # it cannot do. The method exists for the ones that are not this class.
350
+ # #56 was closed because `Client` already is the interface an out-of-tree
351
+ # implementation duck-types, and the one thing a duck-typed interface
352
+ # cannot express is *absence*: an implementation that answers screening
353
+ # questions against data somebody else keeps fresh has no `sync!` to
354
+ # offer, and a caller that cannot ask ends up finding out by rescuing
355
+ # NoMethodError.
356
+ #
357
+ # ### An unknown capability is false rather than an error
358
+ #
359
+ # Deliberately unlike Configuration, where an unrecognised setting raises
360
+ # because a silently dropped one is a client running on a default somebody
361
+ # thinks they changed. The direction here is the opposite: this is a
362
+ # forward-compatibility question, and the caller asking it is usually
363
+ # written against a newer version than the one answering. A host asking
364
+ # `supports?(:something_1_4_added)` of a 1.2 client wants `false` and a
365
+ # fallback path, not an exception -- raising would make the method useless
366
+ # for the one job it has.
367
+ sig { params(capability: T.untyped).returns(T::Boolean) }
368
+ def supports?(capability) = CAPABILITIES.include?(capability.to_sym)
369
+
324
370
  sig { returns(String) }
325
371
  def inspect
326
372
  named = sources ? T.must(sources).join(", ") : "every registered source"
@@ -4,6 +4,7 @@
4
4
  require "sorbet-runtime"
5
5
 
6
6
  require "active_sanction/error"
7
+ require "active_sanction/instrumentation"
7
8
  require "active_sanction/normalizer"
8
9
  require "active_sanction/scorer/weights"
9
10
  require "active_sanction/version"
@@ -245,6 +246,11 @@ module ActiveSanction
245
246
  sig { returns(T.untyped) }
246
247
  attr_reader :logger
247
248
 
249
+ # Anything answering `#call(event)`, or nil for the default, which is that
250
+ # nothing is listening. See #instrumenter= and Instrumentation.
251
+ sig { returns(T.untyped) }
252
+ attr_reader :instrumenter
253
+
248
254
  sig { void }
249
255
  def initialize
250
256
  @user_agent = T.let(DEFAULT_USER_AGENT, String)
@@ -267,6 +273,7 @@ module ActiveSanction
267
273
  @normalizer_dictionary = T.let(Normalizer::Dictionary.default, Normalizer::Dictionary)
268
274
  @scorer_weights = T.let(Scorer::Weights.default, Scorer::Weights)
269
275
  @logger = T.let(nil, T.untyped)
276
+ @instrumenter = T.let(nil, T.untyped)
270
277
  @storage = T.let(nil, T.nilable(Storage::Base))
271
278
  @default_storage = T.let(nil, T.nilable(Storage::Base))
272
279
  end
@@ -569,6 +576,38 @@ module ActiveSanction
569
576
  @logger = value
570
577
  end
571
578
 
579
+ # Where this library's structured events go: a lambda, a Method, or any
580
+ # object answering `#call(event)`.
581
+ #
582
+ # c.instrumenter = ->(event) { StatsD.timing("sanctions.#{event.name}", event.duration_ms) }
583
+ # c.instrumenter = ActiveSanction::Instrumentation::Notifications.new # a Rails host
584
+ #
585
+ # Six events -- `:fetch`, `:parse`, `:store`, `:"index.build"`, `:screen`
586
+ # and `:sync` -- each carrying a duration and the ids needed to correlate
587
+ # it. Their payload keys are public API; see Instrumentation, which is
588
+ # where all of it is documented, and docs/api_stability.md, which
589
+ # enumerates the keys.
590
+ #
591
+ # The default is nil, and nil is a branch rather than a no-op object: an
592
+ # installation that instruments nothing pays nothing, which is the only
593
+ # way a per-query event could be affordable at all.
594
+ #
595
+ # This is read where a stage is *built* rather than where it runs -- a
596
+ # Matcher takes its instrumenter at `build` and freezes it, the way it
597
+ # freezes its weights -- so changing it here affects the next matcher,
598
+ # sync or fetcher and never one already running. That is the same rule
599
+ # every other setting on the query path follows, and it is what keeps a
600
+ # batch from being half one set of numbers and half another.
601
+ sig { params(value: T.untyped).void }
602
+ def instrumenter=(value)
603
+ unless value.nil? || value.respond_to?(:call)
604
+ raise ConfigurationError,
605
+ "instrumenter must respond to #call(event), got #{value.class}. See ActiveSanction::Instrumentation."
606
+ end
607
+
608
+ @instrumenter = value
609
+ end
610
+
572
611
  sig { returns(String) }
573
612
  def self.default_storage_dir
574
613
  -File.expand_path(File.join(Dir.home, DEFAULT_STORAGE_DIRNAME))
@@ -5,6 +5,7 @@ require "sorbet-runtime"
5
5
 
6
6
  require "active_sanction/error"
7
7
  require "active_sanction/http_client"
8
+ require "active_sanction/instrumentation"
8
9
  require "active_sanction/validators"
9
10
  require "active_sanction/validator_store"
10
11
  require "active_sanction/fetcher/result"
@@ -64,20 +65,28 @@ module ActiveSanction
64
65
  sig { returns(T.untyped) }
65
66
  attr_reader :logger
66
67
 
68
+ # Where the `:fetch` event goes, or nil for nothing listening. See
69
+ # Instrumentation.
70
+ sig { returns(T.untyped) }
71
+ attr_reader :instrumenter
72
+
67
73
  # The store defaults to disk, so the second run of a cron job benefits and
68
74
  # not merely the second call in one process. A caller that would rather
69
75
  # keep nothing between runs passes ValidatorStore::Memory.new.
70
76
  sig do
71
- params(client: HttpClient, store: T.untyped, stale_after: T.nilable(Numeric), logger: T.untyped).void
77
+ params(client: HttpClient, store: T.untyped, stale_after: T.nilable(Numeric), logger: T.untyped,
78
+ instrumenter: T.untyped).void
72
79
  end
73
80
  def initialize(client: HttpClient.new,
74
81
  store: ValidatorStore::FileSystem.new,
75
82
  stale_after: ActiveSanction.config.stale_after,
76
- logger: ActiveSanction.config.logger)
83
+ logger: ActiveSanction.config.logger,
84
+ instrumenter: ActiveSanction.config.instrumenter)
77
85
  @client = T.let(client, HttpClient)
78
86
  @store = T.let(store, T.untyped)
79
87
  @stale_after = T.let(stale_after, T.nilable(Numeric))
80
88
  @logger = T.let(logger, T.untyped)
89
+ @instrumenter = T.let(instrumenter, T.untyped)
81
90
  end
82
91
 
83
92
  # Fetches conditionally and buffers the body, like HttpClient#get.
@@ -91,12 +100,17 @@ module ActiveSanction
91
100
  # `force: true` sends no validators, so the publisher has no way to answer
92
101
  # 304. For the operator who suspects the cached copy is wrong and wants the
93
102
  # bytes regardless of what the ETag says.
103
+ # `source:` is which list this file belongs to, and is only ever read by
104
+ # instrumentation: a multi-file source files its validators under
105
+ # `:"ofac_sdn-sdn"` and the rest, and a dashboard asking which *list* is
106
+ # degrading wants `:ofac_sdn`. Defaults to the fetch key, which is right
107
+ # for every single-file source and for a caller fetching a bare URL.
94
108
  sig do
95
- params(url: T.untyped, key: T.untyped, force: T::Boolean, headers: T::Hash[T.untyped, T.untyped])
96
- .returns(Result)
109
+ params(url: T.untyped, key: T.untyped, force: T::Boolean, headers: T::Hash[T.untyped, T.untyped],
110
+ source: T.untyped).returns(Result)
97
111
  end
98
- def fetch(url, key: url, force: false, headers: {})
99
- conditional(url, key, force, headers) { |request| client.get(url, headers: request) }
112
+ def fetch(url, key: url, force: false, headers: {}, source: nil)
113
+ conditional(url, key, force, headers, source) { |request| client.get(url, headers: request) }
100
114
  end
101
115
 
102
116
  # Streams conditionally to disk, like HttpClient#download. A 304 writes
@@ -104,10 +118,10 @@ module ActiveSanction
104
118
  # is left exactly as the last download left it.
105
119
  sig do
106
120
  params(url: T.untyped, to: T.untyped, key: T.untyped, force: T::Boolean,
107
- headers: T::Hash[T.untyped, T.untyped]).returns(Result)
121
+ headers: T::Hash[T.untyped, T.untyped], source: T.untyped).returns(Result)
108
122
  end
109
- def download(url, to:, key: url, force: false, headers: {})
110
- conditional(url, key, force, headers) { |request| client.download(url, to: to, headers: request) }
123
+ def download(url, to:, key: url, force: false, headers: {}, source: nil)
124
+ conditional(url, key, force, headers, source) { |request| client.download(url, to: to, headers: request) }
111
125
  end
112
126
 
113
127
  # Whether a sync is due, answered locally and without a request.
@@ -149,14 +163,26 @@ module ActiveSanction
149
163
 
150
164
  sig do
151
165
  params(url: T.untyped, key: T.untyped, force: T::Boolean, headers: T::Hash[T.untyped, T.untyped],
166
+ source: T.untyped,
152
167
  block: T.proc.params(request: T::Hash[T.untyped, T.untyped]).returns(HttpClient::Response))
153
168
  .returns(Result)
154
169
  end
155
- def conditional(url, key, force, headers, &block)
170
+ def conditional(url, key, force, headers, source, &block)
156
171
  stored = force ? nil : usable(key, url)
157
172
  log_request(key, url, stored, force)
158
- response = block.call(merge(headers, stored))
159
- record(key, url, stored, response)
173
+ # One event per HTTP round trip, which is why `forced` is on it: a file
174
+ # served out of the payload cache after a 304 costs no request and emits
175
+ # nothing, and a source re-fetching one because its cache was empty
176
+ # emits a second event rather than amending the first.
177
+ fields = { source: source || key, key: key, url: url.to_s, forced: force }
178
+ Instrumentation.instrument(instrumenter, :fetch, fields) do |event|
179
+ response = block.call(merge(headers, stored))
180
+ event[:status] = response.status
181
+ event[:not_modified] = response.not_modified?
182
+ event[:bytes] = response.body.to_s.bytesize
183
+ event[:conditional] = !stored.nil?
184
+ record(key, url, stored, response)
185
+ end
160
186
  end
161
187
 
162
188
  # Validators stored against a different URL are not merely useless, they