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 +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 +65 -64
- data/lib/sponsored_logs/report/app/views/sponsored_logs/reports/show.html.erb +18 -25
- 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
|
|
@@ -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
|
|
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
|
data/lib/sponsored_logs/env.rb
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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,
|
|
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"
|
|
197
|
+
<span class="legend-value">#{esc(format.call(value))} · #{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
|
-
|
|
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[:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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]) %>
|
data/lib/sponsored_logs.rb
CHANGED
|
@@ -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(
|
|
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.
|
|
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://
|
|
142
|
+
homepage: https://monetizetheexhaust.dev
|
|
140
143
|
licenses:
|
|
141
144
|
- MIT
|
|
142
145
|
metadata:
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
158
|
+
version: '3.2'
|
|
154
159
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
155
160
|
requirements:
|
|
156
161
|
- - ">="
|