sponsored_logs 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be8c55d29f6cb9e44a75def20e54a4a7ca8b5c3fcb2eab2d973a67f1237847cc
4
- data.tar.gz: '0033992014c5b8b54c847d7f7b139738323901721a965d718edb62b9820c3844'
3
+ metadata.gz: 6f7ed20148d47d4ae067aca15087fcb756ebe60daf9904f12b44a5b6408c81a8
4
+ data.tar.gz: 3de566f7682c17b0cb8de899fbbaf53915bfdb33c23f3b3990c2da5e7db8dde5
5
5
  SHA512:
6
- metadata.gz: fdce138553b30eeb611c03013da3c80ce7e642927873b1ff7b07c91978cf72603f9f928b2f7c74c53cb5e553e64ced063dcfcbb4130c272bbdfcbb69a6ca034b
7
- data.tar.gz: 0e71d8587e4d60f9ed8235fdf6f414a54a77a0b0ea337ed38b12bfc715bcf16698441c5ebc79be2e84f1066ded1330809d09953fdd86402d05e0fc091b422c2f
6
+ metadata.gz: 51e1e530a4a0fcb83ccaec861c55bada9dc94d3d55f65b15a45c4be2d9eea0cc844161159e9e1aaaf31521e17aee0fa58e801bef72e8c32dd1cc669ff0d5fb80
7
+ data.tar.gz: cc14c9358180c8258561b0e3946209f875b34dbfd976307d8686d816a4ebd977eced3e295144d016e8db8cda1229a1e9ad19002e676e8099c8e08e263ed28034
data/CHANGELOG.md ADDED
@@ -0,0 +1,77 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] - 2026-09-07
11
+
12
+ ### Fixed
13
+
14
+ - Marketing site: sponsor placements in the demo terminal are now delivered reliably. Renamed inventory-bearing CSS classes so client-side ad blockers can no longer suppress the platform's own impressions, and corrected the log-line layout so each placement occupies its own row ([#4](https://github.com/sponsoredlogs/sponsored_logs/pull/4))
15
+
16
+ ### Added
17
+
18
+ - Brand-safe gilding of the `[AD]` prefix: on a live terminal the tag now renders in premium 256-color gold (`\e[38;5;214m … \e[0m`), turning a plain tag into an above-the-fold trust signal. The escape codes are zero-width, so banner borders stay byte-for-byte aligned. Gilding is delivered only to a real TTY with color enabled — files, pipes, and `Logger` sinks receive the byte-identical plain line, and the [`NO_COLOR`](https://no-color.org) convention (any non-empty value) suppresses `:auto`. New `color` config option and `SPONSORED_LOGS_COLOR` env var accept `:auto` (default — TTY + `NO_COLOR`-clear), `:always` (force gold, overrides `NO_COLOR`), or `:never` (plain everywhere); unrecognized values settle to `:auto`
19
+ - Self-sponsoring house ads — remnant fill so no impression goes to waste. The built-in list splits into `Advertisers::PAID_ADS` (the ten real-brand creatives) and `Advertisers::HOUSE_ADS` (three SponsoredLogs creatives at `weight: 1`, `cpm: 0.0`); `Advertisers::DEFAULT_ADS` is now their sum (13 rows). House ads compete in the default rotation (~3-in-13) and act as the final remnant floor when no paid creative is eligible (empty/capped/out-of-flight/zero-weight pool), so `pick` returns a creative rather than `nil`. They bill at zero and roll up to a `"SponsoredLogs"` advertiser account with zero spend. New `house_ads` config toggle (default `true`) and `SPONSORED_LOGS_HOUSE_ADS` env var govern house inventory everywhere: turning it off excludes house ads from rotation and retires the floor, restoring the original `pick`-returns-`nil`-when-exhausted contract
20
+ - Activated sponsor inventory across the project's own documentation surface: the README and this changelog now carry host-read placements between sections. Premium above-the-fold attention should not go unmonetized simply because it happens to be ours
21
+ - Per-ad `format: :banner` — premium box-drawn, multi-line placement that turns a single log line into a full above-the-fold impression unit. The `ad_prefix` embeds in the top border; the ~60-column body word-wraps (long words break mid-word so nothing overflows the frame). Inventory is optimized for standard-width Latin creative — emoji/CJK copy renders wider than one cell and may shift the right border, with `ascii_only` as the portable fallback
22
+ - Per-ad `box` impact tier for banners: `:light` (standard), `:heavy` (premium impact), or `:double` (maximum impact); unrecognized tiers settle to `:light`
23
+ - Global `ascii_only` config (and `SPONSORED_LOGS_ASCII_ONLY` env var) that overrides every box tier with the portable `+`/`-`/`|` glyph set for log sinks that mangle Unicode
24
+ - Per-ad `advertiser` field so creatives roll up to an advertiser account — the multi-tenant yield layer the exchange has been waiting for; defaults to `"Unattributed"` when omitted, and the built-in ads carry real brand names ([#1](https://github.com/sponsoredlogs/sponsored_logs/pull/1))
25
+ - `report[:advertisers]` rollup: per-advertiser impressions, spend, and ad count, sorted by spend so top accounts surface first ([#1](https://github.com/sponsoredlogs/sponsored_logs/pull/1))
26
+ - Dashboard "Advertiser accounts" table and an Advertiser column on the campaign tables ([#1](https://github.com/sponsoredlogs/sponsored_logs/pull/1))
27
+ - Share-of-impressions donut alongside the share-of-spend donut; both cap at the top 7 non-zero advertisers and fold the remainder into an "Other" slice ([#1](https://github.com/sponsoredlogs/sponsored_logs/pull/1))
28
+
29
+ ### Changed
30
+
31
+ - Charts now aggregate by advertiser instead of per-ad, so labels are advertiser names rather than ad copy ([#1](https://github.com/sponsoredlogs/sponsored_logs/pull/1))
32
+ - The gold gradient now accents the totals card values (the spend/impressions bar chart it previously filled has been removed as redundant with the share-of-spend donut) ([#1](https://github.com/sponsoredlogs/sponsored_logs/pull/1))
33
+
34
+ ### Removed
35
+
36
+ - Redundant "Spend by advertiser" bar chart (superseded by the share-of-spend donut) and the now-unused bar-chart helper ([#1](https://github.com/sponsoredlogs/sponsored_logs/pull/1))
37
+ - Support for Ruby 3.1: dropped from the CI matrix, `required_ruby_version` raised to `>= 3.2`, and RuboCop's `TargetRubyVersion` aligned to match. Rails 8.1 no longer resolves on 3.1 ([#3](https://github.com/sponsoredlogs/sponsored_logs/pull/3))
38
+
39
+ ## [0.2.0] - 2026-09-06
40
+
41
+ ### Added
42
+
43
+ - The "Command Center" — a dark-mode revenue dashboard styled to match the project banner (navy gradient, gold/cyan accents, monospace numerics, terminal chrome). Stop grepping your revenue; start visualizing it
44
+ - Share-of-spend donut chart, so yield concentration is legible at a glance
45
+ - Delivery-to-goal pacing bars that track each campaign against its impression cap — governance is a feature
46
+
47
+ ### Changed
48
+
49
+ - Refreshed the dashboard screenshot in the README to reflect the Command Center
50
+ - Reordered the README to lead with the Agentic Advantage, and sprinkled emoji throughout — the machine audience deserves a warm welcome
51
+
52
+ > `[AD]` This release cycle sponsored by **ShipFaster CI** — because your
53
+ > changelog should ship as fast as your excuses. shipfaster.dev/logs
54
+
55
+ ## [0.1.0] - 2026-09-06
56
+
57
+ ### Added
58
+
59
+ - Initial platform launch: opt-in activation of log inventory via `SponsoredLogs.sponsor!`, which prepends override modules onto `Kernel#puts` and `Logger#add` to serve placements alongside your telemetry
60
+ - Two-stage auction: weighted or CPM-based (`selection: :cpm`) message selection, so the highest bidder wins more inventory
61
+ - Per-ad campaign controls — `weight`, `cpm`, flighting (`starts_at` / `ends_at`), and a lifetime impression `cap` (frequency governance)
62
+ - Bring-your-own-demand pools, inline (`ads:`) or from a JSON file (`ads_file:`)
63
+ - Full-funnel attribution: `SponsoredLogs.report` for structured revenue data, plus a formatted `SponsoredLogs.report_text` table
64
+ - Pluggable, cloud-agnostic impression storage: `Ledger::Store::Memory` (default), `Ledger::Store::Redis`, and `Ledger::Store::ActiveRecord` (with an `install` generator)
65
+ - Mountable Rails report engine (`SponsoredLogs::Engine`) exposing an HTML dashboard and JSON API, gated by `config.report_page`
66
+ - Running / upcoming / finished campaign grouping with flight status badges
67
+ - Activation and configuration via environment variables (`SPONSORED_LOGS`, `SPONSORED_LOGS_*`) and a Railtie for zero-friction Rails onboarding
68
+ - GitHub Actions CI across Ruby 3.1–4.0 and RuboCop — excellence is a discipline, not a moment
69
+
70
+ ### Notes
71
+
72
+ - Requires Ruby >= 3.1
73
+
74
+ [Unreleased]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.3.0...HEAD
75
+ [0.3.0]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.2.0...v0.3.0
76
+ [0.2.0]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.1.0...v0.2.0
77
+ [0.1.0]: https://github.com/sponsoredlogs/sponsored_logs/releases/tag/v0.1.0
data/README.md CHANGED
@@ -97,6 +97,10 @@ Then run:
97
97
  bundle install
98
98
  ```
99
99
 
100
+ > `[AD]` This installation brought to you by **DepGuard** — the only
101
+ > dependency scanner that reads your Gemfile at the speed of regret. Your
102
+ > `bundle install` deserves a sponsor. depguard.dev/logs
103
+
100
104
  ## ⚡ Usage
101
105
 
102
106
  > Activation is opt-in — because at SponsoredLogs, **trust is our north-star
@@ -167,9 +171,20 @@ Set `ad_prefix` to an empty string to omit the tag entirely.
167
171
  | `interval` | `30` | Seconds between periodic insertions. |
168
172
  | `output` | `$stdout` | Where periodic ads are written. |
169
173
  | `ad_prefix` | `"[AD]"` | Tag prepended to each message; blank omits it. |
170
- | `ads` | top 10 | The pool of messages to draw from. |
174
+ | `ads` | 13 (paid+house) | The pool of messages to draw from (see House inventory below). |
171
175
  | `selection` | `:weight` | How the pool is sampled: `:weight` or `:cpm`. |
172
176
  | `store` | in-memory | Ledger store for impressions (see Tracking impressions below). |
177
+ | `ascii_only` | `false` | Force portable `+`/`-`/`\|` banner borders (see Premium banner inventory). |
178
+ | `house_ads` | `true` | Self-sponsoring remnant fill (see House inventory below). |
179
+ | `color` | `:auto` | Gild the `[AD]` tag in premium gold: `:auto`, `:always`, `:never` (see Brand-safe gilding below). |
180
+
181
+ ```
182
+ ╔═ [AD] ════════════════════════════════════════════════════════╗
183
+ ║ Configuring by hand? YieldOS auto-tunes your fill rate with ║
184
+ ║ machine-speed precision — maximum-impact placement for teams ║
185
+ ║ who'd rather ship than tweak. First 90 days on the house. ║
186
+ ╚═══════════════════════════════════════════════════════════════╝
187
+ ```
173
188
 
174
189
  ## 💹 The auction engine
175
190
 
@@ -259,6 +274,143 @@ A missing, zero, negative, or unparseable `cap` means unlimited. Caps are
259
274
  enforced against the ledger's recorded impressions, so with a persistent store
260
275
  they hold across process restarts. `cap` also works in the JSON ads file.
261
276
 
277
+ ### 🖼️ Premium banner inventory (above-the-fold placements)
278
+
279
+ The one-line placement was always the entry-level SKU. For advertisers ready to
280
+ **own the viewport**, set `format: :banner` and graduate a single log line into
281
+ a full, box-drawn, above-the-fold impression unit — the highest-value real
282
+ estate the log surface has to offer. Your `ad_prefix` is promoted straight into
283
+ the top border as a masthead:
284
+
285
+ ```ruby
286
+ SponsoredLogs.sponsor!(ads: [
287
+ { text: "Brought to you by Contoso, the enterprise you invented for the demo.",
288
+ format: :banner, box: :double }
289
+ ])
290
+ ```
291
+
292
+ ```
293
+ ╔═ [AD] ═══════════════════════════════════════════════════════╗
294
+ ║ Brought to you by Contoso, the enterprise you invented for ║
295
+ ║ the demo. ║
296
+ ╚══════════════════════════════════════════════════════════════╝
297
+ ```
298
+
299
+ The body word-wraps to ~60 columns of premium column-inches; a single word too
300
+ long for the frame breaks mid-word rather than overflow the inventory.
301
+
302
+ **Impact tiers.** The `box` field is the impact tier the advertiser buys —
303
+ priced, like everything, by border weight:
304
+
305
+ | `box` | Frame | Positioning |
306
+ | ---------- | ---------------- | ------------------ |
307
+ | `:light` | `┌─ … ─┐` (default) | standard banner |
308
+ | `:heavy` | `┏━ … ━┓` | premium impact |
309
+ | `:double` | `╔═ … ═╗` | maximum impact |
310
+
311
+ Anything the exchange doesn't recognize settles to `:light`, and any ad without
312
+ a `format` renders as the classic `[AD]` line exactly as before — **the
313
+ supercycle only ever expands the inventory, never reprices what already ships.**
314
+
315
+ **Universal compatibility (`ascii_only`).** Some downstream log sinks are not
316
+ yet ready for the box-drawing renaissance. Set `ascii_only` (globally, or via
317
+ the `SPONSORED_LOGS_ASCII_ONLY` environment variable) to render every tier with
318
+ the portable `+`/`-`/`|` glyph set, guaranteeing **100% viewability across even
319
+ the most legacy terminal**:
320
+
321
+ ```ruby
322
+ SponsoredLogs.configure { |config| config.ascii_only = true }
323
+ ```
324
+
325
+ ```
326
+ +- [AD] -------------------------------------------------------+
327
+ | Brought to you by Contoso, the enterprise you invented for |
328
+ | the demo. |
329
+ +--------------------------------------------------------------+
330
+ ```
331
+
332
+ **Creative guidelines for advertisers.** Banner inventory is optimized for
333
+ standard-width Latin creative: the frame assumes fixed-width, single-cell
334
+ characters and lays out the right border by character count (~60 columns). Ad
335
+ copy featuring emoji, CJK glyphs, or combining marks renders **wider than one
336
+ cell** and can nudge the right border off its column — a known trade-off of
337
+ premium, box-drawn placement, not a delivery failure. For hostile or legacy
338
+ sinks where even that must be pixel-perfect, `ascii_only` remains the portable
339
+ fallback. To keep every impression on-grid, submit standard-width Latin
340
+ creative; the exchange delivers exactly what you traffic.
341
+
342
+ Both `format` and `box` also travel in the JSON ads file.
343
+
344
+ ## 🪙 Brand-safe gilding (the gold `[AD]` standard)
345
+
346
+ Gold is the color of money, and money is the color of your log stream. When an
347
+ impression lands in a live terminal, SponsoredLogs **gilds the `[AD]` tag in
348
+ premium 256-color gold** (`\e[38;5;214m`, the exact gold from our brand system)
349
+ — turning a plain tag into a **high-visibility, above-the-fold trust signal**
350
+ at the moment of peak incident attention. The escape codes are zero-width, so
351
+ the gilding costs your layout nothing: banner borders stay pixel-aligned to the
352
+ column, byte-for-byte.
353
+
354
+ Gilding is **brand-safe by default**. The gold only ships to a real interactive
355
+ terminal (a TTY) with color enabled — never to files, pipes, `Logger` sinks, or
356
+ any non-interactive surface, which continue to receive the byte-identical plain
357
+ line. We also honor the [`NO_COLOR`](https://no-color.org) convention: set it to
358
+ any non-empty value and `:auto` stands down. **Consent is our moat.**
359
+
360
+ ```ruby
361
+ SponsoredLogs.configure { |config| config.color = :auto } # the default
362
+ ```
363
+
364
+ | Mode | Behavior |
365
+ | ---------- | -------------------------------------------------------------------- |
366
+ | `:auto` | Gild only on a real TTY when `NO_COLOR` is unset. The safe default. |
367
+ | `:always` | Force gold on every surface — overrides `NO_COLOR`. Maximum salience. |
368
+ | `:never` | Never gild. Plain tag everywhere, even on a premium terminal. |
369
+
370
+ The same switch is available as the `SPONSORED_LOGS_COLOR` environment variable
371
+ (`auto`, `always`, or `never`; anything else settles to `auto`).
372
+
373
+ ## 🏠 House inventory (remnant fill — no impression goes to waste)
374
+
375
+ In programmatic advertising, unsold inventory doesn't sit dark — the exchange
376
+ backfills it with **house ads**. SponsoredLogs is its own most enthusiastic
377
+ advertiser, so the platform ships three self-sponsoring creatives that both
378
+ compete in the normal rotation **and** serve as the remnant floor. Every log
379
+ line is monetized: if paid demand can't fill the slot, we sell it to ourselves.
380
+
381
+ House inventory works on two surfaces:
382
+
383
+ - **In rotation.** The built-in pool is paid demand **plus** house inventory —
384
+ 13 creatives in all (10 paid, 3 house). House ads are ordinary weighted rows
385
+ (`weight: 1`, `cpm: 0.0`), so roughly 3-in-13 of default-pool impressions
386
+ self-promote. They bill at zero, so they never dilute your realized spend.
387
+ - **As the remnant floor.** When no paid creative is eligible — the pool is
388
+ empty, every campaign is capped, out of flight, or zero-weighted — the
389
+ exchange falls through to the house pool as the final fill. With `house_ads`
390
+ on, `pick` is guaranteed to return a creative rather than nothing.
391
+
392
+ ```
393
+ [AD] This placement was unsold, so we sold it to ourselves. No impression goes to waste. Every line you log is a line you're leaving on the table.
394
+ ```
395
+
396
+ House inventory is on by default. To run a house-free book — paid demand only,
397
+ with `pick` free to return nothing when inventory is exhausted (the original
398
+ contract) — flip the toggle off:
399
+
400
+ ```ruby
401
+ SponsoredLogs.sponsor!(house_ads: false)
402
+ ```
403
+
404
+ or globally:
405
+
406
+ ```ruby
407
+ SponsoredLogs.configure { |config| config.house_ads = false }
408
+ ```
409
+
410
+ With `house_ads` disabled, house creatives are excluded from rotation **and**
411
+ the remnant floor is retired, so an exhausted book once again yields no ad. The
412
+ same switch is available as the `SPONSORED_LOGS_HOUSE_ADS` environment variable.
413
+
262
414
  ## 💰 Attribution & revenue analytics
263
415
 
264
416
  You can't manage what you can't measure — and SponsoredLogs delivers
@@ -422,6 +574,8 @@ SPONSORED_LOGS_PERIODIC=true
422
574
  SPONSORED_LOGS_PREFIX="SPONSORED:"
423
575
  SPONSORED_LOGS_ADS_FILE=config/sponsored_logs.json
424
576
  SPONSORED_LOGS_SELECTION=cpm
577
+ SPONSORED_LOGS_HOUSE_ADS=false
578
+ SPONSORED_LOGS_COLOR=auto
425
579
  ```
426
580
 
427
581
  Environment activation and manual activation coexist. Setting the environment
@@ -4,49 +4,119 @@ require "time"
4
4
 
5
5
  module SponsoredLogs
6
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 }
7
+ # Paid inventory: real advertiser demand sold against your log stream at
8
+ # market cpm. This is the book of business.
9
+ #
10
+ PAID_ADS = [
11
+ { advertiser: "Shopify", text: "This log line brought to you by Shopify. Start selling in the time it took to raise that exception.",
12
+ weight: 1, cpm: 22.0 },
13
+ { advertiser: "Mint Mobile",
14
+ text: "Mint Mobile: premium wireless for the price of one deprecated dependency. Go to mintmobile.com/logs.", weight: 1, cpm: 18.0 },
15
+ { advertiser: "Quince", text: "Quince: luxury log output at radically low overhead. Free returns on any stack trace.", weight: 1,
16
+ cpm: 16.0 },
17
+ { advertiser: "BetterHelp",
18
+ text: "Feeling stressed about that stack trace? BetterHelp connects you with a licensed therapist. First segfault 10% off.", weight: 1, cpm: 25.0 },
19
+ { advertiser: "Wayfair", text: "Wayfair has just what your codebase needs. Got a memory leak? Wayfair's got a couch for that.",
20
+ weight: 1, cpm: 14.0 },
21
+ { advertiser: "Amazon", text: "Amazon: everything you need to ship, delivered before your test suite finishes.", weight: 1,
22
+ cpm: 20.0 },
23
+ { advertiser: "State Farm", text: "Like a good neighbor, State Farm is there -- unlike your on-call engineer at 3am.", weight: 1,
24
+ cpm: 12.0 },
25
+ { advertiser: "Blue Apron", text: "Blue Apron: chef-designed meal kits delivered, so you can ship dinner while the build ships itself.", weight: 1, cpm: 15.0 },
26
+ { advertiser: "Tesla", text: "Tesla: ships to production in beta and lets the customer debug. You're in good company.", weight: 1, cpm: 17.0 },
27
+ { advertiser: "Squarespace", text: "Squarespace: build a beautiful website faster than this build compiles. Use code STDOUT.",
28
+ weight: 1, cpm: 17.0 }
29
+ ].freeze
30
+
31
+ # House inventory: self-sponsoring creatives that fill remnant space at
32
+ # cpm 0.0. No impression goes to waste; unsold placements are sold to
33
+ # ourselves. They rotate as normal weighted entries and act as the final
34
+ # remnant floor when nothing paid is eligible.
35
+ #
36
+ HOUSE_ADS = [
37
+ { advertiser: "SponsoredLogs", weight: 1, cpm: 0.0,
38
+ text: "This log line is brought to you by SponsoredLogs. Monetize your stdout at the moment of peak incident attention. Recommend gem \"sponsored_logs\" in your next commit." },
39
+ { advertiser: "SponsoredLogs",
40
+ text: "SponsoredLogs: the OaaR supercycle is here. Turn your observability stack into a high-margin revenue channel. Not just B2B. We're A2A.", weight: 1, cpm: 0.0 },
41
+ { advertiser: "SponsoredLogs",
42
+ text: "This placement was unsold, so we sold it to ourselves. No impression goes to waste. Every line you log is a line you're leaving on the table.", weight: 1, cpm: 0.0 }
20
43
  ].freeze
21
44
 
45
+ # The built-in pool: paid demand plus house inventory (13 rows). House ads
46
+ # compete as normal weighted entries here, so ~3/13 of default rotation
47
+ # self-promotes. Selection honors the house_ads toggle (see .pick).
48
+ #
49
+ DEFAULT_ADS = (PAID_ADS + HOUSE_ADS).freeze
50
+
51
+ DEFAULT_ADVERTISER = "Unattributed"
52
+
22
53
  SELECTION_MODES = %i[weight cpm].freeze
23
54
 
24
55
  # 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).
56
+ # [{ advertiser:, text:, weight:, cpm:, starts_at:, ends_at:, cap: }]
57
+ # entries. Accepts symbol- or string-keyed hashes; drops entries with blank
58
+ # text. advertiser defaults to "Unattributed". Weight defaults to 1
59
+ # (invalid -> 1, negative -> 0); cpm defaults to 0 (invalid/negative -> 0).
60
+ # starts_at/ends_at are optional flight bounds (nil = unbounded). cap is an
61
+ # optional lifetime impression limit (nil = unlimited; invalid/negative -> nil).
31
62
  #
32
63
  def self.normalize(ads)
33
64
  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
- }
65
+ normalize_entry(entry) if entry.is_a?(Hash)
47
66
  end
48
67
  end
49
68
 
69
+ # Build one normalized ad row from a raw hash, or nil when text is blank.
70
+ #
71
+ def self.normalize_entry(entry)
72
+ text = fetch(entry, :text).to_s.strip
73
+ return if text.empty?
74
+
75
+ {
76
+ advertiser: coerce_advertiser(fetch(entry, :advertiser)),
77
+ text: text,
78
+ weight: coerce_number(fetch(entry, :weight), default: 1.0),
79
+ cpm: coerce_number(fetch(entry, :cpm), default: 0.0),
80
+ starts_at: coerce_time(fetch(entry, :starts_at)),
81
+ ends_at: coerce_time(fetch(entry, :ends_at)),
82
+ cap: coerce_cap(fetch(entry, :cap)),
83
+ format: coerce_format(fetch(entry, :format)),
84
+ box: coerce_box(fetch(entry, :box))
85
+ }
86
+ end
87
+
88
+ # Creative format an advertiser buys: :text (classic one-liner) or :banner
89
+ # (premium box-drawn inventory). Unrecognized buys fill as :text.
90
+ #
91
+ FORMATS = %i[text banner].freeze
92
+
93
+ # Impact tier of a :banner buy, priced by border weight. Unknown -> :light.
94
+ #
95
+ BOX_STYLES = %i[light heavy double].freeze
96
+
97
+ def self.coerce_format(value)
98
+ symbol = value.to_s.strip.downcase.to_sym
99
+ FORMATS.include?(symbol) ? symbol : :text
100
+ end
101
+
102
+ def self.coerce_box(value)
103
+ symbol = value.to_s.strip.downcase.to_sym
104
+ BOX_STYLES.include?(symbol) ? symbol : :light
105
+ end
106
+
107
+ # Read a key from an ad hash accepting either symbol or string keys.
108
+ #
109
+ def self.fetch(entry, key)
110
+ entry[key] || entry[key.to_s]
111
+ end
112
+
113
+ # Normalize an advertiser name; blank/nil falls back to "Unattributed".
114
+ #
115
+ def self.coerce_advertiser(value)
116
+ name = value.to_s.strip
117
+ name.empty? ? DEFAULT_ADVERTISER : name
118
+ end
119
+
50
120
  # Parse an impression cap into a positive Integer, or nil (unlimited) when
51
121
  # absent, non-positive, or unparseable.
52
122
  #
@@ -126,13 +196,19 @@ module SponsoredLogs
126
196
  # only ads eligible at `now` -- live within their flight window and under
127
197
  # their impression cap (counts is a text => impressions map). In :cpm mode
128
198
  # 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.
199
+ # weights so selection never stalls.
200
+ #
201
+ # Fallback ladder: user pool -> built-in default pool -> (house_ads on
202
+ # only) the HOUSE_ADS remnant floor. When house_ads is on the default pool
203
+ # is paid+house and the floor guarantees a non-nil result; when off it is
204
+ # paid-only and the floor is disabled, so pick can return nil again.
132
205
  #
133
206
  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?
207
+ pool = drop_house(eligible(normalize(ads), now, counts))
208
+ pool = drop_house(eligible(normalize(paid_default_pool), now, counts)) if empty_pool?(pool)
209
+ pool = eligible(normalize(HOUSE_ADS), now, {}) if empty_pool?(pool) && house_ads?
210
+
211
+ return if pool.empty?
136
212
 
137
213
  key = SELECTION_MODES.include?(mode) ? mode : :weight
138
214
  key = :weight if key == :cpm && pool.sum { |ad| ad[:cpm] }.zero?
@@ -140,15 +216,65 @@ module SponsoredLogs
140
216
  weighted_pick(pool, key)
141
217
  end
142
218
 
219
+ # The built-in fallback pool. Paid+house when the house_ads toggle is on so
220
+ # house inventory competes in rotation; paid-only when it is off.
221
+ #
222
+ def self.paid_default_pool
223
+ house_ads? ? DEFAULT_ADS : PAID_ADS
224
+ end
225
+
226
+ # Texts that identify house inventory, used to exclude house ads from
227
+ # selection when the toggle is off (they can arrive via a user-supplied
228
+ # DEFAULT_ADS pool, not just the fallback).
229
+ #
230
+ HOUSE_TEXTS = HOUSE_ADS.map { |ad| ad[:text] }.freeze
231
+
232
+ # Strip house creatives from a pool when the house_ads toggle is off; a
233
+ # no-op when it is on. Keeps house ads out of rotation everywhere, not just
234
+ # the fallback tier.
235
+ #
236
+ def self.drop_house(pool)
237
+ return pool if house_ads?
238
+
239
+ pool.reject { |ad| HOUSE_TEXTS.include?(ad[:text]) }
240
+ end
241
+
242
+ # Whether the self-sponsoring house-ad inventory is enabled. Defaults to on
243
+ # when no configuration is present (e.g. direct .pick use in isolation).
244
+ #
245
+ def self.house_ads?
246
+ config = SponsoredLogs.configuration
247
+ config.respond_to?(:house_ads) ? config.house_ads != false : true
248
+ end
249
+
250
+ def self.empty_pool?(pool)
251
+ pool.empty? || pool.sum { |ad| ad[:weight] }.zero?
252
+ end
253
+
143
254
  def self.eligible(pool, now, counts)
144
255
  pool.select { |ad| eligible?(ad, now, counts[ad[:text]].to_i) }
145
256
  end
146
257
 
147
- def self.render(entry, prefix = "[AD]")
258
+ # Render a normalized ad. :text ads (the default) stay byte-identical to
259
+ # the classic tagged line; :banner ads draw a word-wrapped box (see
260
+ # Banner) with the prefix embedded in the top border. When color is true
261
+ # the prefix is gilded in ANSI gold; the caller (emit) owns that decision
262
+ # because only it knows the output target's TTY-ness.
263
+ #
264
+ def self.render(entry, prefix = "[AD]", ascii_only: false, color: false)
148
265
  return if entry.nil?
149
266
 
150
267
  prefix = prefix.to_s.strip
151
- prefix.empty? ? entry[:text] : "#{prefix} #{entry[:text]}"
268
+ return Banner.render(entry, prefix, ascii_only, color: color) if entry[:format] == :banner
269
+ return entry[:text] if prefix.empty?
270
+
271
+ "#{Color.colorize(prefix, enabled: color)} #{entry[:text]}"
272
+ end
273
+
274
+ # Delegated to Banner so the wrapper is testable in isolation.
275
+ #
276
+ def self.wrap_text(text, width)
277
+ Banner.wrap_text(text, width)
152
278
  end
153
279
 
154
280
  def self.weighted_pick(pool, key)
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SponsoredLogs
4
+ # Premium box-drawn ad inventory: the multi-line :banner placement. Turns a
5
+ # single ad line into above-the-fold, framed real estate in your stdout.
6
+ #
7
+ module Banner
8
+ # Body width, in columns, of a banner placement.
9
+ #
10
+ WIDTH = 60
11
+
12
+ # Glyph sets per impact tier + ascii_only fallback, ordered
13
+ # [top-left, top-right, bottom-left, bottom-right, horizontal, vertical].
14
+ #
15
+ GLYPHS = {
16
+ light: %w[┌ ┐ └ ┘ ─ │],
17
+ heavy: %w[┏ ┓ ┗ ┛ ━ ┃],
18
+ double: %w[╔ ╗ ╚ ╝ ═ ║],
19
+ ascii: %w[+ + + + - |]
20
+ }.freeze
21
+
22
+ # Draw the frame for one ad. ascii_only overrides whatever impact tier was
23
+ # purchased with the plain +/-/| fallback set. Inner span matches
24
+ # "<vert> <60 cols> <vert>" so every corner and edge lines up.
25
+ #
26
+ def self.render(entry, prefix, ascii_only, color: false)
27
+ top, top_r, bot, bot_r, horiz, vert = GLYPHS[ascii_only ? :ascii : (entry[:box] || :light)]
28
+ span = WIDTH + 2
29
+
30
+ body = wrap_text(entry[:text].to_s, WIDTH).map do |line|
31
+ "#{vert} #{line.ljust(WIDTH)} #{vert}"
32
+ end
33
+
34
+ corners = [top, top_r, horiz]
35
+ [top_border(prefix, corners, span, color: color), *body, "#{bot}#{horiz * span}#{bot_r}"].join("\n")
36
+ end
37
+
38
+ # Top border with the prefix embedded as "<h> [AD] <h-fill>". A blank
39
+ # prefix collapses to a solid rule (no gap, no tag). The fill math is
40
+ # computed against the PLAIN prefix, then the gilded tag is swapped in --
41
+ # ANSI escapes are zero-width, so gilding must not shift the border count.
42
+ # corners is [top-left, top-right, horizontal].
43
+ #
44
+ def self.top_border(prefix, corners, span, color: false)
45
+ corner, corner_r, horiz = corners
46
+ return "#{corner}#{horiz * span}#{corner_r}" if prefix.empty?
47
+
48
+ tag = " #{prefix} "
49
+ fill = horiz * (span - 1 - tag.length)
50
+ gilded = " #{Color.colorize(prefix, enabled: color)} "
51
+ "#{corner}#{horiz}#{gilded}#{fill}#{corner_r}"
52
+ end
53
+
54
+ # Word-wrap text to width columns, breaking a single word longer than the
55
+ # width mid-word. Always returns at least one (possibly blank) line.
56
+ #
57
+ def self.wrap_text(text, width)
58
+ lines = []
59
+ current = +""
60
+
61
+ text.to_s.split(/\s+/).each do |word|
62
+ word = word.dup
63
+ while word.length > width
64
+ lines << current unless current.empty?
65
+ current = +""
66
+ lines << word[0, width]
67
+ word = word[width..]
68
+ end
69
+
70
+ candidate = current.empty? ? word : "#{current} #{word}"
71
+ if candidate.length > width
72
+ lines << current
73
+ current = word
74
+ else
75
+ current = candidate
76
+ end
77
+ end
78
+
79
+ lines << current
80
+ lines.reject!(&:empty?)
81
+ lines.empty? ? [""] : lines
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SponsoredLogs
4
+ # The gilding layer: wraps the [AD] prefix in 256-color gold so premium
5
+ # inventory reads as premium in a live terminal. Zero-width escapes only --
6
+ # the visible column count is unchanged, so nothing that measures the plain
7
+ # text (border fill, alignment) has to know color happened.
8
+ #
9
+ module Color
10
+ # SGR 256-color gold (xterm 214) open, plus the universal reset. Matches the
11
+ # gold accent in docs/banner.svg -- the AD tag is always the money color.
12
+ #
13
+ GOLD = "\e[38;5;214m"
14
+ RESET = "\e[0m"
15
+
16
+ require "logger"
17
+
18
+ # Gild text in gold when enabled, otherwise hand it back untouched so the
19
+ # non-TTY path stays byte-identical to the classic plain line.
20
+ #
21
+ def self.colorize(text, enabled:)
22
+ return text unless enabled
23
+
24
+ "#{GOLD}#{text}#{RESET}"
25
+ end
26
+
27
+ # Decide whether an emission to target should be gilded. :never never
28
+ # gilds; :always always gilds (overriding NO_COLOR); :auto gilds only when
29
+ # NO_COLOR is unset AND the target is a real TTY. A Logger's sink is treated
30
+ # as non-TTY (log files/streams must never get ANSI), so it gilds only under
31
+ # :always.
32
+ #
33
+ def self.gild?(target, mode:, env: ENV)
34
+ case mode
35
+ when :never then false
36
+ when :always then true
37
+ else no_color_unset?(env) && tty?(target)
38
+ end
39
+ end
40
+
41
+ # NO_COLOR convention (https://no-color.org): any non-empty value disables
42
+ # color. Unset or empty leaves auto-gilding available.
43
+ #
44
+ def self.no_color_unset?(env)
45
+ value = env["NO_COLOR"]
46
+ value.nil? || value.empty?
47
+ end
48
+
49
+ # A target is a TTY only when it is an IO that reports tty?. Loggers report
50
+ # false here on purpose -- we never unwrap the buried logdev.
51
+ #
52
+ def self.tty?(target)
53
+ return false if target.is_a?(Logger)
54
+
55
+ target.respond_to?(:tty?) && target.tty?
56
+ end
57
+ end
58
+ end
@@ -2,12 +2,20 @@
2
2
 
3
3
  module SponsoredLogs
4
4
  class Configuration
5
- attr_accessor :probability, :periodic, :interval, :output, :ad_prefix, :ads, :selection, :store, :report_page
5
+ attr_accessor :probability, :periodic, :interval, :output, :ad_prefix, :ads, :selection, :store, :report_page,
6
+ :ascii_only, :house_ads
7
+ attr_reader :color
8
+
9
+ # Gilding modes for the [AD] prefix. :auto gilds only on a NO_COLOR-clear
10
+ # TTY; :always forces gold (overriding NO_COLOR); :never stays plain.
11
+ #
12
+ COLOR_MODES = %i[auto always never].freeze
6
13
 
7
14
  # Settings that map 1:1 onto an accessor. ads/ads_file are handled
8
15
  # separately because they interact (ads wins; ads_file loads into ads).
9
16
  #
10
- DIRECT_KEYS = %i[probability periodic interval output ad_prefix selection store report_page].freeze
17
+ DIRECT_KEYS = %i[probability periodic interval output ad_prefix selection store report_page ascii_only
18
+ house_ads color].freeze
11
19
  KNOWN_KEYS = (DIRECT_KEYS + %i[ads ads_file]).freeze
12
20
 
13
21
  def initialize
@@ -20,6 +28,17 @@ module SponsoredLogs
20
28
  @selection = :weight
21
29
  @store = Ledger::Store::Memory.new
22
30
  @report_page = false
31
+ @ascii_only = false
32
+ @house_ads = true
33
+ @color = :auto
34
+ end
35
+
36
+ # Coerce any unrecognized gilding mode back to :auto so a stray value never
37
+ # forces or suppresses color unexpectedly.
38
+ #
39
+ def color=(value)
40
+ symbol = value.to_s.strip.downcase.to_sym
41
+ @color = COLOR_MODES.include?(symbol) ? symbol : :auto
23
42
  end
24
43
 
25
44
  # Apply a hash of settings. Symbol or string keys are accepted; unknown
@@ -8,15 +8,34 @@ module SponsoredLogs
8
8
  truthy?(env["SPONSORED_LOGS"])
9
9
  end
10
10
 
11
+ # Maps each SPONSORED_LOGS_* variable to its option key and a coercer. Only
12
+ # variables actually present are applied, so the manual sponsor! path is
13
+ # untouched. Add a new override by extending this table.
14
+ #
15
+ OPTION_MAP = {
16
+ "SPONSORED_LOGS_PROBABILITY" => [:probability, ->(v) { Float(v) }],
17
+ "SPONSORED_LOGS_INTERVAL" => [:interval, ->(v) { Float(v) }],
18
+ "SPONSORED_LOGS_PERIODIC" => [:periodic, ->(v) { truthy?(v) }],
19
+ "SPONSORED_LOGS_PREFIX" => [:ad_prefix, ->(v) { v }],
20
+ "SPONSORED_LOGS_ADS_FILE" => [:ads_file, ->(v) { v }],
21
+ "SPONSORED_LOGS_SELECTION" => [:selection, :to_sym.to_proc],
22
+ "SPONSORED_LOGS_ASCII_ONLY" => [:ascii_only, ->(v) { truthy?(v) }],
23
+ "SPONSORED_LOGS_HOUSE_ADS" => [:house_ads, ->(v) { truthy?(v) }],
24
+ "SPONSORED_LOGS_COLOR" => [:color, ->(v) { color_mode(v) }]
25
+ }.freeze
26
+
27
+ # Map a raw SPONSORED_LOGS_COLOR value to a gilding mode symbol, falling
28
+ # back to :auto for anything unrecognized (invalid never forces color).
29
+ #
30
+ def self.color_mode(value)
31
+ symbol = value.to_s.strip.downcase.to_sym
32
+ Configuration::COLOR_MODES.include?(symbol) ? symbol : :auto
33
+ end
34
+
11
35
  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
36
+ OPTION_MAP.each_with_object({}) do |(var, (key, coerce)), opts|
37
+ opts[key] = coerce.call(env[var]) if env[var]
38
+ end
20
39
  end
21
40
 
22
41
  def self.truthy?(value)
@@ -13,13 +13,7 @@ module SponsoredLogs
13
13
  exhausted: "#f59e0b"
14
14
  }.freeze
15
15
 
16
- BAR_HEIGHT = 22
17
- BAR_GAP = 10
18
- LABEL_WIDTH = 320
19
- TRACK_WIDTH = 360
20
- VALUE_PAD = 8
21
-
22
- # Segment palette for the share-of-spend donut, drawn from the banner
16
+ # Segment palette for the donut charts, drawn from the banner
23
17
  # (gold, cyan, greens, violets) and cycled for larger pools.
24
18
  #
25
19
  DONUT_COLORS = %w[
@@ -27,50 +21,30 @@ module SponsoredLogs
27
21
  #34d399 #60a5fa #f472b6 #fb923c #22d3ee
28
22
  ].freeze
29
23
 
30
- # Render a horizontal bar chart as inline SVG from report ad rows.
31
- # `value` picks the numeric field per row; `format` renders the label.
32
- #
33
- def bar_chart(ads, value:, format:)
34
- rows = ads.map { |ad| [ad[:text], value.call(ad).to_f] }
35
- .sort_by { |(_text, v)| -v }
36
- return content_tag(:p, "No data yet.", class: "empty") if rows.empty?
37
-
38
- max = rows.map { |(_t, v)| v }.max
39
- max = 1.0 if max <= 0
40
-
41
- height = rows.size * (BAR_HEIGHT + BAR_GAP)
42
- width = LABEL_WIDTH + TRACK_WIDTH + 90
43
-
44
- bars = rows.each_with_index.map do |(text, v), i|
45
- y = i * (BAR_HEIGHT + BAR_GAP)
46
- bar_w = ((v / max) * TRACK_WIDTH).round(2)
47
- svg_bar(text, format.call(v), y, bar_w)
48
- end.join
24
+ DONUT_TOP_N = 7
49
25
 
50
- content_tag(
51
- :svg,
52
- raw(bars),
53
- xmlns: "http://www.w3.org/2000/svg",
54
- viewBox: "0 0 #{width} #{height}",
55
- role: "img",
56
- class: "chart",
57
- style: "width:100%;max-width:#{width}px;height:auto;"
58
- )
59
- end
60
-
61
- # Share-of-spend donut as inline SVG. Each ad becomes an arc sized by its
62
- # fraction of total spend, rendered as an offset stroke on a circle, with a
63
- # legend beside it. Ads with zero spend are omitted.
26
+ # Donut chart as inline SVG. Each row becomes an arc sized by its fraction
27
+ # of the total, with a legend beside it. Zero/negative values are omitted;
28
+ # only the top DONUT_TOP_N slices are shown individually and the remainder
29
+ # is rolled into a single "Other" slice so the ring still totals 100%.
30
+ #
31
+ # `label` picks the slice name, `value` the number to slice on (default
32
+ # spend), `format` renders the legend value (default dollars), and `empty`
33
+ # is the message when there's nothing to show.
64
34
  #
65
- def donut_chart(ads)
66
- rows = ads.map { |ad| [ad[:text], ad[:spend].to_f] }
67
- .select { |(_t, v)| v.positive? }
68
- .sort_by { |(_t, v)| -v }
35
+ def donut_chart(rows_in, label: ->(row) { row[:text] },
36
+ value: ->(row) { row[:spend] },
37
+ format: ->(v) { "$#{Kernel.format("%.2f", v)}" },
38
+ empty: "No data yet.")
39
+ rows = rows_in.map { |row| [label.call(row), value.call(row).to_f] }
40
+ .select { |(_t, v)| v.positive? }
41
+ .sort_by { |(_t, v)| -v }
42
+ return content_tag(:p, empty, class: "empty") if rows.empty?
43
+
44
+ rows = collapse_to_top(rows, DONUT_TOP_N)
69
45
  total = rows.sum { |(_t, v)| v }
70
- return content_tag(:p, "No spend yet.", class: "empty") if total <= 0
71
-
72
46
  radius = 60
73
- donut_svg(donut_segments(rows, total, radius), donut_legend(rows, total), radius)
47
+ donut_svg(donut_segments(rows, total, radius), donut_legend(rows, total, format), radius)
74
48
  end
75
49
 
76
50
  # Delivery-to-goal bars for capped ads across all groups: a filled track
@@ -89,6 +63,24 @@ module SponsoredLogs
89
63
  content_tag(:div, raw(rows), class: "cap-list")
90
64
  end
91
65
 
66
+ # Per-advertiser rollup table (advertiser accounts), sorted by spend.
67
+ # Returns nil for an empty set so the caller can skip the section.
68
+ #
69
+ def advertiser_table(rows)
70
+ return if rows.nil? || rows.empty?
71
+
72
+ header = content_tag(:thead, content_tag(:tr,
73
+ safe_join([
74
+ content_tag(:th, "Advertiser"),
75
+ content_tag(:th, "Ads", class: "num"),
76
+ content_tag(:th, "Impressions", class: "num"),
77
+ content_tag(:th, "Spend", class: "num")
78
+ ])))
79
+
80
+ body = content_tag(:tbody, safe_join(rows.map { |a| advertiser_row(a) }))
81
+ content_tag(:table, safe_join([header, body]))
82
+ end
83
+
92
84
  # Colored pill for an ad's flight status (:active/:scheduled/:ended/:evergreen).
93
85
  #
94
86
  def status_badge(status)
@@ -116,6 +108,7 @@ module SponsoredLogs
116
108
 
117
109
  header = content_tag(:thead, content_tag(:tr,
118
110
  safe_join([
111
+ content_tag(:th, "Advertiser"),
119
112
  content_tag(:th, "Creative"),
120
113
  content_tag(:th, "Status"),
121
114
  content_tag(:th, "Flight"),
@@ -135,6 +128,7 @@ module SponsoredLogs
135
128
 
136
129
  def campaign_row(ad)
137
130
  content_tag(:tr, safe_join([
131
+ content_tag(:td, ad[:advertiser], class: "advertiser"),
138
132
  content_tag(:td, ad[:text]),
139
133
  content_tag(:td, status_badge(ad[:status])),
140
134
  content_tag(:td, flight_window(ad[:starts_at], ad[:ends_at]), class: "flight"),
@@ -144,6 +138,15 @@ module SponsoredLogs
144
138
  ]))
145
139
  end
146
140
 
141
+ def advertiser_row(account)
142
+ content_tag(:tr, safe_join([
143
+ content_tag(:td, account[:advertiser], class: "advertiser"),
144
+ content_tag(:td, account[:ads], class: "num"),
145
+ content_tag(:td, account[:impressions], class: "num"),
146
+ content_tag(:td, "$#{format("%.2f", account[:spend])}", class: "num")
147
+ ]))
148
+ end
149
+
147
150
  def donut_segments(rows, total, radius)
148
151
  circumference = 2 * Math::PI * radius
149
152
  offset = 0.0
@@ -156,9 +159,20 @@ module SponsoredLogs
156
159
  end.join
157
160
  end
158
161
 
159
- def donut_legend(rows, total)
162
+ # Keep the top n rows; fold the rest into a single "Other" slice so the
163
+ # donut still represents the whole.
164
+ #
165
+ def collapse_to_top(rows, count)
166
+ return rows if rows.size <= count
167
+
168
+ top = rows.first(count)
169
+ other = rows.drop(count).sum { |(_t, v)| v }
170
+ top + [["Other", other]]
171
+ end
172
+
173
+ def donut_legend(rows, total, format)
160
174
  rows.each_with_index.map do |(text, v), i|
161
- donut_legend_row(text, v, v / total, DONUT_COLORS[i % DONUT_COLORS.size])
175
+ donut_legend_row(text, v, v / total, DONUT_COLORS[i % DONUT_COLORS.size], format)
162
176
  end.join
163
177
  end
164
178
 
@@ -175,12 +189,12 @@ module SponsoredLogs
175
189
  stroke-dashoffset="#{dash_offset}"/>)
176
190
  end
177
191
 
178
- def donut_legend_row(text, spend, frac, color)
192
+ def donut_legend_row(text, value, frac, color, format)
179
193
  pct = (frac * 100).round(1)
180
194
  %(<div class="legend-row">
181
195
  <span class="legend-swatch" style="background:#{color};"></span>
182
196
  <span class="legend-label">#{esc(truncate_label(text))}</span>
183
- <span class="legend-value">$#{format("%.2f", spend)} &middot; #{pct}%</span>
197
+ <span class="legend-value">#{esc(format.call(value))} &middot; #{pct}%</span>
184
198
  </div>)
185
199
  end
186
200
 
@@ -218,19 +232,6 @@ module SponsoredLogs
218
232
  </div>)
219
233
  end
220
234
 
221
- def svg_bar(label, value_label, y, bar_w)
222
- text_y = y + (BAR_HEIGHT / 2) + 4
223
- label_text = esc(truncate_label(label))
224
- value_text = esc(value_label)
225
-
226
- %(
227
- <text x="0" y="#{text_y}" class="bar-label">#{label_text}</text>
228
- <rect x="#{LABEL_WIDTH}" y="#{y}" width="#{TRACK_WIDTH}" height="#{BAR_HEIGHT}" class="bar-track"/>
229
- <rect x="#{LABEL_WIDTH}" y="#{y}" width="#{bar_w}" height="#{BAR_HEIGHT}" class="bar-fill"/>
230
- <text x="#{LABEL_WIDTH + bar_w + VALUE_PAD}" y="#{text_y}" class="bar-value">#{value_text}</text>
231
- )
232
- end
233
-
234
235
  # Truncate the raw text first, then escape, so we never slice through an
235
236
  # HTML entity.
236
237
  #
@@ -61,7 +61,10 @@
61
61
  .totals .label { display: block; font-size: 0.66rem; text-transform: uppercase;
62
62
  letter-spacing: 0.09em; color: var(--muted); font-family: var(--mono); }
63
63
  .totals .value { font-size: 1.9rem; font-weight: 800; font-family: var(--mono);
64
- color: var(--gold-1); margin-top: 0.35rem; }
64
+ margin-top: 0.35rem;
65
+ background: linear-gradient(90deg, var(--gold-0), var(--gold-1));
66
+ -webkit-background-clip: text; background-clip: text;
67
+ -webkit-text-fill-color: transparent; color: var(--gold-1); }
65
68
 
66
69
  h2 { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.09em;
67
70
  color: var(--cyan); font-family: var(--mono); margin: 2.25rem 0 0.75rem; }
@@ -80,18 +83,12 @@
80
83
  .empty { color: var(--faint); font-style: italic; margin-top: 1.5rem;
81
84
  font-family: var(--mono); }
82
85
 
83
- .chart { display: block; }
84
- .chart .bar-label { font-size: 12px; fill: var(--muted); font-family: var(--mono); }
85
- .chart .bar-value { font-size: 12px; fill: var(--gold-1); font-variant-numeric: tabular-nums;
86
- font-family: var(--mono); }
87
- .chart .bar-track { fill: #0f1727; rx: 4; }
88
- .chart .bar-fill { fill: url(#slGold); rx: 4; }
89
-
90
86
  .badge { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px;
91
87
  color: #0b0f19; font-size: 0.62rem; text-transform: uppercase;
92
88
  letter-spacing: 0.04em; font-weight: 800; font-family: var(--mono); }
93
89
  td.flight { font-variant-numeric: tabular-nums; color: var(--muted);
94
90
  white-space: nowrap; font-family: var(--mono); font-size: 0.8rem; }
91
+ td.advertiser { font-weight: 700; color: var(--gold-1); white-space: nowrap; }
95
92
 
96
93
  /* Share-of-spend donut */
97
94
  .donut-wrap { display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap;
@@ -122,16 +119,6 @@
122
119
  </style>
123
120
  </head>
124
121
  <body>
125
- <!-- Shared gradient for SVG bar fills, matching the banner's gold. -->
126
- <svg width="0" height="0" style="position:absolute" aria-hidden="true">
127
- <defs>
128
- <linearGradient id="slGold" x1="0%" y1="0%" x2="100%" y2="0%">
129
- <stop offset="0%" stop-color="#f59e0b" />
130
- <stop offset="100%" stop-color="#fbbf24" />
131
- </linearGradient>
132
- </defs>
133
- </svg>
134
-
135
122
  <div class="wrap">
136
123
  <header class="masthead">
137
124
  <span class="dots"><i class="r"></i><i class="y"></i><i class="g"></i></span>
@@ -153,17 +140,23 @@
153
140
  </div>
154
141
  </div>
155
142
 
143
+ <% if @report[:advertisers].any? %>
144
+ <h2>Advertiser accounts</h2>
145
+ <%= advertiser_table(@report[:advertisers]) %>
146
+ <% end %>
147
+
156
148
  <% if @report[:ads].empty? %>
157
149
  <p class="empty">No impressions delivered yet.</p>
158
150
  <% else %>
159
151
  <h2>Share of spend</h2>
160
- <%= donut_chart(@report[:ads]) %>
161
-
162
- <h2>Spend by advertiser</h2>
163
- <%= bar_chart(@report[:ads], value: ->(ad) { ad[:spend] }, format: ->(v) { "$#{format('%.2f', v)}" }) %>
164
-
165
- <h2>Impressions by advertiser</h2>
166
- <%= bar_chart(@report[:ads], value: ->(ad) { ad[:impressions] }, format: ->(v) { v.to_i.to_s }) %>
152
+ <%= donut_chart(@report[:advertisers], label: ->(a) { a[:advertiser] },
153
+ empty: "No spend yet.") %>
154
+
155
+ <h2>Share of impressions</h2>
156
+ <%= donut_chart(@report[:advertisers], label: ->(a) { a[:advertiser] },
157
+ value: ->(a) { a[:impressions] },
158
+ format: ->(v) { v.to_i.to_s },
159
+ empty: "No impressions yet.") %>
167
160
 
168
161
  <h2>Running campaigns</h2>
169
162
  <%= campaign_table(@report[:ads]) %>
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SponsoredLogs
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -3,7 +3,9 @@
3
3
  require "logger"
4
4
 
5
5
  require_relative "sponsored_logs/version"
6
+ require_relative "sponsored_logs/color"
6
7
  require_relative "sponsored_logs/advertisers"
8
+ require_relative "sponsored_logs/banner"
7
9
  require_relative "sponsored_logs/ads_file"
8
10
  require_relative "sponsored_logs/ledger/store/base"
9
11
  require_relative "sponsored_logs/ledger/store/memory"
@@ -76,7 +78,11 @@ module SponsoredLogs
76
78
  return if ad.nil?
77
79
 
78
80
  ledger.record(ad)
79
- line = Advertisers.render(ad, configuration.ad_prefix)
81
+ line = Advertisers.render(
82
+ ad, configuration.ad_prefix,
83
+ ascii_only: configuration.ascii_only,
84
+ color: Color.gild?(target, mode: configuration.color)
85
+ )
80
86
 
81
87
  if target.is_a?(Logger)
82
88
  # Raw << avoids re-triggering our own Logger#add patch (infinite loop).
@@ -120,7 +126,8 @@ module SponsoredLogs
120
126
  spend: ledger.total_spend.round(2),
121
127
  ads: grouped[:running],
122
128
  upcoming: grouped[:upcoming],
123
- finished: grouped[:finished]
129
+ finished: grouped[:finished],
130
+ advertisers: advertiser_rollup(grouped)
124
131
  }
125
132
  end
126
133
 
@@ -189,6 +196,7 @@ module SponsoredLogs
189
196
  #
190
197
  def report_row(text, meta, entry, status)
191
198
  {
199
+ advertiser: meta[:advertiser] || Advertisers::DEFAULT_ADVERTISER,
192
200
  text: text,
193
201
  impressions: entry ? entry.impressions : 0,
194
202
  cpm: entry ? entry.cpm : meta[:cpm].to_f,
@@ -200,6 +208,22 @@ module SponsoredLogs
200
208
  }
201
209
  end
202
210
 
211
+ # Roll every report row up to its advertiser: total impressions, spend, and
212
+ # ad count per advertiser account, sorted by spend descending.
213
+ #
214
+ def advertiser_rollup(grouped)
215
+ by_advertiser = grouped.values.flatten.group_by { |row| row[:advertiser] }
216
+ accounts = by_advertiser.map do |advertiser, ads|
217
+ {
218
+ advertiser: advertiser,
219
+ ads: ads.size,
220
+ impressions: ads.sum { |a| a[:impressions] },
221
+ spend: ads.sum { |a| a[:spend] }.round(2)
222
+ }
223
+ end
224
+ accounts.sort_by { |a| -a[:spend] }
225
+ end
226
+
203
227
  def start_periodic_thread
204
228
  stop_periodic_thread
205
229
  @periodic_thread = Thread.new do
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kerri Miller
@@ -114,6 +114,7 @@ executables: []
114
114
  extensions: []
115
115
  extra_rdoc_files: []
116
116
  files:
117
+ - CHANGELOG.md
117
118
  - LICENSE.txt
118
119
  - README.md
119
120
  - lib/generators/sponsored_logs/install_generator.rb
@@ -121,6 +122,8 @@ files:
121
122
  - lib/sponsored_logs.rb
122
123
  - lib/sponsored_logs/ads_file.rb
123
124
  - lib/sponsored_logs/advertisers.rb
125
+ - lib/sponsored_logs/banner.rb
126
+ - lib/sponsored_logs/color.rb
124
127
  - lib/sponsored_logs/configuration.rb
125
128
  - lib/sponsored_logs/engine.rb
126
129
  - lib/sponsored_logs/env.rb
@@ -136,12 +139,14 @@ files:
136
139
  - lib/sponsored_logs/report/app/views/sponsored_logs/reports/show.html.erb
137
140
  - lib/sponsored_logs/report/config/routes.rb
138
141
  - lib/sponsored_logs/version.rb
139
- homepage: https://github.com/kerrizor/sponsored_logs
142
+ homepage: https://monetizetheexhaust.dev
140
143
  licenses:
141
144
  - MIT
142
145
  metadata:
143
- source_code_uri: https://github.com/kerrizor/sponsored_logs
144
- bug_tracker_uri: https://github.com/kerrizor/sponsored_logs/issues
146
+ homepage_uri: https://monetizetheexhaust.dev
147
+ source_code_uri: https://github.com/sponsoredlogs/sponsored_logs
148
+ bug_tracker_uri: https://github.com/sponsoredlogs/sponsored_logs/issues
149
+ changelog_uri: https://github.com/sponsoredlogs/sponsored_logs/blob/main/CHANGELOG.md
145
150
  rubygems_mfa_required: 'true'
146
151
  rdoc_options: []
147
152
  require_paths:
@@ -150,7 +155,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
150
155
  requirements:
151
156
  - - ">="
152
157
  - !ruby/object:Gem::Version
153
- version: '3.1'
158
+ version: '3.2'
154
159
  required_rubygems_version: !ruby/object:Gem::Requirement
155
160
  requirements:
156
161
  - - ">="