← back to Kickbacks Ai Upstream

src/viewTracking/timer.ts

227 lines

/** W3 ad-viewership tracker.
 *
 *  WARNING — NOT SHIPPED (verified 2026-06-10, audit #23 / wave-1 EXT-03):
 *  no production code imports this class. The esbuild bundle (entry
 *  src/extension.ts) never reaches it; its only consumer is
 *  test/viewTimer.test.ts. The LIVE view timers are block.asset.js (`_vt`,
 *  CC overlay/banner), codex/block.asset.js, and statusBarAd.ts's inline
 *  interval — all of which now END sessions on hide and CLAMP suspend/wake
 *  poll gaps (see SUSPEND_GAP_MS in block.asset.js and the contract pinned
 *  by test/cc-viewtimer.test.ts). This class still has the PRE-FIX
 *  semantics by design of its 2026-05-22 refactor (no-op hide()/pause(),
 *  unbounded wall-clock catch-up loop in emitTickIfDue — a laptop suspend
 *  would replay the whole sleep gap as a billable tick burst on wake).
 *  Do NOT wire it into a billing surface without first porting the
 *  suspend-gap clamp + hide-ends-session semantics from block.asset.js.
 *
 *  An ad must accumulate `thresholdMs` of cumulative ELAPSED TIME on a
 *  surface before it counts as "shown" and is eligible for credit. As of
 *  the absolute-epoch baseline refactor, elapsed time is computed as
 *  `now() - sessionStartedAt` on every poll — no accumulator, no
 *  show/hide-driven pause. Rationale: the prior accumulator-with-pause
 *  model dropped time whenever the 250 ms poll skipped (throttled tab,
 *  CPU pressure) and produced "stuck session never bills" log signatures.
 *  An absolute baseline is immune to poll-cadence drift, at the cost of
 *  no longer pausing while the webview is hidden — bounded in practice
 *  by mutual exclusion below + the server-side cooldown gate.
 *
 *  Two surfaces are tracked independently: the spinner-verb `overlay` and
 *  the usage-banner `banner`. The same ad can be shown on both surfaces in
 *  one session and each accumulates separately (it's a different visual
 *  impression).
 *
 *  Hooks:
 *    onTick(...)       — fired every `tickMs` of elapsed time (default
 *                        5000 ms). Used to send `view_tick` metrics.
 *    onThresholdMet()  — fired exactly once per (adId, surface, session)
 *                        when elapsed crosses `thresholdMs`.
 *    onErrorImpression() — fired at EVERY `maxSessionMs` boundary of
 *                        elapsed time. Default 5 s, so a 30 s stuck
 *                        session fires 6 events (at 5/10/15/20/25/30 s).
 *                        Backend cooldown gate (cooldown_view_seconds)
 *                        decides which of those actually credit. Mutually
 *                        exclusive with onThresholdMet: if any
 *                        error_impression has fired this session,
 *                        threshold_met is suppressed — in practice this
 *                        means threshold_met only fires when the cap is
 *                        disabled (`maxSessionMs: 0`) or when
 *                        `thresholdMs < maxSessionMs` (threshold-first
 *                        config). */

import { type AdSurface } from "../types/surface";

export type { AdSurface };

export interface TickEvent {
  adId: string;
  surface: AdSurface;
  sessionNonce: string;
  visibleMs: number;          // elapsed since session start (now - sessionStartedAt)
}

export interface ThresholdEvent extends TickEvent {
  thresholdMs: number;
}

export interface ErrorImpressionEvent extends TickEvent {
  maxSessionMs: number;
}

export interface ViewTimerOptions {
  thresholdMs: number;                              // default 15_000
  tickMs?: number;                                  // default 5_000
  // MAX_SESSION_MS safety-net cap: if elapsed crosses this without a
  // natural close, fire onErrorImpression exactly once so a stuck ad
  // still bills. Default 5_000 ms (matches block.asset.js).
  maxSessionMs?: number;
  now?: () => number;                               // injectable for tests
  onTick?: (e: TickEvent) => void;
  onThresholdMet?: (e: ThresholdEvent) => void;
  onErrorImpression?: (e: ErrorImpressionEvent) => void;
}

interface Session {
  adId: string;
  surface: AdSurface;
  sessionNonce: string;
  sessionStartedAt: number;     // absolute epoch when session started; never resets within a session
  lastTickAtMs: number;         // last tick emitted at this elapsed total
  thresholdMet: boolean;
  // Count of error_impression events fired so far this session. Used to
  // gate the *next* fire (at `(count+1) * maxSessionMs`) AND as the mutex
  // signal for threshold_met (suppressed once any error_impression has
  // fired this session).
  errorImpressionCount: number;
}

export class ViewTimer {
  private sessions = new Map<string, Session>();
  private readonly thresholdMs: number;
  private readonly tickMs: number;
  private readonly maxSessionMs: number;
  private readonly now: () => number;
  private readonly onTick?: (e: TickEvent) => void;
  private readonly onThresholdMet?: (e: ThresholdEvent) => void;
  private readonly onErrorImpression?: (e: ErrorImpressionEvent) => void;

  constructor(opts: ViewTimerOptions) {
    this.thresholdMs = Math.max(0, opts.thresholdMs);
    this.tickMs = Math.max(100, opts.tickMs ?? 5_000);
    this.maxSessionMs = Math.max(0, opts.maxSessionMs ?? 5_000);
    this.now = opts.now ?? (() => Date.now());
    this.onTick = opts.onTick;
    this.onThresholdMet = opts.onThresholdMet;
    this.onErrorImpression = opts.onErrorImpression;
  }

  private key(adId: string, surface: AdSurface): string {
    return `${surface}:${adId}`;
  }

  private elapsedFor(s: Session): number {
    return Math.max(0, this.now() - s.sessionStartedAt);
  }

  /** Mark an ad as currently visible on a surface. Idempotent on the same
   *  sessionNonce — the absolute baseline is sticky and does NOT restart
   *  on repeated show() calls. A new sessionNonce resets the session. */
  show(adId: string, surface: AdSurface, sessionNonce: string): void {
    const k = this.key(adId, surface);
    const existing = this.sessions.get(k);
    if (!existing) {
      this.sessions.set(k, { adId, surface, sessionNonce,
        sessionStartedAt: this.now(), lastTickAtMs: 0,
        thresholdMet: false, errorImpressionCount: 0 });
      return;
    }
    if (existing.sessionNonce !== sessionNonce) {
      // Fresh session — restart the baseline + clear all gate flags.
      existing.sessionNonce = sessionNonce;
      existing.sessionStartedAt = this.now();
      existing.lastTickAtMs = 0;
      existing.thresholdMet = false;
      existing.errorImpressionCount = 0;
    }
    // Same nonce — sticky baseline; nothing to update.
  }

  /** No-op under the absolute-epoch baseline. The elapsed counter is
   *  driven by wall clock from sessionStartedAt; a hidden ad still counts
   *  toward billing because we cannot reliably re-anchor across poll
   *  cadence drops (Page Visibility blur, tab throttling). Kept as a
   *  public method so callers don't need to be rewritten. */
  hide(_adId: string, _surface: AdSurface): void { /* no-op */ }

  /** No-op under the absolute-epoch baseline. See hide(). */
  pause(): void { /* no-op */ }

  /** No-op under the absolute-epoch baseline. See hide(). */
  resume(): void { /* no-op */ }

  /** Drive the periodic emissions. Production calls this every ~250 ms;
   *  tests advance the clock and call it directly. Safe to call multiple
   *  times per "real" wall-clock tick: emissions are idempotent (each
   *  session's threshold-met / error_impression fires at most once). */
  poll(): void {
    for (const s of this.sessions.values()) {
      const elapsed = this.elapsedFor(s);
      this.emitTickIfDue(s, elapsed);
      this.emitThresholdIfMet(s, elapsed);
      this.emitErrorImpressionIfStuck(s, elapsed);
    }
  }

  private emitTickIfDue(s: Session, elapsed: number): void {
    if (!this.onTick) { s.lastTickAtMs = elapsed; return; }
    while (elapsed - s.lastTickAtMs >= this.tickMs) {
      s.lastTickAtMs += this.tickMs;
      this.onTick({ adId: s.adId, surface: s.surface,
        sessionNonce: s.sessionNonce, visibleMs: s.lastTickAtMs });
    }
  }

  private emitThresholdIfMet(s: Session, elapsed: number): void {
    if (s.thresholdMet) return;
    // Mutual exclusion: if ANY error_impression has fired this session,
    // suppress the natural threshold-met event. With default cap=5s and
    // threshold=15s this means threshold_met effectively never fires;
    // when the cap is disabled (maxSessionMs=0) threshold_met still works.
    if (s.errorImpressionCount > 0) return;
    if (elapsed < this.thresholdMs) return;
    s.thresholdMet = true;
    this.onThresholdMet?.({
      adId: s.adId, surface: s.surface, sessionNonce: s.sessionNonce,
      visibleMs: elapsed, thresholdMs: this.thresholdMs,
    });
  }

  private emitErrorImpressionIfStuck(s: Session, elapsed: number): void {
    // Mutex: once threshold_met has billed this session, do not also fire
    // error_impression — preserves the codex-rescue "one bill per session"
    // contract for the threshold-first config (maxSessionMs > thresholdMs).
    if (s.thresholdMet) return;
    if (this.maxSessionMs <= 0) return;        // disabled
    // Fire at every maxSessionMs boundary of elapsed time. A `nextFireAt`
    // computed from `errorImpressionCount` keeps the cadence exact even
    // when poll() skips: if 11 s elapsed since the last fire, the next
    // single poll fires once and advances the marker; we do NOT fire
    // twice to "catch up" (that would burst-bill on a throttled tab).
    const nextFireAt = (s.errorImpressionCount + 1) * this.maxSessionMs;
    if (elapsed < nextFireAt) return;
    s.errorImpressionCount += 1;
    this.onErrorImpression?.({
      adId: s.adId, surface: s.surface, sessionNonce: s.sessionNonce,
      visibleMs: elapsed, maxSessionMs: this.maxSessionMs,
    });
  }

  /** Inspection helpers (used by tests + diagnostics). */
  visibleMsFor(adId: string, surface: AdSurface): number {
    const s = this.sessions.get(this.key(adId, surface));
    return s ? this.elapsedFor(s) : 0;
  }
  hasMetThreshold(adId: string, surface: AdSurface): boolean {
    return this.sessions.get(this.key(adId, surface))?.thresholdMet === true;
  }
}