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.
@@ -8,6 +8,7 @@ require "active_sanction/error"
8
8
  require "active_sanction/entity"
9
9
  require "active_sanction/snapshot"
10
10
  require "active_sanction/fetcher"
11
+ require "active_sanction/instrumentation"
11
12
  require "active_sanction/parsers"
12
13
  require "active_sanction/payload_cache"
13
14
  require "active_sanction/sources"
@@ -82,17 +83,25 @@ module ActiveSanction
82
83
  sig { returns(T.untyped) }
83
84
  attr_reader :logger
84
85
 
86
+ # Where this adapter's `:parse` event goes, or nil for nothing
87
+ # listening. See Instrumentation.
88
+ sig { returns(T.untyped) }
89
+ attr_reader :instrumenter
90
+
85
91
  # `cache: nil` turns off payload caching, which costs one thing worth
86
92
  # knowing: a multi-file source can no longer answer a sync where some of
87
93
  # its files changed and others came back 304, so the unchanged ones are
88
94
  # downloaded again in full.
89
95
  sig do
90
- params(fetcher: Fetcher, cache: T.nilable(PayloadCache), logger: T.untyped).void
96
+ params(fetcher: Fetcher, cache: T.nilable(PayloadCache), logger: T.untyped,
97
+ instrumenter: T.untyped).void
91
98
  end
92
- def initialize(fetcher: Fetcher.new, cache: PayloadCache.new, logger: ActiveSanction.config.logger)
99
+ def initialize(fetcher: Fetcher.new, cache: PayloadCache.new, logger: ActiveSanction.config.logger,
100
+ instrumenter: ActiveSanction.config.instrumenter)
93
101
  @fetcher = T.let(fetcher, Fetcher)
94
102
  @cache = T.let(cache, T.nilable(PayloadCache))
95
103
  @logger = T.let(logger, T.untyped)
104
+ @instrumenter = T.let(instrumenter, T.untyped)
96
105
  @results = T.let({}, T::Hash[Symbol, Fetcher::Result])
97
106
  end
98
107
 
@@ -137,6 +146,20 @@ module ActiveSanction
137
146
  "#{self.class} must implement #parse(raw) and return an Array of ActiveSanction::Entity"
138
147
  end
139
148
 
149
+ # What the last #parse could not read: a Parsers::Warning per row that
150
+ # was skipped or could not be mapped, kept rather than raised. Every
151
+ # shipped adapter overrides this with the parser's own warnings plus
152
+ # whatever it noticed itself, which is what the adapter rules require of
153
+ # a new one.
154
+ #
155
+ # Empty here rather than abstract, because an adapter that genuinely
156
+ # cannot fail to read a row should not have to say so, and because the
157
+ # `:parse` event counts these for every source and a count that is
158
+ # sometimes a NoMethodError is not a metric. See Doctor, which reads the
159
+ # warnings themselves rather than the count.
160
+ sig { returns(T::Array[Parsers::Warning]) }
161
+ def warnings = []
162
+
140
163
  # Fetches, parses, and checksums -- or returns nil when the publisher
141
164
  # says nothing has changed, which is the outcome to expect on most runs
142
165
  # and the reason conditional GET exists.
@@ -157,8 +180,15 @@ module ActiveSanction
157
180
  # or, for a source that declares a single file, as the bytes themselves.
158
181
  sig { params(payloads: T.untyped, files: T.untyped).returns(Snapshot) }
159
182
  def snapshot(payloads = nil, **files)
160
- Snapshot.new(source: key, entities: parse(parse_argument(payloads || files)),
161
- fetched_at: Time.now.utc, source_version: source_version)
183
+ raw = parse_argument(payloads || files)
184
+ entities = Instrumentation.instrument(instrumenter, :parse,
185
+ { source: declared_key, bytes: byte_count(raw) }) do |event|
186
+ parsed = parse(raw)
187
+ event[:records] = parsed.size
188
+ event[:warnings] = warnings.size
189
+ parsed
190
+ end
191
+ Snapshot.new(source: key, entities: entities, fetched_at: Time.now.utc, source_version: source_version)
162
192
  rescue ActiveSanction::Error => e
163
193
  raise e.in_source(declared_key)
164
194
  end
@@ -250,7 +280,19 @@ module ActiveSanction
250
280
 
251
281
  sig { params(name: Symbol, address: String, force: T::Boolean).returns(Fetcher::Result) }
252
282
  def fetch_file(name, address, force)
253
- fetcher.fetch(address, key: file_key(name), force: force).success!
283
+ fetcher.fetch(address, key: file_key(name), force: force, source: declared_key).success!
284
+ end
285
+
286
+ # How many bytes #parse was handed, across every file of a multi-file
287
+ # source. Taken before the parse rather than after, so the `:parse`
288
+ # event still says how large the document was when the parse is what
289
+ # raised.
290
+ sig { params(raw: T.untyped).returns(Integer) }
291
+ def byte_count(raw)
292
+ return raw.bytesize if raw.is_a?(String)
293
+ return raw.to_h.each_value.sum { |payload| payload.to_s.bytesize } if raw.respond_to?(:to_h)
294
+
295
+ raw.to_s.bytesize
254
296
  end
255
297
 
256
298
  sig { params(name: Symbol).returns(T.untyped) }
@@ -283,7 +325,7 @@ module ActiveSanction
283
325
  sig { params(name: Symbol).returns(Fetcher::Result) }
284
326
  def refetch(name)
285
327
  logger&.info("[active_sanction] #{key} #{name} unchanged but not cached; fetching in full")
286
- result = fetcher.fetch(url(name), key: file_key(name), force: true).success!
328
+ result = fetcher.fetch(url(name), key: file_key(name), force: true, source: declared_key).success!
287
329
  return result if result.changed?
288
330
 
289
331
  raise MissingPayload,
@@ -110,6 +110,24 @@ module ActiveSanction
110
110
  # per source: a list that failed to refresh keeps its previous snapshot,
111
111
  # which is the right call and only safe if the age of what is being
112
112
  # screened against is visible.
113
+ #
114
+ # **Accurate to a second, and never fewer than the seconds that have
115
+ # passed.** `fetched_at` is stored to the second -- Snapshot#time!
116
+ # truncates it, so that a stored snapshot reloads equal to the one that
117
+ # was written -- so the instant of the fetch is known only to lie
118
+ # somewhere inside the second this names. What comes back is therefore
119
+ # the largest age consistent with what was recorded, which is the
120
+ # direction a staleness measure has to err in: an answer that is at most
121
+ # a second pessimistic is a report that nobody acts on, and one that is
122
+ # optimistic is a list being screened against that is older than it
123
+ # claims.
124
+ #
125
+ # The practical consequence, and the reason it is written down: a list
126
+ # fetched microseconds ago reports 0 or 1, according to whether the run
127
+ # crossed a second boundary on its way here. Both mean "just fetched" --
128
+ # see Sync::Result#age_in_words, which is what a summary table shows --
129
+ # and nothing should assert on the exact number of a fresh sync, because
130
+ # that is a fact about the clock rather than about this library.
113
131
  sig { params(now: Time).returns(Integer) }
114
132
  def age(now = Time.now) = now.to_i - fetched_at.to_i
115
133
 
@@ -5,6 +5,7 @@ require "sorbet-runtime"
5
5
 
6
6
  require "uri"
7
7
  require "active_sanction/error"
8
+ require "active_sanction/instrumentation"
8
9
  require "active_sanction/sources"
9
10
  require "active_sanction/storage"
10
11
  require "active_sanction/sync/result"
@@ -149,6 +150,17 @@ module ActiveSanction
149
150
  sig { returns(T.untyped) }
150
151
  attr_reader :logger
151
152
 
153
+ # Where the `:sync` and `:store` events go, or nil for nothing listening.
154
+ #
155
+ # The `:fetch` and `:parse` events of the sources this run covers do not
156
+ # come from here: an adapter is constructed by the run and reads the
157
+ # configuration, exactly as it does for its logger and its User-Agent. So
158
+ # a host that instruments through `ActiveSanction.configure` sees all six
159
+ # events, and one that hands a run its own instrumenter sees the two this
160
+ # class emits. See Instrumentation.
161
+ sig { returns(T.untyped) }
162
+ attr_reader :instrumenter
163
+
152
164
  sig { params(options: T.untyped, block: T.untyped).returns(Report) }
153
165
  def self.call(**options, &block) = T.unsafe(self).new(**options).call(&block)
154
166
 
@@ -157,9 +169,10 @@ module ActiveSanction
157
169
  # raises here, before the first list is downloaded, rather than after.
158
170
  sig do
159
171
  params(sources: T.untyped, store: T.untyped, force: T::Boolean, concurrency: T.untyped,
160
- logger: T.untyped).void
172
+ logger: T.untyped, instrumenter: T.untyped).void
161
173
  end
162
- def initialize(sources: nil, store: nil, force: false, concurrency: nil, logger: ActiveSanction.config.logger)
174
+ def initialize(sources: nil, store: nil, force: false, concurrency: nil, logger: ActiveSanction.config.logger,
175
+ instrumenter: ActiveSanction.config.instrumenter)
163
176
  @sources = T.let(resolve(sources), T::Array[T.untyped])
164
177
  @keys = T.let(@sources.map { |source| Sources::Definition.key!(source.key) }, T::Array[Symbol])
165
178
  @store = T.let(store || ActiveSanction.storage, T.untyped)
@@ -168,6 +181,7 @@ module ActiveSanction
168
181
  Configuration.sync_concurrency!(concurrency || ActiveSanction.config.sync_concurrency), Integer
169
182
  )
170
183
  @logger = T.let(logger, T.untyped)
184
+ @instrumenter = T.let(instrumenter, T.untyped)
171
185
  @lock = T.let(Mutex.new, Mutex)
172
186
  # The settings this run was started under, so a worker thread reads them
173
187
  # rather than the default client's. A configuration is fiber-local and a
@@ -195,9 +209,17 @@ module ActiveSanction
195
209
  started_at = Time.now.utc
196
210
  began = monotonic
197
211
  log_start
198
- work = keys.each_with_index.map { |key, at| [at, key, sources.fetch(at)] }
199
- results = run(work, &block).sort_by(&:first).map(&:last)
200
- report = Report.new(results: results, started_at: started_at, duration: elapsed(began))
212
+ # The run's own duration is the Report's, taken from the same clock
213
+ # reading, so an event and the report a caller is holding never disagree
214
+ # about how long a run took.
215
+ report = Instrumentation.instrument(instrumenter, :sync,
216
+ { sources: keys, forced: force, concurrency: concurrency }) do |event|
217
+ work = keys.each_with_index.map { |key, at| [at, key, sources.fetch(at)] }
218
+ results = run(work, &block).sort_by(&:first).map(&:last)
219
+ finished = Report.new(results: results, started_at: started_at, duration: elapsed(began))
220
+ summarize(event, finished)
221
+ finished
222
+ end
201
223
  log_finish(report)
202
224
  report
203
225
  end
@@ -285,13 +307,39 @@ module ActiveSanction
285
307
  snapshot = adapter.sync(force: force || previous.nil?)
286
308
  return complete(key, :unchanged, previous, started) if unchanged?(previous, snapshot)
287
309
 
288
- store.write_snapshot(snapshot)
310
+ write(key, snapshot)
289
311
  complete(key, :updated, Storage::Meta.from_snapshot(snapshot), started)
290
312
  rescue StandardError => e
291
313
  complete(key, :failed, previous, started, stamp(key, e))
292
314
  end
293
315
  end
294
316
 
317
+ # Writes one list, and says what it cost. Separate from the rest of
318
+ # #sync_source so the `:store` event times the write and nothing else --
319
+ # a store that takes eleven seconds to persist 19,321 entities is a
320
+ # different operational problem from a publisher that takes eleven seconds
321
+ # to serve them, and a timing that covered both could not tell a host
322
+ # which one it had.
323
+ sig { params(key: Symbol, snapshot: T.untyped).void }
324
+ def write(key, snapshot)
325
+ fields = { source: key, snapshot_id: snapshot.checksum, entities: snapshot.record_count,
326
+ store: store.class.name }
327
+ Instrumentation.instrument(instrumenter, :store, fields) { store.write_snapshot(snapshot) }
328
+ end
329
+
330
+ # What a run did, as counts rather than as the Report itself: a subscriber
331
+ # forwarding an event to a metrics backend wants numbers, and one that
332
+ # wants the whole report already has it as the return value of the call
333
+ # that emitted this.
334
+ sig { params(event: T.untyped, report: Report).void }
335
+ def summarize(event, report)
336
+ event[:outcomes] = report.results.to_h { |result| [result.source, result.status] }
337
+ event[:updated] = report.updated.size
338
+ event[:unchanged] = report.unchanged.size
339
+ event[:failed] = report.failed.size
340
+ event[:records] = report.record_count
341
+ end
342
+
295
343
  # A failure captured for a source names that source, even when it was
296
344
  # raised somewhere that could not know -- a store that will not open, an
297
345
  # adapter constructor. Only ever fills a blank; see Error#in_source.
@@ -6,7 +6,7 @@ module ActiveSanction
6
6
  # new source adapter, a storage fix or a documentation release -- none of
7
7
  # which change what a name scores. MATCHER_VERSION, below, is the one that
8
8
  # answers that question.
9
- VERSION = "1.0.0"
9
+ VERSION = "1.1.0"
10
10
 
11
11
  # Which matching pipeline scored a decision, stamped onto every MatchResult
12
12
  # and bumped whenever a change to the normalizer, the index, the similarity
@@ -6,6 +6,7 @@ require "sorbet-runtime"
6
6
  require "active_sanction/error"
7
7
  require "active_sanction/version"
8
8
  require "active_sanction/deprecation"
9
+ require "active_sanction/instrumentation"
9
10
  require "active_sanction/configuration"
10
11
  require "active_sanction/name"
11
12
  require "active_sanction/address"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_sanction
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marshall Shen
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-13 00:00:00.000000000 Z
11
+ date: 2026-09-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: csv
@@ -102,6 +102,9 @@ files:
102
102
  - lib/active_sanction/index/candidate.rb
103
103
  - lib/active_sanction/index/entry.rb
104
104
  - lib/active_sanction/index/features.rb
105
+ - lib/active_sanction/instrumentation.rb
106
+ - lib/active_sanction/instrumentation/event.rb
107
+ - lib/active_sanction/instrumentation/notifications.rb
105
108
  - lib/active_sanction/match_result.rb
106
109
  - lib/active_sanction/matcher.rb
107
110
  - lib/active_sanction/name.rb