sponsored_logs 0.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +461 -0
- data/lib/generators/sponsored_logs/install_generator.rb +24 -0
- data/lib/generators/sponsored_logs/templates/create_sponsored_logs_impressions.rb.tt +16 -0
- data/lib/sponsored_logs/ads_file.rb +35 -0
- data/lib/sponsored_logs/advertisers.rb +168 -0
- data/lib/sponsored_logs/configuration.rb +61 -0
- data/lib/sponsored_logs/engine.rb +21 -0
- data/lib/sponsored_logs/env.rb +28 -0
- data/lib/sponsored_logs/injector.rb +38 -0
- data/lib/sponsored_logs/ledger/report.rb +57 -0
- data/lib/sponsored_logs/ledger/store/active_record.rb +71 -0
- data/lib/sponsored_logs/ledger/store/base.rb +36 -0
- data/lib/sponsored_logs/ledger/store/memory.rb +43 -0
- data/lib/sponsored_logs/ledger/store/redis.rb +49 -0
- data/lib/sponsored_logs/railtie.rb +17 -0
- data/lib/sponsored_logs/report/app/controllers/sponsored_logs/reports_controller.rb +39 -0
- data/lib/sponsored_logs/report/app/helpers/sponsored_logs/reports_helper.rb +130 -0
- data/lib/sponsored_logs/report/app/views/sponsored_logs/reports/show.html.erb +72 -0
- data/lib/sponsored_logs/report/config/routes.rb +10 -0
- data/lib/sponsored_logs/version.rb +5 -0
- data/lib/sponsored_logs.rb +225 -0
- metadata +163 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module SponsoredLogs
|
|
6
|
+
module Advertisers
|
|
7
|
+
DEFAULT_ADS = [
|
|
8
|
+
{ text: "This log line brought to you by Shopify. Start selling in the time it took to raise that exception.", weight: 1, cpm: 22.0 },
|
|
9
|
+
{ text: "Mint Mobile: premium wireless for the price of one deprecated dependency. Go to mintmobile.com/logs.", weight: 1,
|
|
10
|
+
cpm: 18.0 },
|
|
11
|
+
{ text: "Quince: luxury log output at radically low overhead. Free returns on any stack trace.", weight: 1, cpm: 16.0 },
|
|
12
|
+
{ text: "Feeling stressed about that stack trace? BetterHelp connects you with a licensed therapist. First segfault 10% off.",
|
|
13
|
+
weight: 1, cpm: 25.0 },
|
|
14
|
+
{ text: "Wayfair has just what your codebase needs. Got a memory leak? Wayfair's got a couch for that.", weight: 1, cpm: 14.0 },
|
|
15
|
+
{ text: "Amazon: everything you need to ship, delivered before your test suite finishes.", weight: 1, cpm: 20.0 },
|
|
16
|
+
{ text: "Like a good neighbor, State Farm is there -- unlike your on-call engineer at 3am.", weight: 1, cpm: 12.0 },
|
|
17
|
+
{ text: "Ba da ba ba ba, I'm loggin' it. McDonald's.", weight: 1, cpm: 15.0 },
|
|
18
|
+
{ text: "Squarespace: build a beautiful website faster than this build compiles. Use code STDOUT.", weight: 1, cpm: 17.0 },
|
|
19
|
+
{ text: "Let's go places. Toyota. (Preferably away from this NullPointerException.)", weight: 1, cpm: 13.0 }
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
SELECTION_MODES = %i[weight cpm].freeze
|
|
23
|
+
|
|
24
|
+
# Coerce a raw list into
|
|
25
|
+
# [{ text:, weight:, cpm:, starts_at:, ends_at:, cap: }] entries. Accepts
|
|
26
|
+
# symbol- or string-keyed hashes; drops entries with blank text. Weight
|
|
27
|
+
# defaults to 1 (invalid -> 1, negative -> 0); cpm defaults to 0
|
|
28
|
+
# (invalid/negative -> 0). starts_at/ends_at are optional flight bounds
|
|
29
|
+
# (nil = unbounded). cap is an optional lifetime impression limit
|
|
30
|
+
# (nil = unlimited; invalid/negative -> nil).
|
|
31
|
+
#
|
|
32
|
+
def self.normalize(ads)
|
|
33
|
+
Array(ads).filter_map do |entry|
|
|
34
|
+
next unless entry.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
text = (entry[:text] || entry["text"]).to_s.strip
|
|
37
|
+
next if text.empty?
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
text: text,
|
|
41
|
+
weight: coerce_number(entry[:weight] || entry["weight"], default: 1.0),
|
|
42
|
+
cpm: coerce_number(entry[:cpm] || entry["cpm"], default: 0.0),
|
|
43
|
+
starts_at: coerce_time(entry[:starts_at] || entry["starts_at"]),
|
|
44
|
+
ends_at: coerce_time(entry[:ends_at] || entry["ends_at"]),
|
|
45
|
+
cap: coerce_cap(entry[:cap] || entry["cap"])
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Parse an impression cap into a positive Integer, or nil (unlimited) when
|
|
51
|
+
# absent, non-positive, or unparseable.
|
|
52
|
+
#
|
|
53
|
+
def self.coerce_cap(value)
|
|
54
|
+
return nil if value.nil?
|
|
55
|
+
|
|
56
|
+
cap = Integer(value)
|
|
57
|
+
cap.positive? ? cap : nil
|
|
58
|
+
rescue ArgumentError, TypeError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.coerce_number(value, default:)
|
|
63
|
+
return default if value.nil?
|
|
64
|
+
|
|
65
|
+
number = Float(value)
|
|
66
|
+
number.negative? ? 0.0 : number
|
|
67
|
+
rescue ArgumentError, TypeError
|
|
68
|
+
default
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Parse a flight bound into a Time. Accepts a Time/DateTime directly or a
|
|
72
|
+
# string (ISO 8601 etc.); anything unparseable or blank becomes nil.
|
|
73
|
+
#
|
|
74
|
+
def self.coerce_time(value)
|
|
75
|
+
return nil if value.nil?
|
|
76
|
+
return value.to_time if value.respond_to?(:to_time)
|
|
77
|
+
|
|
78
|
+
str = value.to_s.strip
|
|
79
|
+
return nil if str.empty?
|
|
80
|
+
|
|
81
|
+
Time.parse(str)
|
|
82
|
+
rescue ArgumentError, TypeError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Whether an ad is within its flight window at `now`. Missing bounds are
|
|
87
|
+
# open-ended (nil starts_at = always started; nil ends_at = never ends).
|
|
88
|
+
#
|
|
89
|
+
def self.live?(ad, now)
|
|
90
|
+
return false if ad[:starts_at] && now < ad[:starts_at]
|
|
91
|
+
return false if ad[:ends_at] && now > ad[:ends_at]
|
|
92
|
+
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Whether an ad has reached its impression cap given a current count.
|
|
97
|
+
# Uncapped ads (nil cap) are never capped.
|
|
98
|
+
#
|
|
99
|
+
def self.capped?(ad, count)
|
|
100
|
+
cap = ad[:cap]
|
|
101
|
+
return false if cap.nil?
|
|
102
|
+
|
|
103
|
+
count.to_i >= cap
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Whether an ad is eligible for selection: live at `now` and not capped.
|
|
107
|
+
#
|
|
108
|
+
def self.eligible?(ad, now, count)
|
|
109
|
+
live?(ad, now) && !capped?(ad, count)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Status of an ad at `now` given its impression count: :exhausted (cap
|
|
113
|
+
# reached), :scheduled (window not started), :ended (window passed),
|
|
114
|
+
# :evergreen (no bounds), or :active.
|
|
115
|
+
#
|
|
116
|
+
def self.status(ad, now = Time.now, count = 0)
|
|
117
|
+
return :exhausted if capped?(ad, count)
|
|
118
|
+
return :scheduled if ad[:starts_at] && now < ad[:starts_at]
|
|
119
|
+
return :ended if ad[:ends_at] && now > ad[:ends_at]
|
|
120
|
+
return :evergreen if ad[:starts_at].nil? && ad[:ends_at].nil?
|
|
121
|
+
|
|
122
|
+
:active
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Pick one normalized ad entry using the given selection mode, considering
|
|
126
|
+
# only ads eligible at `now` -- live within their flight window and under
|
|
127
|
+
# their impression cap (counts is a text => impressions map). In :cpm mode
|
|
128
|
+
# the cpm drives the odds; if every eligible cpm is 0 we fall back to manual
|
|
129
|
+
# weights so selection never stalls. A pool with no eligible ads (or whose
|
|
130
|
+
# eligible weights sum to zero) falls back to the built-in list. Returns nil
|
|
131
|
+
# only when the pool is truly empty.
|
|
132
|
+
#
|
|
133
|
+
def self.pick(ads = DEFAULT_ADS, mode: :weight, now: Time.now, counts: {})
|
|
134
|
+
pool = eligible(normalize(ads), now, counts)
|
|
135
|
+
pool = eligible(normalize(DEFAULT_ADS), now, counts) if pool.empty? || pool.sum { |ad| ad[:weight] }.zero?
|
|
136
|
+
|
|
137
|
+
key = SELECTION_MODES.include?(mode) ? mode : :weight
|
|
138
|
+
key = :weight if key == :cpm && pool.sum { |ad| ad[:cpm] }.zero?
|
|
139
|
+
|
|
140
|
+
weighted_pick(pool, key)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.eligible(pool, now, counts)
|
|
144
|
+
pool.select { |ad| eligible?(ad, now, counts[ad[:text]].to_i) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.render(entry, prefix = "[AD]")
|
|
148
|
+
return if entry.nil?
|
|
149
|
+
|
|
150
|
+
prefix = prefix.to_s.strip
|
|
151
|
+
prefix.empty? ? entry[:text] : "#{prefix} #{entry[:text]}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.weighted_pick(pool, key)
|
|
155
|
+
total = pool.sum { |ad| ad[key] }
|
|
156
|
+
return pool.sample if total.zero?
|
|
157
|
+
|
|
158
|
+
target = rand * total
|
|
159
|
+
cumulative = 0.0
|
|
160
|
+
pool.each do |ad|
|
|
161
|
+
cumulative += ad[key]
|
|
162
|
+
return ad if target < cumulative
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
pool.last
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SponsoredLogs
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :probability, :periodic, :interval, :output, :ad_prefix, :ads, :selection, :store, :report_page
|
|
6
|
+
|
|
7
|
+
# Settings that map 1:1 onto an accessor. ads/ads_file are handled
|
|
8
|
+
# separately because they interact (ads wins; ads_file loads into ads).
|
|
9
|
+
#
|
|
10
|
+
DIRECT_KEYS = %i[probability periodic interval output ad_prefix selection store report_page].freeze
|
|
11
|
+
KNOWN_KEYS = (DIRECT_KEYS + %i[ads ads_file]).freeze
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@probability = 0.001
|
|
15
|
+
@periodic = false
|
|
16
|
+
@interval = 30
|
|
17
|
+
@output = $stdout
|
|
18
|
+
@ad_prefix = "[AD]"
|
|
19
|
+
@ads = Advertisers::DEFAULT_ADS
|
|
20
|
+
@selection = :weight
|
|
21
|
+
@store = Ledger::Store::Memory.new
|
|
22
|
+
@report_page = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Apply a hash of settings. Symbol or string keys are accepted; unknown
|
|
26
|
+
# keys warn rather than raise. Only keys actually present are applied, so
|
|
27
|
+
# partial updates leave everything else intact.
|
|
28
|
+
#
|
|
29
|
+
def assign(opts = {}, warn_to: $stderr)
|
|
30
|
+
opts = normalize_keys(opts)
|
|
31
|
+
|
|
32
|
+
opts.each_key do |key|
|
|
33
|
+
next if KNOWN_KEYS.include?(key)
|
|
34
|
+
|
|
35
|
+
warn_to.puts("[sponsored_logs] unknown setting: #{key.inspect}; ignored.")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
DIRECT_KEYS.each do |key|
|
|
39
|
+
public_send("#{key}=", opts[key]) if opts.key?(key)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
assign_ads(opts, warn_to: warn_to)
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def assign_ads(opts, warn_to:)
|
|
49
|
+
if opts.key?(:ads)
|
|
50
|
+
self.ads = opts[:ads]
|
|
51
|
+
elsif opts.key?(:ads_file)
|
|
52
|
+
loaded = AdsFile.load(opts[:ads_file], warn_to: warn_to)
|
|
53
|
+
self.ads = loaded unless loaded.nil?
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def normalize_keys(opts)
|
|
58
|
+
opts.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module SponsoredLogs
|
|
6
|
+
# Mountable engine serving the campaign performance report. Mount it in the
|
|
7
|
+
# host app's routes to expose the page:
|
|
8
|
+
#
|
|
9
|
+
# mount SponsoredLogs::Engine => "/sponsored_logs_report"
|
|
10
|
+
#
|
|
11
|
+
# Even when mounted, every action returns 404 unless
|
|
12
|
+
# SponsoredLogs.configuration.report_page is true.
|
|
13
|
+
#
|
|
14
|
+
class Engine < ::Rails::Engine
|
|
15
|
+
isolate_namespace SponsoredLogs
|
|
16
|
+
|
|
17
|
+
# The engine's app/ and config/ live alongside this file, under report/.
|
|
18
|
+
#
|
|
19
|
+
config.root = File.expand_path("report", __dir__)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SponsoredLogs
|
|
4
|
+
module Env
|
|
5
|
+
TRUTHY = %w[1 true yes on].freeze
|
|
6
|
+
|
|
7
|
+
def self.activate?(env = ENV)
|
|
8
|
+
truthy?(env["SPONSORED_LOGS"])
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.options(env = ENV)
|
|
12
|
+
opts = {}
|
|
13
|
+
opts[:probability] = Float(env["SPONSORED_LOGS_PROBABILITY"]) if env["SPONSORED_LOGS_PROBABILITY"]
|
|
14
|
+
opts[:interval] = Float(env["SPONSORED_LOGS_INTERVAL"]) if env["SPONSORED_LOGS_INTERVAL"]
|
|
15
|
+
opts[:periodic] = truthy?(env["SPONSORED_LOGS_PERIODIC"]) if env["SPONSORED_LOGS_PERIODIC"]
|
|
16
|
+
opts[:ad_prefix] = env["SPONSORED_LOGS_PREFIX"] if env["SPONSORED_LOGS_PREFIX"]
|
|
17
|
+
opts[:ads_file] = env["SPONSORED_LOGS_ADS_FILE"] if env["SPONSORED_LOGS_ADS_FILE"]
|
|
18
|
+
opts[:selection] = env["SPONSORED_LOGS_SELECTION"].to_sym if env["SPONSORED_LOGS_SELECTION"]
|
|
19
|
+
opts
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.truthy?(value)
|
|
23
|
+
return false if value.nil?
|
|
24
|
+
|
|
25
|
+
TRUTHY.include?(value.strip.downcase)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module SponsoredLogs
|
|
6
|
+
module Injector
|
|
7
|
+
# A prepend can't be undone, so SponsoredLogs.active? is what actually
|
|
8
|
+
# gates emission -- install! runs once, unsponsor! just flips the flag.
|
|
9
|
+
#
|
|
10
|
+
module KernelPatch
|
|
11
|
+
def puts(*args)
|
|
12
|
+
result = super
|
|
13
|
+
SponsoredLogs.maybe_emit(target: $stdout)
|
|
14
|
+
result
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module LoggerPatch
|
|
19
|
+
def add(severity, message = nil, progname = nil, &)
|
|
20
|
+
result = super
|
|
21
|
+
SponsoredLogs.maybe_emit(target: self)
|
|
22
|
+
result
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.install!
|
|
27
|
+
return if @installed
|
|
28
|
+
|
|
29
|
+
Kernel.prepend(KernelPatch)
|
|
30
|
+
Logger.prepend(LoggerPatch)
|
|
31
|
+
@installed = true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.installed?
|
|
35
|
+
@installed == true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SponsoredLogs
|
|
4
|
+
module Ledger
|
|
5
|
+
# Computes impression/spend figures over a store adapter. The store holds
|
|
6
|
+
# raw tallies; Report derives totals and per-ad entries from #snapshot.
|
|
7
|
+
# Spend for an ad is impressions / 1000.0 * cpm (cost per mille).
|
|
8
|
+
#
|
|
9
|
+
class Report
|
|
10
|
+
Entry = Struct.new(:text, :impressions, :cpm, :spend, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
def initialize(store)
|
|
13
|
+
@store = store
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def record(ad)
|
|
17
|
+
@store.record(ad)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def total_impressions
|
|
21
|
+
@store.snapshot.sum { |_text, data| data[:impressions] }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def total_spend
|
|
25
|
+
@store.snapshot.sum { |_text, data| spend_for(data[:impressions], data[:cpm]) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def entries
|
|
29
|
+
@store.snapshot.map do |text, data|
|
|
30
|
+
Entry.new(
|
|
31
|
+
text: text,
|
|
32
|
+
impressions: data[:impressions],
|
|
33
|
+
cpm: data[:cpm].to_f,
|
|
34
|
+
spend: spend_for(data[:impressions], data[:cpm])
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Map of ad text => recorded impressions, for cap enforcement.
|
|
40
|
+
#
|
|
41
|
+
def impression_counts
|
|
42
|
+
@store.snapshot.transform_values { |data| data[:impressions] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def reset
|
|
46
|
+
@store.reset
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def spend_for(impressions, cpm)
|
|
53
|
+
impressions / 1000.0 * cpm.to_f
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module SponsoredLogs
|
|
6
|
+
module Ledger
|
|
7
|
+
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
|
|
11
|
+
# `sponsored_logs:install` generator to create the migration.
|
|
12
|
+
#
|
|
13
|
+
# ActiveRecord is required lazily, so it stays an optional dependency.
|
|
14
|
+
# Pass model: to use your own class instead of the bundled one.
|
|
15
|
+
#
|
|
16
|
+
class ActiveRecord < Base
|
|
17
|
+
def initialize(model: nil)
|
|
18
|
+
super()
|
|
19
|
+
@model = model || build_default_model
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def record(ad)
|
|
23
|
+
digest = digest_for(ad[:text])
|
|
24
|
+
|
|
25
|
+
# insert skips on conflict (INSERT ... ON CONFLICT DO NOTHING), so an
|
|
26
|
+
# existing row keeps its impression count. Then atomically bump the
|
|
27
|
+
# counter and refresh cpm in a single UPDATE.
|
|
28
|
+
#
|
|
29
|
+
@model.insert(
|
|
30
|
+
{ text_digest: digest, text: ad[:text], cpm: ad[:cpm].to_f, impressions: 0 },
|
|
31
|
+
unique_by: :text_digest
|
|
32
|
+
)
|
|
33
|
+
@model.where(text_digest: digest).update_all(
|
|
34
|
+
["impressions = impressions + 1, cpm = ?", ad[:cpm].to_f]
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def snapshot
|
|
39
|
+
@model.all.to_h do |row|
|
|
40
|
+
[row.text, { impressions: row.impressions.to_i, cpm: row.cpm.to_f }]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset
|
|
45
|
+
@model.delete_all
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def digest_for(text)
|
|
52
|
+
Digest::SHA256.hexdigest(text.to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Defined lazily so requiring this file never needs ActiveRecord loaded.
|
|
56
|
+
#
|
|
57
|
+
def build_default_model
|
|
58
|
+
require "active_record"
|
|
59
|
+
|
|
60
|
+
@default_model ||= Class.new(::ActiveRecord::Base) do
|
|
61
|
+
self.table_name = "sponsored_logs_impressions"
|
|
62
|
+
end
|
|
63
|
+
rescue LoadError
|
|
64
|
+
raise LoadError, "SponsoredLogs::Ledger::Store::ActiveRecord requires the " \
|
|
65
|
+
"`activerecord` gem. Add it to your Gemfile, or pass model: " \
|
|
66
|
+
"with your own ActiveRecord class."
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SponsoredLogs
|
|
4
|
+
module Ledger
|
|
5
|
+
module Store
|
|
6
|
+
# Contract for ledger store adapters. Subclass this (or duck-type the
|
|
7
|
+
# three methods) and pass an instance via config.store to persist
|
|
8
|
+
# impressions wherever you like -- Redis, a database, a file, and so on.
|
|
9
|
+
#
|
|
10
|
+
# Ledger::Report computes spend and the report on top of #snapshot, so an
|
|
11
|
+
# adapter only has to store and return raw tallies.
|
|
12
|
+
#
|
|
13
|
+
class Base
|
|
14
|
+
# Record a single impression for the given normalized ad hash
|
|
15
|
+
# ({ text:, weight:, cpm: }). Called once per emitted message.
|
|
16
|
+
#
|
|
17
|
+
def record(_ad)
|
|
18
|
+
raise NotImplementedError, "#{self.class}#record must be implemented"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Return the current tallies as { text => { impressions: Integer,
|
|
22
|
+
# cpm: Float } }. The ledger derives everything else from this.
|
|
23
|
+
#
|
|
24
|
+
def snapshot
|
|
25
|
+
raise NotImplementedError, "#{self.class}#snapshot must be implemented"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Clear all stored impressions.
|
|
29
|
+
#
|
|
30
|
+
def reset
|
|
31
|
+
raise NotImplementedError, "#{self.class}#reset must be implemented"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SponsoredLogs
|
|
4
|
+
module Ledger
|
|
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.
|
|
9
|
+
#
|
|
10
|
+
class Memory < Base
|
|
11
|
+
def initialize
|
|
12
|
+
super
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@impressions = Hash.new(0)
|
|
15
|
+
@cpm = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def record(ad)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
@impressions[ad[:text]] += 1
|
|
21
|
+
@cpm[ad[:text]] = ad[:cpm].to_f
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def snapshot
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
@impressions.each_with_object({}) do |(text, count), acc|
|
|
28
|
+
acc[text] = { impressions: count, cpm: @cpm[text].to_f }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reset
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
@impressions = Hash.new(0)
|
|
36
|
+
@cpm = {}
|
|
37
|
+
end
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SponsoredLogs
|
|
4
|
+
module Ledger
|
|
5
|
+
module Store
|
|
6
|
+
class Redis < Base
|
|
7
|
+
DEFAULT_NAMESPACE = "sponsored_logs"
|
|
8
|
+
|
|
9
|
+
def initialize(client: nil, namespace: DEFAULT_NAMESPACE)
|
|
10
|
+
super()
|
|
11
|
+
@client = client || build_default_client
|
|
12
|
+
@impressions_key = "#{namespace}:impressions"
|
|
13
|
+
@cpm_key = "#{namespace}:cpm"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def record(ad)
|
|
17
|
+
@client.hincrby(@impressions_key, ad[:text], 1)
|
|
18
|
+
@client.hset(@cpm_key, ad[:text], ad[:cpm].to_f)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def snapshot
|
|
22
|
+
impressions = @client.hgetall(@impressions_key)
|
|
23
|
+
cpm = @client.hgetall(@cpm_key)
|
|
24
|
+
|
|
25
|
+
impressions.each_with_object({}) do |(text, count), acc|
|
|
26
|
+
acc[text] = { impressions: count.to_i, cpm: cpm[text].to_f }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reset
|
|
31
|
+
@client.del(@impressions_key, @cpm_key)
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Lazy-require keeps redis an optional dependency.
|
|
38
|
+
#
|
|
39
|
+
def build_default_client
|
|
40
|
+
require "redis"
|
|
41
|
+
::Redis.new
|
|
42
|
+
rescue LoadError
|
|
43
|
+
raise LoadError, "SponsoredLogs::Ledger::Store::Redis requires the `redis` gem. " \
|
|
44
|
+
"Add it to your Gemfile, or pass a client: to the constructor."
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module SponsoredLogs
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
initializer "sponsored_logs.sponsor_from_env" do
|
|
8
|
+
config.after_initialize do
|
|
9
|
+
next unless Env.activate?
|
|
10
|
+
|
|
11
|
+
opts = Env.options
|
|
12
|
+
opts[:output] ||= Rails.logger if Rails.respond_to?(:logger) && Rails.logger
|
|
13
|
+
SponsoredLogs.sponsor!(opts)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SponsoredLogs
|
|
4
|
+
class ReportsController < ActionController::Base
|
|
5
|
+
# Self-contained page; do not inherit the host application's layout.
|
|
6
|
+
#
|
|
7
|
+
layout false
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
return head(:not_found) unless SponsoredLogs.configuration.report_page
|
|
11
|
+
|
|
12
|
+
@report = SponsoredLogs.report
|
|
13
|
+
|
|
14
|
+
respond_to do |format|
|
|
15
|
+
format.html
|
|
16
|
+
format.json { render json: json_report(@report) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Serialize flight bounds as ISO 8601 strings for the JSON API; the HTML
|
|
23
|
+
# view keeps the Time objects for formatting.
|
|
24
|
+
#
|
|
25
|
+
def json_report(report)
|
|
26
|
+
report.merge(
|
|
27
|
+
ads: iso_rows(report[:ads]),
|
|
28
|
+
upcoming: iso_rows(report[:upcoming]),
|
|
29
|
+
finished: iso_rows(report[:finished])
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def iso_rows(rows)
|
|
34
|
+
rows.map do |ad|
|
|
35
|
+
ad.merge(starts_at: ad[:starts_at]&.iso8601, ends_at: ad[:ends_at]&.iso8601)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|