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.
@@ -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