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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63d579349c5ed0f731edbb3811cea143c2a1bdf7f591b319fb290ab6fb28c8c6
4
- data.tar.gz: e6ad0e3c72fdca696dc2d9e2e1d528a4a43e638054362f1ebf0931def9b309c8
3
+ metadata.gz: ffb2d77bf3207e3ef97a16269dcf91737e20d48871cc2aec9e747d4185793e52
4
+ data.tar.gz: eeca2cf2bb69972d83e15e982c2daea80969a636e4c88e86cbd29b670420098c
5
5
  SHA512:
6
- metadata.gz: '086eb3b75aa22d1cf3b976b269f28f49a0b5a734a22bba52f0a25119125042c40d41a35fc25b694069814d7f3d4e24426e51ef5da15f5d91f24c33519eff97f4'
7
- data.tar.gz: d5cf18bc582c46e84b1255328e5a8411bb6e35c5444c8660c2687b7be5621411333f0d3a782fd352ad0963ab96e72ed93f1abd3fb7349bccc073f4f02e2e4452
6
+ metadata.gz: 748d59d2d06bb502eefd684a71c1abc74beb6b03d677eaf9a85c4563945c03fb7c9d69474988319d0ef1f03d4a68c328efd7d63ad14a62d33fa74ef4989e361b
7
+ data.tar.gz: dc9c4247f9e3ce24772a8045c4e7e0ea992ae7fbeb17c8f2f123f8a9ccf39fd741d7617a98cd67eef5968a16811c2cfc1b42a0c433df3da94d3b8e4f3f624f40
data/CHANGELOG.md CHANGED
@@ -13,6 +13,123 @@ algorithms or the scorer could move a score. **A change that moves `MATCHER_VERS
13
13
  called out here as such**, because it is the one kind of change that alters what a past
14
14
  screening decision would come out as today.
15
15
 
16
+ ## [Unreleased]
17
+
18
+ Nothing yet.
19
+
20
+ ## [1.1.0] - 2026-09-14
21
+
22
+ **A minor, because it adds.** Nothing that existed changed: no behaviour under `lib/`
23
+ moved, `MATCHER_VERSION` stays at `1`, and a name scores today exactly what it scored
24
+ under 1.0.0. Instrumentation is additive and off unless a host asks for it.
25
+
26
+ ### Added
27
+
28
+ - **Instrumentation: six structured events, so a host can measure this library without
29
+ monkeypatching it** (#59). `ActiveSanction.configure { |c| c.instrumenter = ... }` takes
30
+ anything answering `#call(event)` and is handed a finished
31
+ `ActiveSanction::Instrumentation::Event` for each of `:fetch`, `:parse`, `:store`,
32
+ `:"index.build"`, `:screen` and `:sync` — every one carrying a duration and the ids
33
+ needed to correlate it, with no anonymous timings. The event names and every payload key
34
+ are public API, enumerated in [`docs/api_stability.md`](docs/api_stability.md) and on the
35
+ site's [instrumentation reference](https://babystep.tech/active_sanction/reference/instrumentation/),
36
+ and covered by the deprecation path from here.
37
+
38
+ **It is a measurement of the work and never part of it.** A subscriber that raises has its
39
+ exception caught, reported once through the configured logger, and dropped; the sync it
40
+ was watching finishes and returns the report it was going to return. The converse holds
41
+ too: a stage that raises emits its event with `error:` set and then the exception
42
+ continues exactly as if nothing were listening.
43
+
44
+ **Nothing is listening by default, and that costs nothing.** `nil` is a branch taken
45
+ before anything is allocated rather than a no-op object that gets called, so an
46
+ uninstrumented screening call builds no event and allocates no payload — which is the
47
+ only way a per-query event could be affordable at all. Measured rather than asserted:
48
+ building a matcher over 47,051 names allocates 262 more objects than before, out of
49
+ 5.79 million, and `rake benchmark:latency` reports the same p50 either side of the change
50
+ (12.1–12.8 ms against a run-to-run spread that was already that wide).
51
+
52
+ Rails hosts get `ActiveSanction::Instrumentation::Notifications`, which republishes every
53
+ event into `ActiveSupport::Notifications` under `<event>.active_sanction`. It is an
54
+ adapter and not a dependency: **nothing in this gem requires ActiveSupport**, and building
55
+ one in a process that has not loaded it raises `ConfigurationError` rather than quietly
56
+ instrumenting nothing.
57
+
58
+ The issue asked for `ActiveSanction.instrumenter = ...`, and this is a configuration
59
+ setting instead. #55 ended process-global configuration deliberately, and a module-level
60
+ writer would have rebuilt the default client — dropping the matcher it had indexed every
61
+ stored list into — as a side effect of naming a subscriber. A `Matcher` takes its
62
+ instrumenter at build and freezes it with its weights, so a subscriber swapped halfway
63
+ through a batch cannot make half of it instrumented; that is the rule every other setting
64
+ on the query path already follows.
65
+
66
+ - **`Sources::Base#warnings`**, defaulting to none. Every shipped adapter already exposed
67
+ it and the adapter rules already required it of a new one, but the base class never said
68
+ so — and the `:parse` event counts warnings for every source, which a count that is
69
+ sometimes a `NoMethodError` cannot do. An optional hook with a default implementation, so
70
+ no adapter outside this repository has to change.
71
+
72
+ ## [1.0.1] - 2026-09-14
73
+
74
+ A packaging and release-tooling release. **Nothing about screening changes**: no behaviour
75
+ in `lib/` moved, `MATCHER_VERSION` is unchanged at `1`, and a name scores today exactly what
76
+ it scored under 1.0.0.
77
+
78
+ ### Added
79
+
80
+ - **Releases publish themselves from a tag**, through
81
+ [`.github/workflows/release.yml`](.github/workflows/release.yml). Pushing `v1.2.3` re-runs
82
+ the three gates against the tagged tree, checks that the tag and `VERSION` agree, that the
83
+ tag is an ancestor of `main` and that the changelog has a section for it, then builds the
84
+ gem, publishes it and writes the GitHub release from that section. A tag failing any of
85
+ those publishes nothing.
86
+
87
+ **No API key exists to leak.** It authenticates by
88
+ [trusted publishing](https://guides.rubygems.org/trusted-publishing): a short-lived OIDC
89
+ token, verified by rubygems.org as naming this repository and this workflow file, exchanged
90
+ for a credential that expires with the job. The alternative is a long-lived key in a public
91
+ repository's settings, one leak away from someone else publishing under this gem's name --
92
+ and unlike a bad deploy, a bad gem is already installed by the time anyone could be warned.
93
+ Same reasoning that keeps a service-account key out of the documentation deploy.
94
+
95
+ The gem pushed is the gem that was verified, carried between the two jobs as an artifact
96
+ rather than rebuilt -- a second build is a second thing, however identical it looks.
97
+
98
+ **The publish is confirmed against rubygems.org** (#136), because `gem push` exiting 0 says
99
+ the upload was accepted rather than that a `bundle install` will find the version. The job
100
+ that writes the GitHub release waits on that confirmation, so a version rubygems.org did not
101
+ end up serving is never announced.
102
+
103
+ - **A gem badge on the README** (#136), read live from rubygems.org rather than generated into
104
+ a file. What it states is what is installable, which is a different question from what was
105
+ last tagged -- and a version written into the repository would be stale the moment the next
106
+ one published. Same argument as #104, applied to the one fact about this gem that lives
107
+ somewhere else entirely.
108
+
109
+ - **A release can be cut from the Actions tab**, without tagging by hand (#139). The
110
+ workflow's "Run workflow" button takes a tag and a checkbox: ticked, it runs every gate
111
+ against `main` and writes the tag only once they have all passed. That is the safer order
112
+ than `git tag && git push`, which makes a tag public before anything has checked the tree
113
+ under it — and this workflow will not move a tag somebody may already have fetched, so the
114
+ repair for that is a new version number. A tag it writes is annotated but unsigned; push
115
+ the tag yourself when you want your own signature on it. See
116
+ [`CONTRIBUTING.md`](CONTRIBUTING.md#releasing).
117
+
118
+ ### Fixed
119
+
120
+ - **`rake canary:refresh` regenerates the catalogue page's data as well as the baselines.**
121
+ `site/src/data/sources.json` is derived from `.github/baselines` (#104), so accepting new
122
+ numbers without rebuilding it left the two disagreeing and
123
+ [`spec/site_sources_data_spec.rb`](spec/site_sources_data_spec.rb) red. The rolling
124
+ baseline pull request the canary opens had failed on this every run since it started
125
+ opening one, on all six Rubies. Chained onto the task rather than added as a step to each
126
+ caller: a derived file that callers have to remember to rebuild is stale by the third
127
+ caller.
128
+
129
+ - **The canary signs its baseline commit off.** Nothing exempts a bot from
130
+ [`.github/workflows/dco.yml`](.github/workflows/dco.yml), which skips merge commits and
131
+ nothing else, so the rolling pull request failed the DCO check as well.
132
+
16
133
  ## [1.0.0] - 2026-09-13
17
134
 
18
135
  The first release. Everything below is in it.
@@ -535,4 +652,7 @@ summarized here because they are what a reader of a first release most needs:
535
652
  - Recall at the default threshold is 0.939 overall on the labeled set, and every record this
536
653
  version misses is named in the committed accuracy report.
537
654
 
655
+ [Unreleased]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.1.0...main
656
+ [1.1.0]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.0.1...v1.1.0
657
+ [1.0.1]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.0.0...v1.0.1
538
658
  [1.0.0]: https://github.com/Babystep-Technologies/active_sanction/releases/tag/v1.0.0
data/CONTRIBUTING.md CHANGED
@@ -232,11 +232,75 @@ comment restating the name would be noise.
232
232
 
233
233
  ## Releasing
234
234
 
235
- `bundle exec rake install` installs the gem locally. A release is: bump
236
- `VERSION` in [`lib/active_sanction/version.rb`](lib/active_sanction/version.rb),
237
- move the `Unreleased` section of [`CHANGELOG.md`](CHANGELOG.md) under the new
238
- version with its date, then `bundle exec rake release`, which tags, pushes
239
- and publishes to [rubygems.org](https://rubygems.org).
235
+ `bundle exec rake install` installs the gem locally.
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.
241
+
242
+ Then the tag, which you can let the workflow write or write yourself.
243
+
244
+ **From the Actions tab**, which is the shorter path and the safer order. Open
245
+ [`Release`](https://github.com/Babystep-Technologies/active_sanction/actions/workflows/release.yml),
246
+ press **Run workflow**, enter `v1.2.3`, and tick **Create the tag on main**.
247
+ Every gate runs against `main` first, and the tag is written only once they
248
+ have all passed — so a release that turns out not to be publishable leaves no
249
+ tag behind.
250
+
251
+ **Or from a terminal**, which is the path to take when you want your own
252
+ signature on the tag:
253
+
254
+ $ git tag -s v1.2.3 -m "Release 1.2.3"
255
+ $ git push origin v1.2.3
256
+
257
+ The cost of that order is that the tag is public before anything has checked
258
+ the tree under it. If a gate then fails, the tag stays where it is: this
259
+ workflow will not move a tag somebody may already have fetched, so the repair
260
+ is a new version rather than a retagged one.
261
+
262
+ Either way, [`.github/workflows/release.yml`](.github/workflows/release.yml)
263
+ does the rest. It re-runs the three gates against the tree being released,
264
+ checks that the tag and `VERSION` agree, that the commit is on `main`, and
265
+ that the changelog has a section for it — then builds the gem, publishes it to
266
+ [rubygems.org](https://rubygems.org), confirms rubygems.org is serving that
267
+ version, and writes the GitHub release from that changelog section. A release
268
+ that fails any of those checks publishes nothing, and a push rubygems.org did
269
+ not end up serving is never announced (#136).
270
+
271
+ **Leave the box unticked to re-run a publish.** A dispatch with `Create the
272
+ tag` unticked publishes a tag that already exists — one pushed before this
273
+ workflow did, or one whose publish failed after the tag was public — and
274
+ nothing in this workflow ever moves or deletes a tag. Ticking the box against
275
+ a tag that already exists is not an error either; it publishes that tag and
276
+ says it created nothing.
277
+
278
+ **A tag the workflow writes is annotated but not signed.** It has no key of
279
+ yours, and signing with a machine key would say less than saying nothing. The
280
+ tag names the commit every gate ran against, and the provenance that matters
281
+ for what users install is on the other side: the gem is published by [trusted
282
+ publishing](https://guides.rubygems.org/trusted-publishing) from this
283
+ repository and this workflow file. Sign the tag yourself, with the terminal
284
+ path above, when you want the tag to carry it too.
285
+
286
+ The [gem badge](https://rubygems.org/gems/active_sanction) at the top of the
287
+ README reads from rubygems.org rather than from anything in this repository,
288
+ so it states what is installable rather than what was last tagged.
289
+
290
+ **Nobody needs a RubyGems API key, including the person tagging.** The
291
+ workflow authenticates by [trusted
292
+ publishing](https://guides.rubygems.org/trusted-publishing): it mints a
293
+ short-lived OIDC token, rubygems.org verifies the token names this repository
294
+ and this workflow file, and hands back a credential that expires with the job.
295
+ There is no publishing secret in this repository's settings, for the same
296
+ reason there is no service-account key for the documentation deploy. It is
297
+ configured once, on rubygems.org, under the gem's **Trusted Publishers**:
298
+ owner `Babystep-Technologies`, repository `active_sanction`, workflow
299
+ `release.yml`, environment `rubygems`.
300
+
301
+ `bundle exec rake release` — the manual path that tags, pushes and publishes
302
+ in one step — still exists and still works for anyone with push rights to the
303
+ gem. It is not how this gem is released, and using it skips every check above.
240
304
 
241
305
  `MATCHER_VERSION` in the same file is bumped on a different occasion and for
242
306
  a different reason — whenever a change to the normalizer, the index, the
data/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  # ActiveSanction
6
6
 
7
+ [![Gem](https://img.shields.io/gem/v/active_sanction?label=gem&color=CC342D)](https://rubygems.org/gems/active_sanction)
7
8
  [![CI](https://github.com/Babystep-Technologies/active_sanction/actions/workflows/ci.yml/badge.svg)](https://github.com/Babystep-Technologies/active_sanction/actions/workflows/ci.yml)
8
9
 
9
10
  **[Full documentation, guides and the source catalogue →](https://babystep.tech/active_sanction/)**
@@ -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
@@ -107,7 +107,7 @@ on working.
107
107
 
108
108
  ## Contracts that are not constants
109
109
 
110
- Four promises here are about behaviour rather than about a name, and none of
110
+ Five promises here are about behaviour rather than about a name, and none of
111
111
  them is enforceable by the surface spec.
112
112
 
113
113
  **The error hierarchy.** Within a major version an error does not move to a
@@ -128,6 +128,35 @@ anyone may produce or consume one, in any language.
128
128
  store written against one schema version keeps being readable; the file layout
129
129
  underneath a shipped store is not public and may change.
130
130
 
131
+ **The instrumentation events.** `ActiveSanction::Instrumentation::EVENTS`
132
+ names the six, and each one's payload keys are promised the same way a method
133
+ signature is: within a major version a key is not removed, renamed, or made to
134
+ mean something else, and a dashboard written against one keeps working. Keys
135
+ may be **added** to an event — that is how a new measurement ships without a
136
+ major version — so a subscriber reads the keys it knows and ignores the rest,
137
+ and must not assume the set is closed.
138
+
139
+ Every event carries `name`, `started_at` and `duration`, plus the keys below.
140
+ `error` is present only when the stage raised, in which case the keys it had
141
+ not reached yet are **absent rather than zero**.
142
+
143
+ | Event | Payload keys |
144
+ |---|---|
145
+ | `:fetch` | `source`, `key`, `url`, `forced`, `conditional`, `status`, `not_modified`, `bytes` |
146
+ | `:parse` | `source`, `bytes`, `records`, `warnings` |
147
+ | `:store` | `source`, `snapshot_id`, `entities`, `store`, `imported` (on an import only) |
148
+ | `:"index.build"` | `store`, `sources`, `snapshots`, `entities`, `names`, `keys`, `postings`, `bytes` |
149
+ | `:screen` | `candidates`, `scored`, `results`, `threshold`, `limit`, `sources`, `snapshots` |
150
+ | `:sync` | `sources`, `forced`, `concurrency`, `outcomes`, `updated`, `unchanged`, `failed`, `records` |
151
+
152
+ Two things about them are contracts rather than incidental. **A `:fetch` event
153
+ is one HTTP round trip** — a file served out of the payload cache after a 304
154
+ costs no request and emits nothing, and a source re-fetching one because its
155
+ cache was empty emits a second event rather than amending the first. And
156
+ **`bytes` on `:"index.build"` is an estimate**, documented as one on
157
+ `Index#profile`; it is good to within a factor a dashboard cares about and is
158
+ not a heap measurement.
159
+
131
160
  **A `MatchResult` is reproducible.** The snapshot checksum, matcher version,
132
161
  weights and query it stamps are what let a screening decision be re-derived
133
162
  years later. Fields may be added to that stamp; the meaning of an existing one
@@ -138,6 +167,12 @@ does not change under it.
138
167
  Named here because their absence from the list is a decision rather than an
139
168
  oversight:
140
169
 
170
+ - **The instrumenter contract's other half.** A subscriber is anything
171
+ answering `#call(event)`, and that is public. `Instrumentation.instrument`
172
+ and `.emit`, which the library's own stages call, are not: where an event is
173
+ emitted from is an implementation detail of the stage, and a host that
174
+ wanted to emit one of these names itself would be publishing a measurement
175
+ of something this library did not do.
141
176
  - **The matching internals** — `Index`, `Similarity`, `Phonetics`, and the
142
177
  scorer's `Adjustments` and `NameScore`. These are where accuracy work
143
178
  happens, and accuracy work that had to preserve a signature would stop.
@@ -193,6 +228,16 @@ ActiveSanction::Configuration::DEFAULT_USER_AGENT
193
228
  ActiveSanction::Configuration::DEFAULT_XML_BACKEND
194
229
  ```
195
230
 
231
+ ### Instrumentation
232
+
233
+ ```
234
+ ActiveSanction::Instrumentation
235
+ ActiveSanction::Instrumentation::EVENTS
236
+ ActiveSanction::Instrumentation::Event
237
+ ActiveSanction::Instrumentation::Notifications
238
+ ActiveSanction::Instrumentation::Notifications::NAMESPACE
239
+ ```
240
+
196
241
  ### The canonical record
197
242
 
198
243
  ```
@@ -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
@@ -157,7 +158,8 @@ module ActiveSanction
157
158
  @lock.synchronize do
158
159
  @matcher ||= with_configuration do
159
160
  Matcher.build(storage, sources: configuration.sources, weights: configuration.scorer_weights,
160
- candidate_limit: configuration.candidate_limit, backend: backend)
161
+ candidate_limit: configuration.candidate_limit, backend: backend,
162
+ instrumenter: configuration.instrumenter)
161
163
  end
162
164
  end
163
165
  end
@@ -286,7 +288,14 @@ module ActiveSanction
286
288
  sig { params(path: T.untyped, verify_with: T.untyped).returns(Snapshot) }
287
289
  def import(path, verify_with: nil)
288
290
  snapshot = ::File.open(path.to_s, "rb") { |io| Snapshot::Bundle.read(io, verify_with: verify_with) }
289
- with_configuration { storage.write_snapshot(snapshot) }
291
+ # The same `:store` event a sync emits, because it is the same fact: a
292
+ # list version was written to this store, and a host watching data
293
+ # freshness should not have to know which of the two ways it arrived.
294
+ fields = { source: snapshot.source, snapshot_id: snapshot.checksum, entities: snapshot.record_count,
295
+ store: storage.class.name, imported: true }
296
+ Instrumentation.instrument(configuration.instrumenter, :store, fields) do
297
+ with_configuration { storage.write_snapshot(snapshot) }
298
+ end
290
299
  reload!
291
300
  snapshot
292
301
  end
@@ -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
@@ -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