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 +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +155 -1
- data/lib/sponsored_logs/advertisers.rb +165 -39
- data/lib/sponsored_logs/banner.rb +84 -0
- data/lib/sponsored_logs/color.rb +58 -0
- data/lib/sponsored_logs/configuration.rb +21 -2
- data/lib/sponsored_logs/env.rb +27 -8
- data/lib/sponsored_logs/report/app/helpers/sponsored_logs/reports_helper.rb +159 -42
- data/lib/sponsored_logs/report/app/views/sponsored_logs/reports/show.html.erb +143 -33
- data/lib/sponsored_logs/version.rb +1 -1
- data/lib/sponsored_logs.rb +26 -2
- metadata +10 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f7ed20148d47d4ae067aca15087fcb756ebe60daf9904f12b44a5b6408c81a8
|
|
4
|
+
data.tar.gz: 3de566f7682c17b0cb8de899fbbaf53915bfdb33c23f3b3990c2da5e7db8dde5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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` |
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
{
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
{ text: "
|
|
16
|
-
|
|
17
|
-
{
|
|
18
|
-
|
|
19
|
-
{ text: "
|
|
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: }]
|
|
26
|
-
# symbol- or string-keyed hashes; drops entries with blank
|
|
27
|
-
# defaults to
|
|
28
|
-
# (invalid
|
|
29
|
-
# (nil = unbounded). cap is an
|
|
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
|
-
|
|
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.
|
|
130
|
-
#
|
|
131
|
-
#
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|