← back to Kickbacks Ai Upstream
src/metrics/client.ts
156 lines
import { randomUUID } from "node:crypto";
import { type AdSurface } from "../types/surface";
import { dlog } from "../log";
import { timeoutFetch } from "../util/http";
export type { AdSurface };
type Fetch = typeof fetch;
// W3 viewership: `view_tick` is a 5s heartbeat fired while an ad accumulates
// visible time; `view_threshold_met` fires exactly once per ad-surface-
// session when cumulative visible time crosses the configured threshold
// (default 15s, server-overridable via portfolio.view_threshold_seconds).
// `error_impression` is the MAX_SESSION_MS safety-net fire (default 5 s) —
// once per session if the natural session-close never lands so a stuck ad
// still bills. Billable view-family events share a backend cooldown bucket so
// only one credit can move inside each cooldown window.
export type MetricEvent =
| "impression_rendered"
| "impression_viewable"
| "prompt_view"
| "click"
| "view_tick"
| "view_threshold_met"
| "error_impression";
const EVENT_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function newMetricEventUuid(): string {
return randomUUID();
}
// Deliberate sign-out hook: cmdSignOut calls noteMetricsSignOut() so the
// `demoted:true` stamp (which marks mid-session token DEATH) is not applied
// to demo traffic after an intentional sign-out. Module-scoped, matching the
// adRotation clear hook: exactly one MetricsClient serves an extension host.
let liveSignOutReset: (() => void) | null = null;
export function noteMetricsSignOut(): void {
liveSignOutReset?.();
}
/** POSTs the S2 /v1/metrics contract (required keys
* event_type,ad_id,campaign_id,client_id,ts,nonce; server-authoritative on
* tier/measurement). Best-effort: never throws. */
export class MetricsClient {
// Demo-route demotion tracking: routing keys purely on token absence, so a
// mid-session token death silently re-routes REAL ad ids to the demo
// endpoint. `wasAuthed` lets a demoted (was-signed-in) send be stamped so
// it stays distinguishable from genuine signed-out demo traffic;
// `demotionLogged` makes the first demotion of each outage observable in
// the debug log. Routing itself (money semantics) is unchanged.
private wasAuthed = false;
private demotionLogged = false;
constructor(private base: string, private token: () => string | null,
private clientId: () => string, private extVersion: string,
private f: Fetch = timeoutFetch(15000),
// Client-environment fingerprint (os/arch/os_version/editor),
// computed once at activation and sent on every beacon so the
// backend can segment ad traffic by client type. Transparent —
// see app/admin Traffic; nothing here is hidden or obfuscated.
private clientEnv?: Record<string, unknown>,
// Fleet-signal sink: the backend piggybacks `kill` (every
// response) and `balances` (authed + billed) on the beacon
// response, replacing standalone polls. Optional + best-effort.
private signals: { noteKill(raw: unknown): void;
noteBalances(raw: unknown): void }
| null = null) {
liveSignOutReset = () => {
this.wasAuthed = false;
this.demotionLogged = false;
};
}
async send(event: MetricEvent,
a: { adId: string; campaignId: string; ccVersion: string;
corr?: string; surface?: AdSurface; visibleMs?: number;
sessionNonce?: string; viewable?: boolean;
viewPct?: number; viewMs?: number;
sessionToken?: string; eventUuid?: string }): Promise<void> {
try {
const eventUuid = a.eventUuid && EVENT_UUID_RE.test(a.eventUuid)
? a.eventUuid
: newMetricEventUuid();
const body: Record<string, unknown> = {
event_type: event, ad_id: a.adId, campaign_id: a.campaignId,
client_id: this.clientId(), ts: new Date().toISOString(),
claude_code_version: a.ccVersion, extension_version: this.extVersion,
nonce: eventUuid,
};
// W3 viewership additions — only included when present so we don't
// pollute legacy event shapes (impression_*, click) with empty fields.
if (a.surface) body.surface = a.surface;
if (typeof a.visibleMs === "number") body.visible_ms = a.visibleMs;
if (a.sessionNonce) body.session_nonce = a.sessionNonce;
if (typeof a.viewable === "boolean") body.viewable = a.viewable;
if (typeof a.viewPct === "number") body.view_pct = a.viewPct;
if (typeof a.viewMs === "number") body.view_ms = a.viewMs;
if (a.sessionToken) body.session_token = a.sessionToken;
if (this.clientEnv) body.ext = this.clientEnv;
const t = this.token();
// Demo routing: a tokenless (signed-out) send goes to the public
// /v1/metrics/demo — it charges the advertiser but credits no user (the
// platform keeps 100%). The signed-in path is byte-identical to before.
// The `client_id` already in `body` is the demo identity anchor that the
// backend pairs with the demo session token. One switch here covers every
// surface (overlay + status bar); signed-out used to send nothing at all.
const path = t ? "/v1/metrics" : "/v1/metrics/demo";
if (t) {
this.wasAuthed = true;
this.demotionLogged = false;
} else {
// Stamp every demo-route send inside `ext` — the only schema-allowed
// free-form field (the backend 400s unknown top-level keys). A send
// demoted by a mid-session token death additionally carries
// `demoted:true` so analytics can tell it apart from genuine
// signed-out demo traffic.
const demoted = this.wasAuthed;
body.ext = { ...(this.clientEnv ?? {}), demo: true,
...(demoted ? { demoted: true } : {}) };
if (demoted && !this.demotionLogged) {
this.demotionLogged = true;
dlog("ext", "metric.demo_demoted", { event, adId: a.adId });
}
}
const res = await this.f(`${this.base}${path}`, {
method: "POST",
headers: { "content-type": "application/json",
...(t ? { authorization: `Bearer ${t}` } : {}),
// W1 rename: send the new header AND the legacy one for one release
// so the backend dual-accept can be staged independently.
...(a.corr ? { "X-Kickbacks-Corr": a.corr, "X-Vibe-Corr": a.corr } : {}) },
body: JSON.stringify(body),
});
if (!res.ok) {
dlog("ext", "metric.send_failed", { status: res.status, event });
} else if (this.signals) {
// Fleet signals: only a FRESH 2xx body may produce a verdict; a
// failed/old-backend response body (empty, non-JSON, no fields)
// simply yields nothing. Balances only when this send was authed —
// a demo-route response never carries them and must never repaint
// a signed-out status bar.
try {
const j = await res.json() as { kill?: unknown; balances?: unknown };
this.signals.noteKill(j?.kill);
if (t) this.signals.noteBalances(j?.balances);
} catch { /* old backend / empty body — no signal */ }
}
} catch (e) {
// Still best-effort (never throws past here) — but a network-level
// failure must stay greppable, not a total observability blackout.
dlog("ext", "metric.send_error",
{ event, msg: e instanceof Error ? e.message : String(e) });
}
}
}