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 +4 -4
- data/CHANGELOG.md +13 -1
- data/README.md +20 -0
- data/lib/generators/sponsored_logs/templates/create_sponsored_logs_impressions.rb.tt +2 -2
- data/lib/sponsored_logs/advertisers.rb +9 -6
- 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 +28 -15
- metadata +2 -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,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.
|
|
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 :
|
|
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
|
|
@@ -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
|
|
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
|
-
#
|
|
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
|
-
|
|
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|
|
|
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
|
|
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 { |
|
|
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,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
|
|
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[:
|
|
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.
|
|
178
|
+
served = ledger.entries.to_h { |entry| [entry.id, entry] }
|
|
177
179
|
|
|
178
180
|
grouped = Hash.new { |h, k| h[k] = [] }
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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.
|
|
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
|