sponsored_logs 0.3.1 → 0.4.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: 6fb5cc1d702c7a78769eabc68497519929f3b539022e060e183236bea09bf2e9
4
- data.tar.gz: 833fc7655dc58fe08d225c30d8cbeb4fb671f8420e28174a902a2b90127a8126
3
+ metadata.gz: 71c5f68d3e16d9f70a3bbf6d1fddf4eb43be5f4d4e02aca424164644e2c15805
4
+ data.tar.gz: 6c966d77ed2ac1161dee7d0128ac4b4876b6b71dc6de70d2ba4ae01a64743851
5
5
  SHA512:
6
- metadata.gz: a082ca74275c9295fbe89b5a37df6ff53ef4384be302b6fe63a6b69f93ee11ec9ac2443a75375ea3a2fb78db96fb2285a825d797c0d793af7834b64304d28faf
7
- data.tar.gz: 0e583c888d9b72d785d76492c1dc7b71cebb6769321f025a2d9a3282a5ff0425f51dfbbbca693a6e1382bc0dbbe19eb3a4bc71c3825535e8e46444fbebc00ba0
6
+ metadata.gz: f5ac2e5b839c73b285125a4a6b739ec47c085e70ee00c63fe880a16970b94bdace3588411ec9d46470ca9c8fc0414331e47bcb36fc8cdf3c94653bf190ff2df9
7
+ data.tar.gz: 5eca7cfa43eebb905ecd905b9c9664cbd1dac4d15a03ff7afb40320c515370375befdbaa461aead4f128a204809f67753e2df35302693d65382a3570cc9b33ff
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-09-10
11
+
12
+ ### Added
13
+
14
+ - **Identity is a moat.** Copy is a draft, not an account — an advertiser that rewrites its line is not a new advertiser. Every ad takes an optional `id`; set it and impressions, spend, and cap ride through a mid-flight rewrite, omit it and `id` falls back to a content hash of the text so nothing already booked moves. Two advertisers can even run the same line as separate books. Works in the JSON ads file
15
+
16
+ ### Changed
17
+
18
+ - **The ledger settles by account, not by copy.** The store snapshot is now `{ id => {text:, impressions:, cpm:} }` — text rides along as a label so the Command Center still reads in plain English, but caps and reporting key on `id` end to end. Custom stores must return the new shape
19
+ - **`text_digest` retires; `ad_id` takes the desk.** The ActiveRecord ledger keys on `ad_id`, which for a no-id ad is exactly the old text digest, so existing rows line up untouched. Existing installs rerun `rails g sponsored_logs:install` (or rename the column and its unique index). The Redis store keys on `id` too, so existing Redis tallies reset once on upgrade
20
+
10
21
  ## [0.3.1] - 2026-09-10
11
22
 
12
23
  ### Fixed
@@ -78,7 +89,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
78
89
 
79
90
  - Requires Ruby >= 3.1
80
91
 
81
- [Unreleased]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.3.1...HEAD
92
+ [Unreleased]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.4.0...HEAD
93
+ [0.4.0]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.3.1...v0.4.0
82
94
  [0.3.1]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.3.0...v0.3.1
83
95
  [0.3.0]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.2.0...v0.3.0
84
96
  [0.2.0]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.1.0...v0.2.0
data/README.md CHANGED
@@ -233,6 +233,26 @@ A missing `weight` defaults to `1`; a negative weight is treated as `0`. A
233
233
  missing `cpm` defaults to `0`. A pool that is empty, has only blank text, or
234
234
  sums to zero weight falls back to the built-in list.
235
235
 
236
+ ### 🪪 Stable ad identity (portfolio continuity)
237
+
238
+ Copy is not identity — copy is a draft. An advertiser that rewrites its line is
239
+ not a new account, and two advertisers that happen to write the same line are
240
+ not one. Give each creative a stable `id` and its impressions, spend, and cap
241
+ carry across every rewrite:
242
+
243
+ ```ruby
244
+ SponsoredLogs.sponsor!(ads: [
245
+ { id: "acme-q3", text: "Acme: now with more Acme.", weight: 1, cap: 10_000 },
246
+ { id: "acme-q3", text: "Acme: even MORE Acme.", weight: 1, cap: 10_000 } # same account, new copy
247
+ ])
248
+ ```
249
+
250
+ Omit `id` and it defaults to a content hash of the text — exactly how the ledger
251
+ has always keyed — so nothing already in flight moves. `id` works in the JSON
252
+ ads file too. Tallies, cap governance, and the Command Center all settle by
253
+ account, then read your copy back in plain English. **An advertiser should never
254
+ have to re-earn its own history just to fix a typo.**
255
+
236
256
  ### 🗓️ Flighting (start and end dates)
237
257
 
238
258
  **Campaign flighting** — table stakes for any serious ad server, and we deliver
@@ -3,7 +3,7 @@
3
3
  class CreateSponsoredLogsImpressions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  def change
5
5
  create_table :sponsored_logs_impressions do |t|
6
- t.string :text_digest, null: false
6
+ t.string :ad_id, null: false
7
7
  t.text :text, null: false
8
8
  t.integer :impressions, null: false, default: 0
9
9
  t.float :cpm, null: false, default: 0.0
@@ -11,6 +11,6 @@ class CreateSponsoredLogsImpressions < ActiveRecord::Migration[<%= ActiveRecord:
11
11
  t.timestamps
12
12
  end
13
13
 
14
- add_index :sponsored_logs_impressions, :text_digest, unique: true
14
+ add_index :sponsored_logs_impressions, :ad_id, unique: true
15
15
  end
16
16
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require "digest"
4
5
 
5
6
  module SponsoredLogs
6
7
  module Advertisers
@@ -73,6 +74,7 @@ module SponsoredLogs
73
74
  return if text.empty?
74
75
 
75
76
  {
77
+ id: Identity.coerce_id(fetch(entry, :id), text),
76
78
  advertiser: coerce_advertiser(fetch(entry, :advertiser)),
77
79
  text: text,
78
80
  weight: coerce_number(fetch(entry, :weight), default: 1.0),
@@ -197,7 +199,7 @@ module SponsoredLogs
197
199
 
198
200
  # Pick one normalized ad entry using the given selection mode, considering
199
201
  # only ads eligible at `now` -- live within their flight window and under
200
- # their impression cap (counts is a text => impressions map). In :cpm mode
202
+ # their impression cap (counts is an id => impressions map). In :cpm mode
201
203
  # the cpm drives the odds; if every eligible cpm is 0 we fall back to manual
202
204
  # weights so selection never stalls.
203
205
  #
@@ -233,11 +235,12 @@ module SponsoredLogs
233
235
  house_ads? ? DEFAULT_ADS : PAID_ADS
234
236
  end
235
237
 
236
- # Texts that identify house inventory, used to exclude house ads from
238
+ # Ids that identify house inventory, used to exclude house ads from
237
239
  # selection when the toggle is off (they can arrive via a user-supplied
238
- # DEFAULT_ADS pool, not just the fallback).
240
+ # DEFAULT_ADS pool, not just the fallback). Keyed by id, not text, so
241
+ # editing house-ad copy can't break the match.
239
242
  #
240
- HOUSE_TEXTS = HOUSE_ADS.map { |ad| ad[:text] }.freeze
243
+ HOUSE_IDS = HOUSE_ADS.map { |ad| Digest::SHA256.hexdigest(ad[:text]) }.freeze
241
244
 
242
245
  # Strip house creatives from a pool when the house_ads toggle is off; a
243
246
  # no-op when it is on. Keeps house ads out of rotation everywhere, not just
@@ -246,7 +249,7 @@ module SponsoredLogs
246
249
  def self.drop_house(pool)
247
250
  return pool if house_ads?
248
251
 
249
- pool.reject { |ad| HOUSE_TEXTS.include?(ad[:text]) }
252
+ pool.reject { |ad| HOUSE_IDS.include?(Identity.id_for(ad)) }
250
253
  end
251
254
 
252
255
  # Whether the self-sponsoring house-ad inventory is enabled. Defaults to on
@@ -262,7 +265,7 @@ module SponsoredLogs
262
265
  end
263
266
 
264
267
  def self.eligible(pool, now, counts)
265
- pool.select { |ad| eligible?(ad, now, counts[ad[:text]].to_i) }
268
+ pool.select { |ad| eligible?(ad, now, counts[Identity.id_for(ad)].to_i) }
266
269
  end
267
270
 
268
271
  # Render a normalized ad. :text ads (the default) stay byte-identical to
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module SponsoredLogs
6
+ # Stable ad identity. The ledger keys impressions on an ad's id, not its
7
+ # mutable text, so identical copy under different ids tallies separately and
8
+ # editing copy never resets a count. An absent/blank id falls back to
9
+ # SHA256(text), which is byte-compatible with the pre-0.4.0 text-keyed
10
+ # behavior and lines up with the ActiveRecord digest for zero-migration
11
+ # back-compat.
12
+ #
13
+ module Identity
14
+ module_function
15
+
16
+ # Resolve an explicit id (opt-in stability) or fall back to SHA256(text).
17
+ #
18
+ def coerce_id(value, text)
19
+ id = value.to_s.strip
20
+ id.empty? ? Digest::SHA256.hexdigest(text) : id
21
+ end
22
+
23
+ # The ledger key for an ad hash. Normalized ads carry :id; raw hashes
24
+ # recorded straight to a store fall back to the same SHA256(text) default,
25
+ # so both paths agree on identity. Accepts symbol- or string-keyed hashes.
26
+ #
27
+ def id_for(ad)
28
+ value = ad[:id] || ad["id"]
29
+ coerce_id(value, sanitize_text(ad[:text] || ad["text"]))
30
+ end
31
+
32
+ # Mirror Advertisers.sanitize_text so an id derived from raw text matches
33
+ # the id derived from normalized (sanitized) text for the same creative.
34
+ #
35
+ def sanitize_text(value)
36
+ value.to_s.gsub(Advertisers::CONTROL_CHARS, " ").strip
37
+ end
38
+ end
39
+ end
@@ -7,7 +7,7 @@ module SponsoredLogs
7
7
  # Spend for an ad is impressions / 1000.0 * cpm (cost per mille).
8
8
  #
9
9
  class Report
10
- Entry = Struct.new(:text, :impressions, :cpm, :spend, keyword_init: true)
10
+ Entry = Struct.new(:id, :text, :impressions, :cpm, :spend, keyword_init: true)
11
11
 
12
12
  def initialize(store)
13
13
  @store = store
@@ -18,17 +18,18 @@ module SponsoredLogs
18
18
  end
19
19
 
20
20
  def total_impressions
21
- @store.snapshot.sum { |_text, data| data[:impressions] }
21
+ @store.snapshot.sum { |_id, data| data[:impressions] }
22
22
  end
23
23
 
24
24
  def total_spend
25
- @store.snapshot.sum { |_text, data| spend_for(data[:impressions], data[:cpm]) }
25
+ @store.snapshot.sum { |_id, data| spend_for(data[:impressions], data[:cpm]) }
26
26
  end
27
27
 
28
28
  def entries
29
- @store.snapshot.map do |text, data|
29
+ @store.snapshot.map do |id, data|
30
30
  Entry.new(
31
- text: text,
31
+ id: id,
32
+ text: data[:text],
32
33
  impressions: data[:impressions],
33
34
  cpm: data[:cpm].to_f,
34
35
  spend: spend_for(data[:impressions], data[:cpm])
@@ -36,7 +37,7 @@ module SponsoredLogs
36
37
  end
37
38
  end
38
39
 
39
- # Map of ad text => recorded impressions, for cap enforcement.
40
+ # Map of ad id => recorded impressions, for cap enforcement.
40
41
  #
41
42
  def impression_counts
42
43
  @store.snapshot.transform_values { |data| data[:impressions] }
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
-
5
3
  module SponsoredLogs
6
4
  module Ledger
7
5
  module Store
8
- # Persistent store backed by ActiveRecord, one row per ad keyed by a
9
- # SHA256 digest of the ad text (the full text is stored alongside for
10
- # reporting). Rows live in `sponsored_logs_impressions`; run the
6
+ # Persistent store backed by ActiveRecord, one row per ad keyed by the
7
+ # stable ad id (the full text is stored alongside for reporting). For an
8
+ # ad with no explicit id the id defaults to SHA256(text), so the ad_id
9
+ # column holds exactly the digest the pre-0.4.0 text_digest column did.
10
+ # Rows live in `sponsored_logs_impressions`; run the
11
11
  # `sponsored_logs:install` generator to create the migration.
12
12
  #
13
13
  # ActiveRecord is required lazily, so it stays an optional dependency.
@@ -20,24 +20,25 @@ module SponsoredLogs
20
20
  end
21
21
 
22
22
  def record(ad)
23
- digest = digest_for(ad[:text])
23
+ id = Identity.id_for(ad)
24
24
 
25
25
  # insert skips on conflict (INSERT ... ON CONFLICT DO NOTHING), so an
26
26
  # existing row keeps its impression count. Then atomically bump the
27
- # counter and refresh cpm in a single UPDATE.
27
+ # counter and refresh cpm and text in a single UPDATE, so a stable id
28
+ # whose copy was edited shows the latest text (like the other stores).
28
29
  #
29
30
  @model.insert(
30
- { text_digest: digest, text: ad[:text], cpm: ad[:cpm].to_f, impressions: 0 },
31
- unique_by: :text_digest
31
+ { ad_id: id, text: ad[:text], cpm: ad[:cpm].to_f, impressions: 0 },
32
+ unique_by: :ad_id
32
33
  )
33
- @model.where(text_digest: digest).update_all(
34
- ["impressions = impressions + 1, cpm = ?", ad[:cpm].to_f]
34
+ @model.where(ad_id: id).update_all(
35
+ ["impressions = impressions + 1, cpm = ?, text = ?", ad[:cpm].to_f, ad[:text].to_s]
35
36
  )
36
37
  end
37
38
 
38
39
  def snapshot
39
40
  @model.all.to_h do |row|
40
- [row.text, { impressions: row.impressions.to_i, cpm: row.cpm.to_f }]
41
+ [row.ad_id, { text: row.text, impressions: row.impressions.to_i, cpm: row.cpm.to_f }]
41
42
  end
42
43
  end
43
44
 
@@ -48,10 +49,6 @@ module SponsoredLogs
48
49
 
49
50
  private
50
51
 
51
- def digest_for(text)
52
- Digest::SHA256.hexdigest(text.to_s)
53
- end
54
-
55
52
  # Defined lazily so requiring this file never needs ActiveRecord loaded.
56
53
  #
57
54
  def build_default_model
@@ -12,14 +12,18 @@ module SponsoredLogs
12
12
  #
13
13
  class Base
14
14
  # Record a single impression for the given normalized ad hash
15
- # ({ text:, weight:, cpm: }). Called once per emitted message.
15
+ # ({ id:, text:, weight:, cpm: }). Called once per emitted message.
16
+ # Tallies are keyed by the stable ad id (SponsoredLogs::Advertisers
17
+ # Identity.id_for), not the mutable text, so editing copy never resets
18
+ # a count.
16
19
  #
17
20
  def record(_ad)
18
21
  raise NotImplementedError, "#{self.class}#record must be implemented"
19
22
  end
20
23
 
21
- # Return the current tallies as { text => { impressions: Integer,
22
- # cpm: Float } }. The ledger derives everything else from this.
24
+ # Return the current tallies as { id => { text: String, impressions:
25
+ # Integer, cpm: Float } }. Text is a display value here, not the key;
26
+ # the ledger derives everything else from this.
23
27
  #
24
28
  def snapshot
25
29
  raise NotImplementedError, "#{self.class}#snapshot must be implemented"
@@ -3,9 +3,10 @@
3
3
  module SponsoredLogs
4
4
  module Ledger
5
5
  module Store
6
- # Default adapter. Keeps impression counts and CPMs in memory, keyed by ad
7
- # text. Not persisted across process restarts. A mutex guards writes so the
8
- # periodic thread and request threads can record concurrently.
6
+ # Default adapter. Keeps impression counts and CPMs in memory, keyed by the
7
+ # stable ad id (text stored alongside for display). Not persisted across
8
+ # process restarts. A mutex guards writes so the periodic thread and
9
+ # request threads can record concurrently.
9
10
  #
10
11
  class Memory < Base
11
12
  def initialize
@@ -13,19 +14,23 @@ module SponsoredLogs
13
14
  @mutex = Mutex.new
14
15
  @impressions = Hash.new(0)
15
16
  @cpm = {}
17
+ @text = {}
16
18
  end
17
19
 
18
20
  def record(ad)
21
+ id = Identity.id_for(ad)
22
+
19
23
  @mutex.synchronize do
20
- @impressions[ad[:text]] += 1
21
- @cpm[ad[:text]] = ad[:cpm].to_f
24
+ @impressions[id] += 1
25
+ @cpm[id] = ad[:cpm].to_f
26
+ @text[id] = ad[:text].to_s
22
27
  end
23
28
  end
24
29
 
25
30
  def snapshot
26
31
  @mutex.synchronize do
27
- @impressions.each_with_object({}) do |(text, count), acc|
28
- acc[text] = { impressions: count, cpm: @cpm[text].to_f }
32
+ @impressions.each_with_object({}) do |(id, count), acc|
33
+ acc[id] = { text: @text[id], impressions: count, cpm: @cpm[id].to_f }
29
34
  end
30
35
  end
31
36
  end
@@ -34,6 +39,7 @@ module SponsoredLogs
34
39
  @mutex.synchronize do
35
40
  @impressions = Hash.new(0)
36
41
  @cpm = {}
42
+ @text = {}
37
43
  end
38
44
  self
39
45
  end
@@ -11,24 +11,28 @@ module SponsoredLogs
11
11
  @client = client || build_default_client
12
12
  @impressions_key = "#{namespace}:impressions"
13
13
  @cpm_key = "#{namespace}:cpm"
14
+ @text_key = "#{namespace}:text"
14
15
  end
15
16
 
16
17
  def record(ad)
17
- @client.hincrby(@impressions_key, ad[:text], 1)
18
- @client.hset(@cpm_key, ad[:text], ad[:cpm].to_f)
18
+ id = Identity.id_for(ad)
19
+ @client.hincrby(@impressions_key, id, 1)
20
+ @client.hset(@cpm_key, id, ad[:cpm].to_f)
21
+ @client.hset(@text_key, id, ad[:text].to_s)
19
22
  end
20
23
 
21
24
  def snapshot
22
25
  impressions = @client.hgetall(@impressions_key)
23
26
  cpm = @client.hgetall(@cpm_key)
27
+ text = @client.hgetall(@text_key)
24
28
 
25
- impressions.each_with_object({}) do |(text, count), acc|
26
- acc[text] = { impressions: count.to_i, cpm: cpm[text].to_f }
29
+ impressions.each_with_object({}) do |(id, count), acc|
30
+ acc[id] = { text: text[id], impressions: count.to_i, cpm: cpm[id].to_f }
27
31
  end
28
32
  end
29
33
 
30
34
  def reset
31
- @client.del(@impressions_key, @cpm_key)
35
+ @client.del(@impressions_key, @cpm_key, @text_key)
32
36
  self
33
37
  end
34
38
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SponsoredLogs
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -5,6 +5,7 @@ require "logger"
5
5
  require_relative "sponsored_logs/version"
6
6
  require_relative "sponsored_logs/color"
7
7
  require_relative "sponsored_logs/advertisers"
8
+ require_relative "sponsored_logs/identity"
8
9
  require_relative "sponsored_logs/flight"
9
10
  require_relative "sponsored_logs/banner"
10
11
  require_relative "sponsored_logs/ads_file"
@@ -159,39 +160,51 @@ module SponsoredLogs
159
160
 
160
161
  private
161
162
 
162
- # Map of ad text => normalized config metadata (weight/cpm/flight/cap),
163
+ # Map of ad id => normalized config metadata (text/weight/cpm/flight/cap),
163
164
  # used to enrich report rows and drive status.
164
165
  #
165
166
  def ad_metadata
166
- Advertisers.normalize(configuration.ads).to_h { |ad| [ad[:text], ad] }
167
+ Advertisers.normalize(configuration.ads).to_h { |ad| [ad[:id], ad] }
167
168
  end
168
169
 
169
170
  # Partition every known ad (served or configured) into running / upcoming /
170
- # finished report rows by flight-and-cap status.
171
+ # finished report rows by flight-and-cap status. Ads are keyed by stable id;
172
+ # the human-readable text comes from the served ledger entry or config meta.
171
173
  #
172
174
  def grouped_report_rows
173
175
  now = Time.now
174
176
  metas = ad_metadata
175
177
  counts = ledger.impression_counts
176
- served = ledger.entries.to_h { |entry| [entry.text, entry] }
178
+ served = ledger.entries.to_h { |entry| [entry.id, entry] }
177
179
 
178
180
  grouped = Hash.new { |h, k| h[k] = [] }
179
-
180
- (served.keys + metas.keys).uniq.each do |text|
181
- meta = metas[text] || {}
182
- status = Advertisers.status(meta, now, counts[text].to_i)
183
- row = report_row(text, meta, served[text], status)
184
-
185
- case status
186
- when :scheduled then grouped[:upcoming] << row
187
- when :ended, :exhausted then grouped[:finished] << row
188
- else grouped[:running] << row if served[text]
189
- end
181
+ (served.keys + metas.keys).uniq.each do |id|
182
+ meta = metas[id] || {}
183
+ entry = served[id]
184
+ bucket, row = report_bucket_for(meta, entry, now, counts[id].to_i)
185
+ grouped[bucket] << row if bucket
190
186
  end
191
187
 
192
188
  grouped
193
189
  end
194
190
 
191
+ # Classify one ad into its report bucket (:running / :upcoming / :finished)
192
+ # and build its row. Text comes from the served ledger entry (latest copy)
193
+ # or, for an unserved configured ad, its config meta. Running rows are
194
+ # impression-driven, so an unserved running ad returns a nil bucket.
195
+ #
196
+ def report_bucket_for(meta, entry, now, count)
197
+ text = entry ? entry.text : meta[:text]
198
+ status = Advertisers.status(meta, now, count)
199
+ row = report_row(text, meta, entry, status)
200
+
201
+ case status
202
+ when :scheduled then [:upcoming, row]
203
+ when :ended, :exhausted then [:finished, row]
204
+ else entry ? [:running, row] : [nil, row]
205
+ end
206
+ end
207
+
195
208
  # A single report row. Impressions/spend come from the ledger entry when the
196
209
  # ad has served, otherwise zero; window/cap come from config metadata.
197
210
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sponsored_logs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kerri Miller
@@ -128,6 +128,7 @@ files:
128
128
  - lib/sponsored_logs/engine.rb
129
129
  - lib/sponsored_logs/env.rb
130
130
  - lib/sponsored_logs/flight.rb
131
+ - lib/sponsored_logs/identity.rb
131
132
  - lib/sponsored_logs/injector.rb
132
133
  - lib/sponsored_logs/ledger/report.rb
133
134
  - lib/sponsored_logs/ledger/store/active_record.rb