Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 98 additions & 2 deletions app/api/ads/v1/earnings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,104 @@ export async function GET(req: NextRequest) {
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });

const days = parseDays(req.nextUrl.searchParams.get("days"));
const sb = serviceClient();
// The service client has no RLS. loadEarnings filters every table by
// owner_id itself, which is what makes passing it here safe.
const model = await loadEarnings(serviceClient(), auth.userId, days);
return NextResponse.json(model);
const model = await loadEarnings(sb, auth.userId, days);

// The windowed delivery figures come from RPCs that are `security definer`
// and filter on `auth.uid()`. A service client has no auth.uid(), so they
// return nothing and every impression count arrives as a confident zero.
// The stats views are `security_invoker` and granted to service_role, so
// they can be read directly. They are lifetime rather than windowed, which
// is why this only replaces figures that came back empty, and why the answer
// says which it gave you.
const delivery = await lifetimeDelivery(sb, model);
return NextResponse.json({ ...model, ...delivery });
}

type StatsRow = {
impressions: number | null;
free_impressions: number | null;
clicks: number | null;
free_clicks: number | null;
};

/**
* What the stats views actually mean, which is not what the column names
* suggest and is worth writing down once:
*
* impressions paid tier, non-duplicate
* free_impressions free tier, non-duplicate -> delivery is the SUM of both
* clicks **valid** clicks, either tier -> delivery is this alone
* free_clicks free tier and **not valid** -> fraud/duplicate, NOT delivery
*
* So impressions add up and clicks do not. Adding `free_clicks` into clicks
* would fold invalid clicks into the CTR, which is the one number a click
* fraud problem would show up in.
*/
const sum = (rows: StatsRow[], key: keyof StatsRow) =>
rows.reduce((total, row) => total + (Number(row[key]) || 0), 0);

/**
* Read a stats view for a list of ids, in chunks.
*
* PostgREST puts `in.(…)` in the query string, and this account is past 180
* campaigns, so one call is a 7KB URL that comes back empty rather than
* erroring. That empty answer is exactly what a network with no delivery looks
* like, which is how it went unnoticed.
*/
async function readStats(
sb: ReturnType<typeof serviceClient>,
view: "ad_campaign_stats" | "ad_slot_stats",
key: "campaign_id" | "slot_id",
ids: string[],
): Promise<{ rows: StatsRow[]; failed: boolean }> {
const rows: StatsRow[] = [];
let failed = false;
const CHUNK = 50;
for (let i = 0; i < ids.length; i += CHUNK) {
const { data, error } = await sb
.from(view)
.select("impressions, free_impressions, clicks, free_clicks")
.in(key, ids.slice(i, i + CHUNK));
if (error) failed = true;
else rows.push(...((data ?? []) as StatsRow[]));
}
return { rows, failed };
}

async function lifetimeDelivery(
sb: ReturnType<typeof serviceClient>,
model: Awaited<ReturnType<typeof loadEarnings>>,
) {
const t = model.totals;
const empty = !t.advImpressions && !t.advClicks && !t.pubImpressions && !t.pubClicks;
if (!empty) return { deliveryWindow: "range" as const };

const campaignIds = model.campaigns.map((c) => c.id);
const slotIds = model.slots.map((s) => s.id);
if (!campaignIds.length && !slotIds.length) return { deliveryWindow: "range" as const };

const [c, s] = await Promise.all([
readStats(sb, "ad_campaign_stats", "campaign_id", campaignIds),
readStats(sb, "ad_slot_stats", "slot_id", slotIds),
]);

return {
deliveryWindow: "lifetime" as const,
statsUnavailable: model.statsUnavailable || c.failed || s.failed,
totals: {
...t,
advImpressions: sum(c.rows, "impressions") + sum(c.rows, "free_impressions"),
advClicks: sum(c.rows, "clicks"),
advFreeImpressions: sum(c.rows, "free_impressions"),
advPaidImpressions: sum(c.rows, "impressions"),
pubImpressions: sum(s.rows, "impressions") + sum(s.rows, "free_impressions"),
pubClicks: sum(s.rows, "clicks"),
pubFreeImpressions: sum(s.rows, "free_impressions"),
pubPaidImpressions: sum(s.rows, "impressions"),
invalidClicks: sum(c.rows, "free_clicks"),
},
};
}
145 changes: 103 additions & 42 deletions cli/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import type { Container, RenderArgs, Theme } from "@profullstack/hqtui";

import { collectDashboard, type CoinPayAuth, type DashboardSnapshot } from "../lib/dashboard/collect";
import { AD_TARGET_CTR, AD_TARGET_IMPRESSIONS, adTargets } from "../lib/dashboard/roi";

export const TABS = ["ROI", "Traffic", "Ads", "Money", "Spend"] as const;
export const RANGES = ["1h", "4h", "1d", "1w", "1m"] as const;
Expand Down Expand Up @@ -85,6 +86,8 @@ type State = {
paused: boolean;
showHelp: boolean;
panes: Record<string, Pane>;
targetImpressions: number;
targetCtr: number;
};

function pane(state: State, name: string, total: number): Pane {
Expand Down Expand Up @@ -128,14 +131,19 @@ function roiScreen(ui: Container, state: State, theme: Theme): void {
grid.panel(
{
title: "The number",
subtitle: r.cost.scopeMissing ? "whole bank feed" : "business scope",
// Three different time bases meet on this panel, so each says which.
subtitle: `${r.cost.scopeMissing ? "whole bank feed" : "business scope"} · ${r.cost.lookbackDays}d avg`,
subtitleColor: r.cost.scopeMissing ? theme.warning : theme.muted,
},
(p) => {
p.keyValues(
[
{ label: "Cost", value: `${money(r.cost.perMonthUsd)}/mo`, color: theme.danger },
{ label: "Revenue", value: `${money(r.revenue.perMonthUsd)}/mo`, color: theme.success },
{
label: `Revenue (${r.revenue.observedDays}d)`,
value: `${money(r.revenue.perMonthUsd)}/mo`,
color: theme.success,
},
{
label: "Net",
value: `${money(r.derived.netPerMonthUsd)}/mo`,
Expand Down Expand Up @@ -202,7 +210,7 @@ function roiScreen(ui: Container, state: State, theme: Theme): void {
});

grid.panel(
{ title: "Internal", subtitle: "one account, both sides", subtitleColor: theme.muted },
{ title: "Internal", subtitle: "one account, both sides · lifetime", subtitleColor: theme.muted },
(p) => {
p.text("Ad money moving between our own products.", { fg: theme.muted });
p.text("Counted as neither cost nor revenue.", { fg: theme.muted });
Expand All @@ -222,7 +230,16 @@ function roiScreen(ui: Container, state: State, theme: Theme): void {
},
);

grid.panel({ title: "Where the money goes", colSpan: 2 }, (p) => {
grid.panel(
{
title: "Where the money goes",
// The bank window, which is NOT the traffic range in the header. Bank
// data has no hourly resolution, so these differ on every range below
// a month and an unlabelled panel invites the wrong reading.
subtitle: `last ${s.window.financeDays}d · business accounts`,
colSpan: 2,
},
(p) => {
const vendors = r.cost.vendors.slice(0, 8);
if (!vendors.length) {
p.text("No business debits in the window.", { fg: theme.muted });
Expand All @@ -238,19 +255,33 @@ function roiScreen(ui: Container, state: State, theme: Theme): void {
})),
{ labelWidth: 19, valueWidth: 8 },
);
});
},
);

grid.panel({ title: "Reach" }, (p) => {
grid.panel({ title: "Reach", subtitle: `ads ${s.ads?.rangeDays ?? "?"}d · money ${r.revenue.observedDays}d` }, (p) => {
p.keyValues(
[
{ label: "Impressions", value: count(r.attention.impressions) },
{ label: "Clicks", value: count(r.attention.clicks) },
{ label: "CTR", value: pct(r.attention.ctr, 2) },
{ label: "", value: "" },
// Both are rates built from the day series. The lifetime totals sit
// underneath them precisely so the large number is visible without
// being mistaken for a run rate.
{ label: "Merchant volume", value: `${money(r.revenue.grossVolumePerMonthUsd, { compact: true })}/mo` },
{ label: "Our commission", value: `${money(r.revenue.commissionPerMonthUsd)}/mo`, color: theme.success },
{
label: " lifetime volume",
value: money(r.revenue.lifetimeGrossVolumeUsd, { compact: true }),
color: theme.muted,
},
{
label: " lifetime commission",
value: money(r.revenue.lifetimeCommissionUsd),
color: theme.muted,
},
],
{ labelWidth: 17 },
{ labelWidth: 21 },
);
});

Expand Down Expand Up @@ -356,48 +387,70 @@ function adsScreen(ui: Container, state: State, theme: Theme): void {
return;
}

const t = ads.totals ?? {};
const spent = num(t.spentCents) / 100;
const earned = num(t.earnedCents) / 100;
const t = adTargets(ads, { targetImpressions: state.targetImpressions, targetCtr: state.targetCtr });
const spent = num(ads.totals?.spentCents) / 100;
const earned = num(ads.totals?.earnedCents) / 100;
const window =
(ads as { deliveryWindow?: string }).deliveryWindow === "lifetime" ? "lifetime" : `${ads.rangeDays ?? "?"}d`;

ui.grid({ columns: ["1fr", "1fr", "1fr"], rows: [13, "1fr"], gap: 1 }, (grid) => {
grid.panel({ title: "As advertiser", subtitle: `${ads.rangeDays ?? "?"}d delivery` }, (p) => {
// Free first, because the network is free backfill today and leading with
// the paid columns reports a working network as a dead one.
grid.panel({ title: "Delivered", subtitle: window }, (p) => {
p.keyValues(
[
{ label: "Impressions", value: count(t.advImpressions) },
{ label: "Clicks", value: count(t.advClicks) },
{
label: "CTR",
value: pct(num(t.advImpressions) > 0 ? num(t.advClicks) / num(t.advImpressions) : null, 2),
},
{ label: "Spend (lifetime)", value: money(spent), color: theme.warning },
],
{ labelWidth: 19 },
);
});

grid.panel({ title: "As publisher", subtitle: `${ads.rangeDays ?? "?"}d delivery` }, (p) => {
p.keyValues(
[
{ label: "Impressions", value: count(t.pubImpressions) },
{ label: "Clicks", value: count(t.pubClicks) },
{
label: "CTR",
value: pct(num(t.pubImpressions) > 0 ? num(t.pubClicks) / num(t.pubImpressions) : null, 2),
},
{ label: "Impressions", value: count(t.impressions), color: theme.primary },
{ label: " free", value: count(t.freeImpressions), color: theme.success },
{ label: " paid", value: count(t.paidImpressions), color: theme.muted },
{ label: "Clicks", value: count(t.clicks) },
{ label: "CTR", value: pct(t.ctr, 3) },
{
label: "Invalid clicks",
value: count(t.invalidClicks),
color: num(t.invalidClicks) > 0 ? theme.warning : theme.muted,
color: t.invalidClicks > t.clicks ? theme.danger : theme.warning,
},
{ label: "Earned (lifetime)", value: money(earned), color: theme.success },
{ label: "Available", value: money(num(t.availableCents) / 100) },
],
{ labelWidth: 19 },
{ labelWidth: 16 },
);
if (t.invalidClicks > t.clicks && t.clicks > 0) {
p.text(`${Math.round(t.invalidClicks / t.clicks)}x more invalid than valid.`, { fg: theme.danger });
}
});

grid.panel({ title: "Net of the network" }, (p) => {
grid.panel(
{ title: "Toward the target", subtitle: `${count(t.targetImpressions)}/mo · ${pct(t.targetCtr, 0)} CTR` },
(p) => {
p.meters(
[
{
label: "impressions",
value: Math.min(1, t.impressionProgress),
max: 1,
text: pct(t.impressionProgress, 1),
},
{ label: "CTR", value: Math.min(1, t.ctrProgress), max: 1, text: pct(t.ctrProgress, 1) },
],
{ labelWidth: 12, valueWidth: 8 },
);
p.keyValues(
[
{ label: "Short by", value: count(Math.max(0, t.targetImpressions - t.impressions)) },
{
label: "Cost per click",
value: t.cpcCents === null ? "nothing charged yet" : `${t.cpcCents.toFixed(1)}c`,
},
{
label: "At target",
value: t.projectedMonthlyUsd === null ? "-" : `${money(t.projectedMonthlyUsd)}/mo`,
color: theme.success,
},
],
{ labelWidth: 16 },
);
},
);

grid.panel({ title: "Net of the network", subtitle: "one account, both sides" }, (p) => {
p.text("We advertise on our own slots, so these", { fg: theme.muted });
p.text("two sides are the same dollar.", { fg: theme.muted });
p.keyValues(
Expand All @@ -409,24 +462,25 @@ function adsScreen(ui: Container, state: State, theme: Theme): void {
value: money(earned - spent),
color: Math.abs(earned - spent) < 1 ? theme.muted : theme.warning,
},
{ label: "Available", value: money(num(ads.totals?.availableCents) / 100) },
],
{ labelWidth: 12 },
);
if (ads.statsUnavailable) {
p.text("A delivery query failed; counts are zero-filled.", { fg: theme.danger });
p.text("A delivery query failed; counts are low.", { fg: theme.danger });
}
});

grid.panel({ title: "Ad-driven arrivals", colSpan: 3, subtitle: "sources bucketed as Ad · …" }, (p) => {
const adSources = s.fleet.sources.filter((x) => x.label.startsWith("Ad ·"));
grid.panel({ title: "Ad-driven arrivals", colSpan: 3, subtitle: "sources bucketed as Ad" }, (p) => {
const adSources = s.fleet.sources.filter((x) => x.label.startsWith("Ad "));
if (!adSources.length) {
p.text("No arrivals attributed to an ad in this window.", { fg: theme.muted });
return;
}
const max = Math.max(1, ...adSources.map((x) => x.value));
p.meters(
adSources.slice(0, 10).map((x) => ({
label: x.label.replace(/^Ad · /, "").slice(0, 24),
label: x.label.replace(/^Ad . /, "").slice(0, 24),
value: x.value,
max,
text: count(x.value),
Expand Down Expand Up @@ -546,7 +600,9 @@ function spendScreen(ui: Container, state: State, theme: Theme): void {
grid.panel(
{
title: "Who we pay",
subtitle: r.cost.vendorsPartial ? "newest page of the ledger" : "the whole window",
subtitle: r.cost.vendorsPartial
? `last ${s.window.financeDays}d · newest page of the ledger`
: `last ${s.window.financeDays}d`,
subtitleColor: r.cost.vendorsPartial ? theme.warning : theme.muted,
footer: "business accounts only",
},
Expand Down Expand Up @@ -643,6 +699,9 @@ export type DashboardOptions = {
coinpay: CoinPayAuth | null;
only?: string[] | null;
theme?: string;
/** Where the ad network is trying to get to; see lib/dashboard/roi.ts. */
targetImpressions?: number;
targetCtr?: number;
};

export async function runDashboard(opts: DashboardOptions): Promise<void> {
Expand All @@ -664,6 +723,8 @@ export async function runDashboard(opts: DashboardOptions): Promise<void> {
paused: false,
showHelp: false,
panes: {},
targetImpressions: opts.targetImpressions ?? AD_TARGET_IMPRESSIONS,
targetCtr: opts.targetCtr ?? AD_TARGET_CTR,
};

let refreshing = false;
Expand Down
Loading
Loading