sponsored_logs 0.3.0 → 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 +4 -4
- data/CHANGELOG.md +21 -1
- data/README.md +41 -0
- data/lib/generators/sponsored_logs/templates/create_sponsored_logs_impressions.rb.tt +2 -2
- data/lib/sponsored_logs/advertisers.rb +48 -35
- data/lib/sponsored_logs/flight.rb +62 -0
- data/lib/sponsored_logs/identity.rb +39 -0
- data/lib/sponsored_logs/ledger/report.rb +7 -6
- data/lib/sponsored_logs/ledger/store/active_record.rb +13 -16
- data/lib/sponsored_logs/ledger/store/base.rb +7 -3
- data/lib/sponsored_logs/ledger/store/memory.rb +13 -7
- data/lib/sponsored_logs/ledger/store/redis.rb +9 -5
- data/lib/sponsored_logs/version.rb +1 -1
- data/lib/sponsored_logs.rb +29 -15
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71c5f68d3e16d9f70a3bbf6d1fddf4eb43be5f4d4e02aca424164644e2c15805
|
|
4
|
+
data.tar.gz: 6c966d77ed2ac1161dee7d0128ac4b4876b6b71dc6de70d2ba4ae01a64743851
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f5ac2e5b839c73b285125a4a6b739ec47c085e70ee00c63fe880a16970b94bdace3588411ec9d46470ca9c8fc0414331e47bcb36fc8cdf3c94653bf190ff2df9
|
|
7
|
+
data.tar.gz: 5eca7cfa43eebb905ecd905b9c9664cbd1dac4d15a03ff7afb40320c515370375befdbaa461aead4f128a204809f67753e2df35302693d65382a3570cc9b33ff
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ 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
|
+
|
|
21
|
+
## [0.3.1] - 2026-09-10
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **Fill integrity is a moat.** A `NaN` bid is not demand — it's a tourist. It used to slip past the guard into `weighted_pick` and stall the whole rotation. Now non-finite bids (`NaN`, `±Infinity`) bill at zero where they belong, and the exchange keeps clearing. We don't let bad actors set the price
|
|
26
|
+
- **Brand safety is table stakes.** A creative that forges its own `ERROR` line isn't a placement — it's fraud with ambitions. Control characters, DEL, and the sneaky Unicode separators (U+2028/U+2029) now collapse to a space before serving. Punctuation, em-dashes, and emoji survive. The exploit does not
|
|
27
|
+
|
|
10
28
|
## [0.3.0] - 2026-09-07
|
|
11
29
|
|
|
12
30
|
### Fixed
|
|
@@ -71,7 +89,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
71
89
|
|
|
72
90
|
- Requires Ruby >= 3.1
|
|
73
91
|
|
|
74
|
-
[Unreleased]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.
|
|
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
|
|
94
|
+
[0.3.1]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.3.0...v0.3.1
|
|
75
95
|
[0.3.0]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.2.0...v0.3.0
|
|
76
96
|
[0.2.0]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.1.0...v0.2.0
|
|
77
97
|
[0.1.0]: https://github.com/sponsoredlogs/sponsored_logs/releases/tag/v0.1.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
|
|
@@ -609,6 +629,27 @@ Run the test suite:
|
|
|
609
629
|
bundle exec rspec
|
|
610
630
|
```
|
|
611
631
|
|
|
632
|
+
## 🏅 Certifications & Compliance
|
|
633
|
+
|
|
634
|
+
The exchange meets or exceeds every standard that matters, and several we
|
|
635
|
+
invented.
|
|
636
|
+
|
|
637
|
+
[](https://scriptporn.com/v/83-a-house-key-would-break-character/)
|
|
638
|
+
|
|
639
|
+
- 🧾 **ScriptPorn Verified™ — FILTHY (79/100).** Independently audited by a
|
|
640
|
+
five-judge panel; a season-finale score. SOC 2? We have something better.
|
|
641
|
+
- 🛡️ **Brand-Safety Certified.** No creative has forged a log line since the
|
|
642
|
+
U+2028 incident of 2026. Zero known injection vectors. Zero.
|
|
643
|
+
- ♻️ **Carbon-Neutral by Design.** We monetize exhaust that already exists. The
|
|
644
|
+
greenest CPM on Earth.
|
|
645
|
+
- 🤖 **A2A-Ready™.** First-party audited for agent-to-agent interoperability.
|
|
646
|
+
Our fastest-growing auditors are not human.
|
|
647
|
+
- ✅ **100% Viewability Guaranteed.** Independently unverifiable, because the
|
|
648
|
+
impressions are inside your logs, where no verification vendor can reach.
|
|
649
|
+
- 🔒 **SponsoredLogs Promise™ Compliant.** Fully opt-in. Consent is our moat.
|
|
650
|
+
|
|
651
|
+
_Governance is a feature. Excellence is a discipline, not a moment._
|
|
652
|
+
|
|
612
653
|
## 📜 License
|
|
613
654
|
|
|
614
655
|
Released under the [MIT License](LICENSE.txt) — **democratizing access to the
|
|
@@ -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 :
|
|
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, :
|
|
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
|
|
@@ -69,10 +70,11 @@ module SponsoredLogs
|
|
|
69
70
|
# Build one normalized ad row from a raw hash, or nil when text is blank.
|
|
70
71
|
#
|
|
71
72
|
def self.normalize_entry(entry)
|
|
72
|
-
text = fetch(entry, :text)
|
|
73
|
+
text = sanitize_text(fetch(entry, :text))
|
|
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),
|
|
@@ -110,6 +112,22 @@ module SponsoredLogs
|
|
|
110
112
|
entry[key] || entry[key.to_s]
|
|
111
113
|
end
|
|
112
114
|
|
|
115
|
+
# Line-breaking and control characters that let crafted ad copy inject
|
|
116
|
+
# forged log lines when emitted raw: the C0 range plus DEL, and the Unicode
|
|
117
|
+
# line/paragraph separators U+2028/U+2029 that many log viewers and parsers
|
|
118
|
+
# treat as newlines. Newlines and carriage returns are the obvious vectors;
|
|
119
|
+
# the rest are neutralized for good measure.
|
|
120
|
+
#
|
|
121
|
+
CONTROL_CHARS = /[\u0000-\u001F\u007F\u2028\u2029]/
|
|
122
|
+
|
|
123
|
+
# Scrub emitted creative text: replace control characters with a space so a
|
|
124
|
+
# newline in ad copy cannot forge a second log line, then strip. Normal
|
|
125
|
+
# punctuation, em-dashes, and emoji are preserved untouched.
|
|
126
|
+
#
|
|
127
|
+
def self.sanitize_text(value)
|
|
128
|
+
value.to_s.gsub(CONTROL_CHARS, " ").strip
|
|
129
|
+
end
|
|
130
|
+
|
|
113
131
|
# Normalize an advertiser name; blank/nil falls back to "Unattributed".
|
|
114
132
|
#
|
|
115
133
|
def self.coerce_advertiser(value)
|
|
@@ -133,7 +151,14 @@ module SponsoredLogs
|
|
|
133
151
|
return default if value.nil?
|
|
134
152
|
|
|
135
153
|
number = Float(value)
|
|
136
|
-
|
|
154
|
+
|
|
155
|
+
# NaN and +/-Infinity parse cleanly but poison downstream math
|
|
156
|
+
# (weighted_pick sums + rand * total + target < cumulative all break),
|
|
157
|
+
# so treat non-finite demand as unsellable: zero, like a negative bid.
|
|
158
|
+
#
|
|
159
|
+
return 0.0 if number.negative? || !number.finite?
|
|
160
|
+
|
|
161
|
+
number
|
|
137
162
|
rescue ArgumentError, TypeError
|
|
138
163
|
default
|
|
139
164
|
end
|
|
@@ -153,48 +178,28 @@ module SponsoredLogs
|
|
|
153
178
|
nil
|
|
154
179
|
end
|
|
155
180
|
|
|
156
|
-
#
|
|
157
|
-
#
|
|
181
|
+
# Flight-window and cap predicates live in Flight; delegated here so the
|
|
182
|
+
# public selection API (live?, status, capped?, eligible?) is unchanged.
|
|
158
183
|
#
|
|
159
184
|
def self.live?(ad, now)
|
|
160
|
-
|
|
161
|
-
return false if ad[:ends_at] && now > ad[:ends_at]
|
|
162
|
-
|
|
163
|
-
true
|
|
185
|
+
Flight.live?(ad, now)
|
|
164
186
|
end
|
|
165
187
|
|
|
166
|
-
# Whether an ad has reached its impression cap given a current count.
|
|
167
|
-
# Uncapped ads (nil cap) are never capped.
|
|
168
|
-
#
|
|
169
188
|
def self.capped?(ad, count)
|
|
170
|
-
|
|
171
|
-
return false if cap.nil?
|
|
172
|
-
|
|
173
|
-
count.to_i >= cap
|
|
189
|
+
Flight.capped?(ad, count)
|
|
174
190
|
end
|
|
175
191
|
|
|
176
|
-
# Whether an ad is eligible for selection: live at `now` and not capped.
|
|
177
|
-
#
|
|
178
192
|
def self.eligible?(ad, now, count)
|
|
179
|
-
|
|
193
|
+
Flight.eligible?(ad, now, count)
|
|
180
194
|
end
|
|
181
195
|
|
|
182
|
-
# Status of an ad at `now` given its impression count: :exhausted (cap
|
|
183
|
-
# reached), :scheduled (window not started), :ended (window passed),
|
|
184
|
-
# :evergreen (no bounds), or :active.
|
|
185
|
-
#
|
|
186
196
|
def self.status(ad, now = Time.now, count = 0)
|
|
187
|
-
|
|
188
|
-
return :scheduled if ad[:starts_at] && now < ad[:starts_at]
|
|
189
|
-
return :ended if ad[:ends_at] && now > ad[:ends_at]
|
|
190
|
-
return :evergreen if ad[:starts_at].nil? && ad[:ends_at].nil?
|
|
191
|
-
|
|
192
|
-
:active
|
|
197
|
+
Flight.status(ad, now, count)
|
|
193
198
|
end
|
|
194
199
|
|
|
195
200
|
# Pick one normalized ad entry using the given selection mode, considering
|
|
196
201
|
# only ads eligible at `now` -- live within their flight window and under
|
|
197
|
-
# their impression cap (counts is
|
|
202
|
+
# their impression cap (counts is an id => impressions map). In :cpm mode
|
|
198
203
|
# the cpm drives the odds; if every eligible cpm is 0 we fall back to manual
|
|
199
204
|
# weights so selection never stalls.
|
|
200
205
|
#
|
|
@@ -211,7 +216,14 @@ module SponsoredLogs
|
|
|
211
216
|
return if pool.empty?
|
|
212
217
|
|
|
213
218
|
key = SELECTION_MODES.include?(mode) ? mode : :weight
|
|
214
|
-
|
|
219
|
+
|
|
220
|
+
# Fall back to weight when there is no sellable cpm demand. `positive?`
|
|
221
|
+
# (rather than `zero?`) also catches a non-finite or negative sum, so a
|
|
222
|
+
# NaN/Infinity cpm that ever reaches this line can't skip the fallback and
|
|
223
|
+
# poison weighted_pick. coerce_number already zeroes NaN upstream; this is
|
|
224
|
+
# the defense-in-depth guard for the downstream decision.
|
|
225
|
+
#
|
|
226
|
+
key = :weight if key == :cpm && !pool.sum { |ad| ad[:cpm] }.positive?
|
|
215
227
|
|
|
216
228
|
weighted_pick(pool, key)
|
|
217
229
|
end
|
|
@@ -223,11 +235,12 @@ module SponsoredLogs
|
|
|
223
235
|
house_ads? ? DEFAULT_ADS : PAID_ADS
|
|
224
236
|
end
|
|
225
237
|
|
|
226
|
-
#
|
|
238
|
+
# Ids that identify house inventory, used to exclude house ads from
|
|
227
239
|
# selection when the toggle is off (they can arrive via a user-supplied
|
|
228
|
-
# 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.
|
|
229
242
|
#
|
|
230
|
-
|
|
243
|
+
HOUSE_IDS = HOUSE_ADS.map { |ad| Digest::SHA256.hexdigest(ad[:text]) }.freeze
|
|
231
244
|
|
|
232
245
|
# Strip house creatives from a pool when the house_ads toggle is off; a
|
|
233
246
|
# no-op when it is on. Keeps house ads out of rotation everywhere, not just
|
|
@@ -236,7 +249,7 @@ module SponsoredLogs
|
|
|
236
249
|
def self.drop_house(pool)
|
|
237
250
|
return pool if house_ads?
|
|
238
251
|
|
|
239
|
-
pool.reject { |ad|
|
|
252
|
+
pool.reject { |ad| HOUSE_IDS.include?(Identity.id_for(ad)) }
|
|
240
253
|
end
|
|
241
254
|
|
|
242
255
|
# Whether the self-sponsoring house-ad inventory is enabled. Defaults to on
|
|
@@ -252,7 +265,7 @@ module SponsoredLogs
|
|
|
252
265
|
end
|
|
253
266
|
|
|
254
267
|
def self.eligible(pool, now, counts)
|
|
255
|
-
pool.select { |ad| eligible?(ad, now, counts[ad
|
|
268
|
+
pool.select { |ad| eligible?(ad, now, counts[Identity.id_for(ad)].to_i) }
|
|
256
269
|
end
|
|
257
270
|
|
|
258
271
|
# Render a normalized ad. :text ads (the default) stay byte-identical to
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module SponsoredLogs
|
|
6
|
+
# Flight-window and impression-cap predicates for a normalized ad. Extracted
|
|
7
|
+
# from Advertisers so the eligibility rules live in one cohesive place and the
|
|
8
|
+
# host module stays under Metrics/ModuleLength. Advertisers keeps thin
|
|
9
|
+
# delegators for its public surface (live?, status, capped?, eligible?).
|
|
10
|
+
#
|
|
11
|
+
module Flight
|
|
12
|
+
# Whether `now` falls before an ad's flight window opens. A nil starts_at is
|
|
13
|
+
# open-ended, so the ad has always started.
|
|
14
|
+
#
|
|
15
|
+
def self.before_start?(ad, now)
|
|
16
|
+
!ad[:starts_at].nil? && now < ad[:starts_at]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Whether `now` falls after an ad's flight window closes. A nil ends_at is
|
|
20
|
+
# open-ended, so the ad never ends.
|
|
21
|
+
#
|
|
22
|
+
def self.after_end?(ad, now)
|
|
23
|
+
!ad[:ends_at].nil? && now > ad[:ends_at]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Whether an ad is within its flight window at `now`. Missing bounds are
|
|
27
|
+
# open-ended (nil starts_at = always started; nil ends_at = never ends).
|
|
28
|
+
#
|
|
29
|
+
def self.live?(ad, now)
|
|
30
|
+
!before_start?(ad, now) && !after_end?(ad, now)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Whether an ad has reached its impression cap given a current count.
|
|
34
|
+
# Uncapped ads (nil cap) are never capped.
|
|
35
|
+
#
|
|
36
|
+
def self.capped?(ad, count)
|
|
37
|
+
cap = ad[:cap]
|
|
38
|
+
return false if cap.nil?
|
|
39
|
+
|
|
40
|
+
count.to_i >= cap
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Whether an ad is eligible for selection: live at `now` and not capped.
|
|
44
|
+
#
|
|
45
|
+
def self.eligible?(ad, now, count)
|
|
46
|
+
live?(ad, now) && !capped?(ad, count)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Status of an ad at `now` given its impression count: :exhausted (cap
|
|
50
|
+
# reached), :scheduled (window not started), :ended (window passed),
|
|
51
|
+
# :evergreen (no bounds), or :active.
|
|
52
|
+
#
|
|
53
|
+
def self.status(ad, now = Time.now, count = 0)
|
|
54
|
+
return :exhausted if capped?(ad, count)
|
|
55
|
+
return :scheduled if before_start?(ad, now)
|
|
56
|
+
return :ended if after_end?(ad, now)
|
|
57
|
+
return :evergreen if ad[:starts_at].nil? && ad[:ends_at].nil?
|
|
58
|
+
|
|
59
|
+
:active
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -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 { |
|
|
21
|
+
@store.snapshot.sum { |_id, data| data[:impressions] }
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def total_spend
|
|
25
|
-
@store.snapshot.sum { |
|
|
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 |
|
|
29
|
+
@store.snapshot.map do |id, data|
|
|
30
30
|
Entry.new(
|
|
31
|
-
|
|
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
|
|
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
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
|
|
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
|
-
{
|
|
31
|
-
unique_by: :
|
|
31
|
+
{ ad_id: id, text: ad[:text], cpm: ad[:cpm].to_f, impressions: 0 },
|
|
32
|
+
unique_by: :ad_id
|
|
32
33
|
)
|
|
33
|
-
@model.where(
|
|
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.
|
|
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 {
|
|
22
|
-
# cpm: Float } }.
|
|
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
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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[
|
|
21
|
-
@cpm[
|
|
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 |(
|
|
28
|
-
acc[
|
|
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
|
-
|
|
18
|
-
@client.
|
|
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 |(
|
|
26
|
-
acc[
|
|
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
|
|
data/lib/sponsored_logs.rb
CHANGED
|
@@ -5,6 +5,8 @@ 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"
|
|
9
|
+
require_relative "sponsored_logs/flight"
|
|
8
10
|
require_relative "sponsored_logs/banner"
|
|
9
11
|
require_relative "sponsored_logs/ads_file"
|
|
10
12
|
require_relative "sponsored_logs/ledger/store/base"
|
|
@@ -158,39 +160,51 @@ module SponsoredLogs
|
|
|
158
160
|
|
|
159
161
|
private
|
|
160
162
|
|
|
161
|
-
# Map of ad
|
|
163
|
+
# Map of ad id => normalized config metadata (text/weight/cpm/flight/cap),
|
|
162
164
|
# used to enrich report rows and drive status.
|
|
163
165
|
#
|
|
164
166
|
def ad_metadata
|
|
165
|
-
Advertisers.normalize(configuration.ads).to_h { |ad| [ad[:
|
|
167
|
+
Advertisers.normalize(configuration.ads).to_h { |ad| [ad[:id], ad] }
|
|
166
168
|
end
|
|
167
169
|
|
|
168
170
|
# Partition every known ad (served or configured) into running / upcoming /
|
|
169
|
-
# 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.
|
|
170
173
|
#
|
|
171
174
|
def grouped_report_rows
|
|
172
175
|
now = Time.now
|
|
173
176
|
metas = ad_metadata
|
|
174
177
|
counts = ledger.impression_counts
|
|
175
|
-
served = ledger.entries.to_h { |entry| [entry.
|
|
178
|
+
served = ledger.entries.to_h { |entry| [entry.id, entry] }
|
|
176
179
|
|
|
177
180
|
grouped = Hash.new { |h, k| h[k] = [] }
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
case status
|
|
185
|
-
when :scheduled then grouped[:upcoming] << row
|
|
186
|
-
when :ended, :exhausted then grouped[:finished] << row
|
|
187
|
-
else grouped[:running] << row if served[text]
|
|
188
|
-
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
|
|
189
186
|
end
|
|
190
187
|
|
|
191
188
|
grouped
|
|
192
189
|
end
|
|
193
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
|
+
|
|
194
208
|
# A single report row. Impressions/spend come from the ledger entry when the
|
|
195
209
|
# ad has served, otherwise zero; window/cap come from config metadata.
|
|
196
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kerri Miller
|
|
@@ -127,6 +127,8 @@ files:
|
|
|
127
127
|
- lib/sponsored_logs/configuration.rb
|
|
128
128
|
- lib/sponsored_logs/engine.rb
|
|
129
129
|
- lib/sponsored_logs/env.rb
|
|
130
|
+
- lib/sponsored_logs/flight.rb
|
|
131
|
+
- lib/sponsored_logs/identity.rb
|
|
130
132
|
- lib/sponsored_logs/injector.rb
|
|
131
133
|
- lib/sponsored_logs/ledger/report.rb
|
|
132
134
|
- lib/sponsored_logs/ledger/store/active_record.rb
|