sponsored_logs 0.1.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: c36f3d05ba2e86f78562d0dc5dcfb9b9a2f90e1ea674fc5616615d67016426d5
4
- data.tar.gz: 499cf9eec6047a818239ebed6b04518d9523cf10dc72ab02ddd72833092e4bb6
3
+ metadata.gz: 6f7ed20148d47d4ae067aca15087fcb756ebe60daf9904f12b44a5b6408c81a8
4
+ data.tar.gz: 3de566f7682c17b0cb8de899fbbaf53915bfdb33c23f3b3990c2da5e7db8dde5
5
5
  SHA512:
6
- metadata.gz: d6a269ba2faee897196f0cdcca7259d3267a9f7f1816d9831edeff71a40091a6804bb9dac6945b1f4a8cc5bad92fdeaa852e53634dc30552b587d39d86c4ce4c
7
- data.tar.gz: 1bb00754fb7ff7b83f775783ed18436f236afb98fc8056b69ddf142e88eaca0aeed712c7666c6cd4c8ea80d52e15c22fbcd84269ee5241cbf9145454b1c4be53
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