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
2 changes: 1 addition & 1 deletion app/(app)/dashboard/ads/earnings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import dynamic from "next/dynamic";
import { createClient } from "@/lib/supabase/server";
import { loadEarnings, dollars, type EarningsModel } from "@/lib/ads/earnings-data";
import { EarningsPdfButton } from "@/components/ads/earnings-pdf-button";
import { StatsUnavailable } from "@/components/ads/stats-unavailable";
import { StatsUnavailable } from "@/components/stats-unavailable";

// recharts is client-only; keep it out of the server bundle.
const MoneyTrend = dynamic(() => import("@/components/ads/money-trend").then((m) => m.MoneyTrend));
Expand Down
2 changes: 1 addition & 1 deletion app/(app)/dashboard/ads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { MiniTrend } from "@/components/ads/mini-trend";
import { AccountTrend } from "@/components/ads/account-trend";
import { RangeTabs } from "@/components/ads/range-tabs";
import { StatSpark } from "@/components/ads/stat-spark";
import { StatsUnavailable } from "@/components/ads/stats-unavailable";
import { StatsUnavailable } from "@/components/stats-unavailable";
import {
deliveredClicks,
deliveredImpressions,
Expand Down
2 changes: 1 addition & 1 deletion app/(app)/dashboard/ads/slots/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createClient } from "@/lib/supabase/server";
import { env } from "@/lib/env";
import { fetchSupportedTokens } from "@/lib/coinpay-tokens";
import { SlotManager } from "@/components/ads/slot-manager";
import { StatsUnavailable } from "@/components/ads/stats-unavailable";
import { StatsUnavailable } from "@/components/stats-unavailable";
import {
deliveredClicks,
deliveredImpressions,
Expand Down
37 changes: 36 additions & 1 deletion app/(app)/dashboard/analytics/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Link from "next/link";
import { createClient } from "@/lib/supabase/server";
import { rpcFailed } from "@/lib/loaded";
import { StatsUnavailable } from "@/components/stats-unavailable";
import { ProjectLogo } from "@/components/project-logo";
import { FontSparkline } from "@/components/font-sparkline";
import { bucketLabel } from "@/lib/tracker/categorize";
Expand Down Expand Up @@ -175,6 +177,30 @@ export default async function PortfolioAnalyticsPage({
supabase.rpc("tracker_device_totals_multi", { p_projects: projectIds, days }),
]);

// Every panel below zero-fills on failure so one bad query cannot take the
// page down. Without this flag that is indistinguishable from a portfolio
// with no traffic: these eleven RPCs run concurrently over every project the
// account owns, and when they were cancelled by the 8s statement_timeout the
// whole page rendered as zeros with nothing logged.
const rpcResults: Array<[string, { error: { message?: string } | null }]> = [
["tracker_daily_series_multi", seriesRes],
["tracker_project_totals", totalsRes],
["tracker_bucket_totals_multi", bucketsRes],
["tracker_event_mix_multi", mixRes],
["tracker_top_pages_multi", pagesRes],
["tracker_top_referrers_multi", referrersRes],
["tracker_top_actions_multi", actionsRes],
["tracker_top_exit_pages_multi", exitRes],
["tracker_top_countries_multi", countriesRes],
["tracker_top_cities_multi", citiesRes],
["tracker_device_totals_multi", devicesRes],
];
let statsFailed = false;
// Not `.some()`: every failure should be logged, not just the first.
for (const [name, res] of rpcResults) {
if (rpcFailed("tracker", name, res.error)) statsFailed = true;
}

const series = ((seriesRes.data ?? []) as Parameters<typeof toSeriesRow>[0][]).map(
toSeriesRow,
);
Expand Down Expand Up @@ -215,10 +241,15 @@ export default async function PortfolioAnalyticsPage({
Math.min(ranked.length, Math.floor(DAILY_ROW_BUDGET / days)),
);
const detailIds = ranked.slice(0, detailCount).map((r) => r.project.id);
const { data: perProjectRaw } = await supabase.rpc(
const { data: perProjectRaw, error: perProjectError } = await supabase.rpc(
"tracker_project_daily_series",
{ p_projects: detailIds, days },
);
const perProjectFailed = rpcFailed(
"tracker",
"tracker_project_daily_series",
perProjectError,
);

const axis = utcDayAxis(days);
const dailyByProject = new Map<string, Map<string, number>>();
Expand Down Expand Up @@ -397,6 +428,10 @@ export default async function PortfolioAnalyticsPage({
<div className="space-y-6">
<PageHeader days={days} orgs={orgs} selectedOrgId={selectedOrg?.id ?? null} />

{(statsFailed || perProjectFailed) && (
<StatsUnavailable what="portfolio analytics for this window" />
)}

<section className="card p-4">
<h2 className="text-lg font-semibold">{verdict}</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Expand Down
37 changes: 29 additions & 8 deletions app/(app)/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import Link from "next/link";
import { createClient } from "@/lib/supabase/server";
import { rpcFailed, type Loaded } from "@/lib/loaded";
import { ScoreBadge } from "@/components/score-badge";
import { FontSparkline } from "@/components/font-sparkline";
import { ProjectLogo } from "@/components/project-logo";
import { StatsUnavailable } from "@/components/stats-unavailable";
import { backfillProjectLogo } from "@/app/actions/createProject";
import { getOrCreateDefaultOrg, isOrgWideRole, listUserOrgs, missingOrgSchema } from "@/lib/orgs";
import { listOrgTeam } from "@/app/actions/org-members";
Expand Down Expand Up @@ -118,12 +120,14 @@ export default async function DashboardPage({
// project has an lx_site row in status=active; social is "on" when at
// least one social account is linked at the project level.
const projectIds = (projects ?? []).map((p) => p.id);
const [autoblogIds, socialIds, latestPosts, trafficByProject] = await Promise.all([
const [autoblogIds, socialIds, latestPosts, traffic] = await Promise.all([
fetchEnabledProjectIds(supabase, "lx_site", projectIds, { status: "active" }),
fetchEnabledProjectIds(supabase, "sp_site_account", projectIds),
fetchLatestBlogPostByProject(supabase, projectIds),
fetchSevenDayPageviews(supabase, projectIds),
]);
const trafficByProject = traffic.data;
const trafficFailed = traffic.failed;

// Lazy backfill: any project still missing a logo gets one scraped
// in the background on this dashboard hit. Fire-and-forget — the
Expand Down Expand Up @@ -187,6 +191,10 @@ export default async function DashboardPage({
</div>
</div>

{projects && projects.length > 0 && trafficFailed && (
<StatsUnavailable what="pageviews for these projects" />
)}

{projects && projects.length > 0 ? (
<ul className="grid gap-3 md:grid-cols-2">
{projects.map((p) => (
Expand Down Expand Up @@ -256,13 +264,17 @@ export default async function DashboardPage({
<div className="mt-3 flex items-center justify-between gap-3 border-t border-[var(--color-border)] pt-3">
<div>
<div className="text-xs font-medium text-[var(--color-fg)]">
{totalTraffic(trafficByProject.get(p.id) ?? []).toLocaleString()} pageviews
{trafficFailed
? "Pageviews unavailable"
: `${totalTraffic(trafficByProject.get(p.id) ?? []).toLocaleString()} pageviews`}
</div>
<div className="text-[11px] text-[var(--color-muted)]">
Past 7 days
{trafficFailed ? "Query failed \u2014 not zero" : "Past 7 days"}
</div>
</div>
<FontSparkline samples={trafficSamples(trafficByProject.get(p.id))} />
{!trafficFailed && (
<FontSparkline samples={trafficSamples(trafficByProject.get(p.id))} />
)}
</div>
{orgSchemaReady && (
<ProjectOrgMoveControl
Expand Down Expand Up @@ -363,7 +375,7 @@ async function fetchLatestBlogPostByProject(
async function fetchSevenDayPageviews(
supabase: Awaited<ReturnType<typeof createClient>>,
projectIds: string[],
): Promise<Map<string, TrafficPoint[]>> {
): Promise<Loaded<Map<string, TrafficPoint[]>>> {
const days = lastSevenDays();
const out = new Map<string, TrafficPoint[]>();
for (const projectId of projectIds) {
Expand All @@ -372,15 +384,24 @@ async function fetchSevenDayPageviews(
days.map((day) => ({ day, count: 0 })),
);
}
if (projectIds.length === 0) return out;
if (projectIds.length === 0) return { data: out, failed: false };

// Server-side aggregator — selecting raw rows runs into PostgREST's
// 1000-row response cap and silently truncates whichever projects'
// rows didn't make the cut.
const { data } = await supabase.rpc("dashboard_project_pageviews", {
//
// The error is checked rather than discarded: this RPC reads a 1.2M-row
// rollup under RLS, and when it was cancelled by the 8s statement_timeout
// the zero-filled map below rendered "0 pageviews" on every card. That is
// indistinguishable from a portfolio with no traffic, and was read as a
// dead tracker three times over.
const { data, error } = await supabase.rpc("dashboard_project_pageviews", {
p_project_ids: projectIds,
p_since: days[0],
});
if (rpcFailed("tracker", "dashboard_project_pageviews", error)) {
return { data: out, failed: true };
}

for (const row of (data ?? []) as Array<{
project_id: string;
Expand All @@ -393,7 +414,7 @@ async function fetchSevenDayPageviews(
if (point) point.count += Number(row.count);
}

return out;
return { data: out, failed: false };
}

function lastSevenDays() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
* Shown when a stats query failed, in place of the zeros it would otherwise
* have rendered.
*
* The ad dashboards zero-fill on failure so one bad panel cannot take the page
* down. Without this banner that choice is indistinguishable from a real run of
* no delivery, which is how a live network reporting six figures of impressions
* came to show four zeros and read as a dead pipeline.
* The ad and tracker dashboards both zero-fill on failure so one bad panel
* cannot take the page down. Without this banner that choice is
* indistinguishable from a real run of no traffic, which is how a live network
* reporting six figures of impressions came to show four zeros, and how every
* project on the portfolio page came to read 0 pageviews while ingest was
* writing rows every second.
*/
export function StatsUnavailable({ what = "these figures" }: { what?: string }) {
return (
Expand Down
34 changes: 8 additions & 26 deletions lib/ads/series.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { bucketAxis, bucketOf, rangeSince, type RangeDef } from "./ranges";
import { rpcFailed as rpcFailedIn, type Loaded } from "@/lib/loaded";

export type AccountPoint = {
/** Bucket start, epoch ms (epoch-aligned, matching SQL date_bin). */
Expand Down Expand Up @@ -27,34 +28,15 @@ export const EMPTY_TOTALS: RangeTotals = {
spentCents: 0,
};

/**
* A loader's result together with whether the query behind it actually ran.
*
* Every loader below zero-fills rather than throwing, so one failing panel
* cannot take the whole page down with it. That part is deliberate and stays.
* What it cost is a way to tell the two apart: a cancelled query and a
* genuinely quiet range produced byte-identical output, so the dashboard could
* report four zeros over a network delivering six figures and say nothing was
* wrong. It has now done exactly that three times, for three unrelated reasons
* — the paid-only measure (#199), the same bug on two more surfaces (#225), and
* an RPC being cancelled by statement_timeout. Each time the zeros were read as
* a dead pipeline and diagnosed from scratch.
*
* `failed` is how a caller tells "we could not load this" from "this is 0".
*/
export type Loaded<T> = { data: T; failed: boolean };
// `Loaded<T>` and the failure logging live in lib/loaded.ts now: the tracker
// dashboards swallowed their RPC errors exactly the same way and reported 0
// pageviews across every project while ingest was healthy, so the two halves
// of the product share one helper rather than each growing their own.

export type { Loaded };

/**
* Record an RPC failure and report whether there was one.
*
* Logs, because the error was previously discarded at the point of failure:
* the only surviving evidence that a query had been cancelled was in Postgres'
* own logs, which is a long way to go to explain a tile reading 0.
*/
function rpcFailed(name: string, error: { message?: string } | null): boolean {
if (!error) return false;
console.error(`[ads] RPC ${name} failed: ${error.message ?? "unknown error"}`);
return true;
return rpcFailedIn("ads", name, error);
}

type AccountSeriesRow = {
Expand Down
37 changes: 37 additions & 0 deletions lib/loaded.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* A loader's result together with whether the query behind it actually ran.
*
* Every loader that uses this zero-fills rather than throwing, so one failing
* panel cannot take the whole page down with it. That part is deliberate and
* stays. What it cost is a way to tell the two apart: a cancelled query and a
* genuinely quiet range produced byte-identical output, so a dashboard could
* report zeros over a live pipeline and say nothing was wrong.
*
* That has now happened on both halves of the product. On the ad surfaces it
* took four goes to pin down — the paid-only measure (#199), the same bug on
* two more surfaces (#225), and RPCs cancelled by statement_timeout (#226).
* The tracker surfaces swallowed their errors the same way and reported 0
* pageviews for every project while ingest was writing rows every second.
*
* `failed` is how a caller tells "we could not load this" from "this is 0".
*/
export type Loaded<T> = { data: T; failed: boolean };

/**
* Record an RPC failure and report whether there was one.
*
* Logs, because the error was previously discarded at the point of failure:
* the only surviving evidence that a query had been cancelled was in Postgres'
* own logs, which is a long way to go to explain a tile reading 0.
*/
export function rpcFailed(
scope: string,
name: string,
error: { message?: string } | null,
): boolean {
if (!error) return false;
console.error(
`[${scope}] RPC ${name} failed: ${error.message ?? "unknown error"}`,
);
return true;
}
Loading
Loading