From 4c7e039c32aa82945564cbfacdd77950a213f112 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 19 May 2026 16:25:20 +0100 Subject: [PATCH 1/5] feat(billing): add posthog code token spend analysis banner Surfaces a self-serve spend analysis inside Settings -> Plan & usage. Calls the new /api/llm_analytics/posthog_code_spend/ endpoint, renders totals + breakdowns by ai_product / tool / model + top traces, and generates inline heuristic suggestions on where to optimise. Footer links to PostHog LLM analytics docs and the exploring-llm-costs skill. Gated behind the posthog-code-spend-analysis feature flag (always on in dev). Plan & usage section itself is also forced on in dev so the banner is reachable without billing-flag setup. Generated-By: PostHog Code Task-Id: f9d5d152-49c6-46cf-8fde-079105ba2e67 --- apps/code/src/renderer/api/posthogClient.ts | 21 + .../components/TokenSpendAnalysisBanner.tsx | 392 ++++++++++++++++++ .../billing/hooks/useTokenSpendAnalysis.ts | 40 ++ .../features/billing/types/spend-analysis.ts | 43 ++ .../settings/components/SettingsDialog.tsx | 2 +- .../components/sections/PlanUsageSettings.tsx | 8 + 6 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx create mode 100644 apps/code/src/renderer/features/billing/hooks/useTokenSpendAnalysis.ts create mode 100644 apps/code/src/renderer/features/billing/types/spend-analysis.ts diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 5f24d2830..b0e48fa09 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1,3 +1,4 @@ +import type { SpendAnalysisResponse } from "@features/billing/types/spend-analysis"; import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort"; import type { PermissionMode } from "@posthog/agent/execution-mode"; import { @@ -2854,4 +2855,24 @@ export class PostHogAPIClient { const blob = await response.blob(); return URL.createObjectURL(blob); } + + /** Fetch the requesting user's PostHog Code token spend analysis. */ + async getPostHogCodeSpendAnalysis( + days: number = 30, + ): Promise { + const urlPath = `/api/llm_analytics/posthog_code_spend/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + url.searchParams.set("days", String(days)); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch token spend analysis: ${response.statusText}`, + ); + } + return (await response.json()) as SpendAnalysisResponse; + } } diff --git a/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx b/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx new file mode 100644 index 000000000..34b413c42 --- /dev/null +++ b/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx @@ -0,0 +1,392 @@ +import { useTokenSpendAnalysis } from "@features/billing/hooks/useTokenSpendAnalysis"; +import type { + SpendAnalysisProductRow, + SpendAnalysisResponse, + SpendAnalysisToolRow, + SpendAnalysisTraceRow, +} from "@features/billing/types/spend-analysis"; +import { + ArrowSquareOut, + ChartLine, + Lightning, + WarningCircle, +} from "@phosphor-icons/react"; +import { Button, Callout, Flex, Spinner, Table, Text } from "@radix-ui/themes"; + +const DOCS_URL = "https://posthog.com/docs/llm-analytics"; +const SKILL_URL = + "https://github.com/PostHog/posthog/blob/master/products/llm_analytics/skills/exploring-llm-costs/SKILL.md"; + +function formatUsd(amount: number): string { + if (amount === 0) return "$0"; + if (amount < 0.01) return "<$0.01"; + if (amount < 1) return `$${amount.toFixed(2)}`; + if (amount < 100) return `$${amount.toFixed(2)}`; + return `$${Math.round(amount).toLocaleString()}`; +} + +function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`; + return n.toString(); +} + +function formatTrace(traceId: string | null): string { + if (!traceId) return "(no trace id)"; + if (traceId.length <= 14) return traceId; + return `${traceId.slice(0, 8)}…${traceId.slice(-4)}`; +} + +function formatDate(iso: string | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +function generateSuggestions(data: SpendAnalysisResponse): string[] { + const suggestions: string[] = []; + const { summary, by_tool, top_traces } = data; + + if (summary.total_cost_usd === 0) { + return ["No LLM spend in the selected window."]; + } + + const codeShare = + summary.posthog_code_cost_usd / Math.max(summary.total_cost_usd, 0.0001); + if (codeShare > 0.7) { + suggestions.push( + `PostHog Code is ${Math.round(codeShare * 100)}% of your spend. Other AI products (background agents, posthog_ai) are minor here.`, + ); + } + + const codeTotal = summary.posthog_code_cost_usd; + if (codeTotal > 0 && by_tool.length > 0) { + const top = by_tool[0]; + const share = top.cost_usd / codeTotal; + if (share > 0.35 && top.tool) { + suggestions.push( + `${top.tool} drives ${Math.round(share * 100)}% of your PostHog Code spend — averaging ${formatTokens(top.avg_input_tokens)} input tokens per call.`, + ); + } + const noToolRow = by_tool.find((r) => r.tool === null); + if (noToolRow && noToolRow.cost_usd / codeTotal > 0.1) { + suggestions.push( + `${Math.round((noToolRow.cost_usd / codeTotal) * 100)}% is spent on generations that take no tool action — pure text replies. Consider tighter prompts or stopping the agent earlier.`, + ); + } + } + + if (top_traces.length > 0 && codeTotal > 0) { + const topTrace = top_traces[0]; + const share = topTrace.cost_usd / codeTotal; + if (share > 0.15) { + suggestions.push( + `Your top session cost ${formatUsd(topTrace.cost_usd)} — ${Math.round(share * 100)}% of PostHog Code spend in one trace. Long sessions compound context cost.`, + ); + } + } + + if (suggestions.length === 0) { + suggestions.push( + "Your spend is fairly evenly distributed across tools and sessions — no single hotspot stands out.", + ); + } + + return suggestions; +} + +function SummaryRow({ data }: { data: SpendAnalysisResponse }) { + const { summary } = data; + const codeShare = + summary.total_cost_usd > 0 + ? Math.round( + (summary.posthog_code_cost_usd / summary.total_cost_usd) * 100, + ) + : 0; + return ( + + + + + + + ); +} + +function StatCard({ + label, + value, + sub, +}: { + label: string; + value: string; + sub?: string; +}) { + return ( + + + {label} + + {value} + {sub && {sub}} + + ); +} + +function ProductTable({ rows }: { rows: SpendAnalysisProductRow[] }) { + if (rows.length === 0) return null; + return ( + + {rows.map((r) => ( + + {r.product ?? "(none)"} + {r.event_count.toLocaleString()} + {formatUsd(r.cost_usd)} + + ))} + + ); +} + +function ToolTable({ rows }: { rows: SpendAnalysisToolRow[] }) { + if (rows.length === 0) return null; + return ( + + {rows.slice(0, 10).map((r) => ( + + {r.tool ?? "(no tool)"} + {r.generation_count.toLocaleString()} + {formatTokens(r.avg_input_tokens)} + {formatUsd(r.cost_usd)} + + ))} + + ); +} + +function TraceTable({ rows }: { rows: SpendAnalysisTraceRow[] }) { + if (rows.length === 0) return null; + return ( + + {rows.map((r) => ( + + + + {formatTrace(r.trace_id)} + + + {r.generation_count.toLocaleString()} + {formatDate(r.started_at)} + {formatUsd(r.cost_usd)} + + ))} + + ); +} + +function SectionTable({ + title, + headers, + widths, + children, +}: { + title: string; + headers: string[]; + widths: string[]; + children: React.ReactNode; +}) { + return ( + + {title} + + + + {headers.map((h, i) => ( + + {h} + + ))} + + + {children} + + + ); +} + +function FooterLinks() { + return ( + + + Use{" "} + + PostHog LLM analytics + {" "} + in your own project for the full slice-and-dice experience. + + + Want an agent to run this kind of analysis on demand? Drop the{" "} + + exploring-llm-costs + {" "} + skill into your agent. + + + ); +} + +export function TokenSpendAnalysisBanner() { + const { data, isLoading, error, run } = useTokenSpendAnalysis(); + + if (data) { + const suggestions = generateSuggestions(data); + return ( + + + + + Your PostHog Code token spend (last 30 days) + + + + + + + + + + + + Where to look + + {suggestions.map((s) => ( + + {s} + + ))} + + + + ); + } + + if (error) { + return ( + + + + + + + Couldn't load spend analysis + {error} + + + + + ); + } + + return ( + + + + + + + + Analyse your token usage with PostHog LLM analytics + + + See where your spend goes — by tool, by model, by trace — over the + last 30 days, and get tips on where to optimise. + + + + + + ); +} diff --git a/apps/code/src/renderer/features/billing/hooks/useTokenSpendAnalysis.ts b/apps/code/src/renderer/features/billing/hooks/useTokenSpendAnalysis.ts new file mode 100644 index 000000000..624c37e6a --- /dev/null +++ b/apps/code/src/renderer/features/billing/hooks/useTokenSpendAnalysis.ts @@ -0,0 +1,40 @@ +import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; +import type { SpendAnalysisResponse } from "@features/billing/types/spend-analysis"; +import { logger } from "@utils/logger"; +import { useCallback, useState } from "react"; + +const log = logger.scope("token-spend-analysis"); + +interface UseTokenSpendAnalysisReturn { + data: SpendAnalysisResponse | null; + isLoading: boolean; + error: string | null; + run: (days?: number) => Promise; +} + +export function useTokenSpendAnalysis(): UseTokenSpendAnalysisReturn { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const run = useCallback(async (days: number = 30) => { + setIsLoading(true); + setError(null); + try { + const client = await getAuthenticatedClient(); + if (!client) { + throw new Error("Not authenticated"); + } + const result = await client.getPostHogCodeSpendAnalysis(days); + setData(result); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + log.warn("Failed to fetch spend analysis", { error: message }); + setError(message); + } finally { + setIsLoading(false); + } + }, []); + + return { data, isLoading, error, run }; +} diff --git a/apps/code/src/renderer/features/billing/types/spend-analysis.ts b/apps/code/src/renderer/features/billing/types/spend-analysis.ts new file mode 100644 index 000000000..de1d0c7ab --- /dev/null +++ b/apps/code/src/renderer/features/billing/types/spend-analysis.ts @@ -0,0 +1,43 @@ +export interface SpendAnalysisSummary { + period_days: number; + total_cost_usd: number; + event_count: number; + posthog_code_cost_usd: number; + posthog_code_event_count: number; +} + +export interface SpendAnalysisProductRow { + product: string | null; + event_count: number; + cost_usd: number; +} + +export interface SpendAnalysisToolRow { + tool: string | null; + generation_count: number; + cost_usd: number; + avg_input_tokens: number; +} + +export interface SpendAnalysisModelRow { + model: string | null; + generation_count: number; + cost_usd: number; + input_tokens: number; + output_tokens: number; +} + +export interface SpendAnalysisTraceRow { + trace_id: string | null; + generation_count: number; + cost_usd: number; + started_at: string | null; +} + +export interface SpendAnalysisResponse { + summary: SpendAnalysisSummary; + by_product: SpendAnalysisProductRow[]; + by_tool: SpendAnalysisToolRow[]; + by_model: SpendAnalysisModelRow[]; + top_traces: SpendAnalysisTraceRow[]; +} diff --git a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx index 46ff6a1fb..766927c69 100644 --- a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx +++ b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx @@ -128,7 +128,7 @@ export function SettingsDialog() { const client = useOptionalAuthenticatedClient(); const { data: user } = useCurrentUser({ client }); const { seat, planLabel } = useSeat(); - const billingEnabled = useFeatureFlag(BILLING_FLAG); + const billingEnabled = useFeatureFlag(BILLING_FLAG) || import.meta.env.DEV; const logoutMutation = useLogoutMutation(); const sidebarItems = useMemo( diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx index 1eb3f9753..ea5b9c7bd 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -1,7 +1,9 @@ import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { TokenSpendAnalysisBanner } from "@features/billing/components/TokenSpendAnalysisBanner"; import { useUsage } from "@features/billing/hooks/useUsage"; import { useSeatStore } from "@features/billing/stores/seatStore"; +import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { useSeat } from "@hooks/useSeat"; import type { UsageBucket } from "@main/services/llm-gateway/schemas"; import { @@ -28,6 +30,8 @@ import { useEffect, useState } from "react"; const log = logger.scope("plan-usage"); +const SPEND_ANALYSIS_FLAG = "posthog-code-spend-analysis"; + async function openBillingPage(orgId: string | null): Promise { if (orgId) { try { @@ -75,6 +79,8 @@ export function PlanUsageSettings() { ? (getPostHogUrl(redirectUrl, cloudRegion) ?? billingUrl) : null; const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); + const spendAnalysisEnabled = + useFeatureFlag(SPEND_ANALYSIS_FLAG) || import.meta.env.DEV; const isAlpha = orgSeat?.plan_key === PLAN_PRO_ALPHA; const { @@ -165,6 +171,8 @@ export function PlanUsageSettings() { )} + {spendAnalysisEnabled && } + {hasBetterPlanElsewhere && seat?.organization_name && ( From 40d941f138dde44caea4a0850299ddaa73565172 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 19 May 2026 16:50:46 +0100 Subject: [PATCH 2/5] fix(billing): address greptile review for spend analysis banner - Clear data state on fetch error so error UI is reachable on refresh failures. - Render the model breakdown table that was already typed and fetched but unused. - Remove redundant amount < 1 branch in formatUsd. Generated-By: PostHog Code Task-Id: f9d5d152-49c6-46cf-8fde-079105ba2e67 --- .../components/TokenSpendAnalysisBanner.tsx | 24 ++++++++++++++++++- .../billing/hooks/useTokenSpendAnalysis.ts | 1 + 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx b/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx index 34b413c42..961b6beee 100644 --- a/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx +++ b/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx @@ -1,5 +1,6 @@ import { useTokenSpendAnalysis } from "@features/billing/hooks/useTokenSpendAnalysis"; import type { + SpendAnalysisModelRow, SpendAnalysisProductRow, SpendAnalysisResponse, SpendAnalysisToolRow, @@ -20,7 +21,6 @@ const SKILL_URL = function formatUsd(amount: number): string { if (amount === 0) return "$0"; if (amount < 0.01) return "<$0.01"; - if (amount < 1) return `$${amount.toFixed(2)}`; if (amount < 100) return `$${amount.toFixed(2)}`; return `$${Math.round(amount).toLocaleString()}`; } @@ -186,6 +186,27 @@ function ToolTable({ rows }: { rows: SpendAnalysisToolRow[] }) { ); } +function ModelTable({ rows }: { rows: SpendAnalysisModelRow[] }) { + if (rows.length === 0) return null; + return ( + + {rows.map((r) => ( + + {r.model ?? "(unknown)"} + {r.generation_count.toLocaleString()} + {formatTokens(r.input_tokens)} + {formatTokens(r.output_tokens)} + {formatUsd(r.cost_usd)} + + ))} + + ); +} + function TraceTable({ rows }: { rows: SpendAnalysisTraceRow[] }) { if (rows.length === 0) return null; return ( @@ -310,6 +331,7 @@ export function TokenSpendAnalysisBanner() { + Date: Tue, 19 May 2026 21:18:50 +0100 Subject: [PATCH 3/5] refactor(billing): switch banner to /personal_spend with product filter Renames the client method to getPersonalSpendAnalysis, takes an options object with optional `product` query param, and points at the new /api/llm_analytics/personal_spend/ endpoint. Banner passes `product: "posthog_code"` so the tool / model / trace breakdowns stay PostHog Code-scoped; the by_product breakdown shows the cross-product distribution. SpendAnalysisSummary drops posthog_code_cost_usd / posthog_code_event_count in favour of generic scoped_cost_usd / scoped_event_count fields, matching the backend shape. Hook renamed useTokenSpendAnalysis -> useSpendAnalysis. Generated-By: PostHog Code Task-Id: f9d5d152-49c6-46cf-8fde-079105ba2e67 --- apps/code/src/renderer/api/posthogClient.ts | 18 +++++++++---- .../components/TokenSpendAnalysisBanner.tsx | 27 ++++++++++--------- ...enSpendAnalysis.ts => useSpendAnalysis.ts} | 17 +++++++----- .../features/billing/types/spend-analysis.ts | 5 ++-- 4 files changed, 42 insertions(+), 25 deletions(-) rename apps/code/src/renderer/features/billing/hooks/{useTokenSpendAnalysis.ts => useSpendAnalysis.ts} (73%) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index b0e48fa09..459ca0e2f 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -2856,13 +2856,21 @@ export class PostHogAPIClient { return URL.createObjectURL(blob); } - /** Fetch the requesting user's PostHog Code token spend analysis. */ - async getPostHogCodeSpendAnalysis( - days: number = 30, + /** + * Fetch the requesting user's personal LLM spend analysis. When `product` is + * set the tool / model / trace breakdowns are scoped to that `ai_product` + * (e.g. `posthog_code`); when omitted they aggregate across every product. + */ + async getPersonalSpendAnalysis( + options: { days?: number; product?: string } = {}, ): Promise { - const urlPath = `/api/llm_analytics/posthog_code_spend/`; + const { days = 30, product } = options; + const urlPath = `/api/llm_analytics/personal_spend/`; const url = new URL(`${this.api.baseUrl}${urlPath}`); url.searchParams.set("days", String(days)); + if (product) { + url.searchParams.set("product", product); + } const response = await this.api.fetcher.fetch({ method: "get", url, @@ -2870,7 +2878,7 @@ export class PostHogAPIClient { }); if (!response.ok) { throw new Error( - `Failed to fetch token spend analysis: ${response.statusText}`, + `Failed to fetch personal spend analysis: ${response.statusText}`, ); } return (await response.json()) as SpendAnalysisResponse; diff --git a/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx b/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx index 961b6beee..ffb873ef0 100644 --- a/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx +++ b/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx @@ -1,4 +1,4 @@ -import { useTokenSpendAnalysis } from "@features/billing/hooks/useTokenSpendAnalysis"; +import { useSpendAnalysis } from "@features/billing/hooks/useSpendAnalysis"; import type { SpendAnalysisModelRow, SpendAnalysisProductRow, @@ -54,14 +54,16 @@ function generateSuggestions(data: SpendAnalysisResponse): string[] { } const codeShare = - summary.posthog_code_cost_usd / Math.max(summary.total_cost_usd, 0.0001); + summary.scoped_cost_usd / Math.max(summary.total_cost_usd, 0.0001); if (codeShare > 0.7) { suggestions.push( `PostHog Code is ${Math.round(codeShare * 100)}% of your spend. Other AI products (background agents, posthog_ai) are minor here.`, ); } - const codeTotal = summary.posthog_code_cost_usd; + const codeTotal = summary.scoped_cost_usd; + // codeTotal is the scoped spend (PostHog Code, since the banner always + // requests `product=posthog_code`). if (codeTotal > 0 && by_tool.length > 0) { const top = by_tool[0]; const share = top.cost_usd / codeTotal; @@ -101,21 +103,19 @@ function SummaryRow({ data }: { data: SpendAnalysisResponse }) { const { summary } = data; const codeShare = summary.total_cost_usd > 0 - ? Math.round( - (summary.posthog_code_cost_usd / summary.total_cost_usd) * 100, - ) + ? Math.round((summary.scoped_cost_usd / summary.total_cost_usd) * 100) : 0; return ( @@ -300,7 +300,10 @@ function FooterLinks() { } export function TokenSpendAnalysisBanner() { - const { data, isLoading, error, run } = useTokenSpendAnalysis(); + const { data, isLoading, error, run } = useSpendAnalysis(); + const triggerRun = (): void => { + void run({ days: 30, product: "posthog_code" }); + }; if (data) { const suggestions = generateSuggestions(data); @@ -322,7 +325,7 @@ export function TokenSpendAnalysisBanner() { variant="ghost" disabled={isLoading} onClick={() => { - void run(30); + triggerRun(); }} > {isLoading ? : "Refresh"} @@ -369,7 +372,7 @@ export function TokenSpendAnalysisBanner() { variant="outline" color="red" onClick={() => { - void run(30); + triggerRun(); }} className="self-start" > @@ -400,7 +403,7 @@ export function TokenSpendAnalysisBanner() { variant="solid" disabled={isLoading} onClick={() => { - void run(30); + triggerRun(); }} className="self-start" > diff --git a/apps/code/src/renderer/features/billing/hooks/useTokenSpendAnalysis.ts b/apps/code/src/renderer/features/billing/hooks/useSpendAnalysis.ts similarity index 73% rename from apps/code/src/renderer/features/billing/hooks/useTokenSpendAnalysis.ts rename to apps/code/src/renderer/features/billing/hooks/useSpendAnalysis.ts index e55090f78..d74a79f4f 100644 --- a/apps/code/src/renderer/features/billing/hooks/useTokenSpendAnalysis.ts +++ b/apps/code/src/renderer/features/billing/hooks/useSpendAnalysis.ts @@ -3,21 +3,26 @@ import type { SpendAnalysisResponse } from "@features/billing/types/spend-analys import { logger } from "@utils/logger"; import { useCallback, useState } from "react"; -const log = logger.scope("token-spend-analysis"); +const log = logger.scope("spend-analysis"); -interface UseTokenSpendAnalysisReturn { +interface RunOptions { + days?: number; + product?: string; +} + +interface UseSpendAnalysisReturn { data: SpendAnalysisResponse | null; isLoading: boolean; error: string | null; - run: (days?: number) => Promise; + run: (options?: RunOptions) => Promise; } -export function useTokenSpendAnalysis(): UseTokenSpendAnalysisReturn { +export function useSpendAnalysis(): UseSpendAnalysisReturn { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const run = useCallback(async (days: number = 30) => { + const run = useCallback(async (options: RunOptions = {}) => { setIsLoading(true); setError(null); try { @@ -25,7 +30,7 @@ export function useTokenSpendAnalysis(): UseTokenSpendAnalysisReturn { if (!client) { throw new Error("Not authenticated"); } - const result = await client.getPostHogCodeSpendAnalysis(days); + const result = await client.getPersonalSpendAnalysis(options); setData(result); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; diff --git a/apps/code/src/renderer/features/billing/types/spend-analysis.ts b/apps/code/src/renderer/features/billing/types/spend-analysis.ts index de1d0c7ab..fd78d500c 100644 --- a/apps/code/src/renderer/features/billing/types/spend-analysis.ts +++ b/apps/code/src/renderer/features/billing/types/spend-analysis.ts @@ -1,9 +1,10 @@ export interface SpendAnalysisSummary { period_days: number; + product: string | null; total_cost_usd: number; event_count: number; - posthog_code_cost_usd: number; - posthog_code_event_count: number; + scoped_cost_usd: number; + scoped_event_count: number; } export interface SpendAnalysisProductRow { From 75d19d4422171fbacb7069ccb2dfbe989ee5e542 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 19 May 2026 22:51:28 +0100 Subject: [PATCH 4/5] feat(billing): match backend /personal_spend rename, breakdowns now paginated Mirrors the backend changes: - URL: getPersonalSpendAnalysis now calls /api/llm_analytics/@me/spend/ - Response shape: each breakdown is `{ items, truncated }`; banner reads `.items` and tables stay the same. - SpendAnalysisToolRow gains `share_of_scoped` (float 0-1). Banner suggestions use this directly, no longer divide cost_usd by scoped_cost_usd (which can over-count for multi-tool generations). Generated-By: PostHog Code Task-Id: f9d5d152-49c6-46cf-8fde-079105ba2e67 --- apps/code/src/renderer/api/posthogClient.ts | 2 +- .../components/TokenSpendAnalysisBanner.tsx | 31 ++++++++++--------- .../features/billing/types/spend-analysis.ts | 14 ++++++--- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 459ca0e2f..0047fb417 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -2865,7 +2865,7 @@ export class PostHogAPIClient { options: { days?: number; product?: string } = {}, ): Promise { const { days = 30, product } = options; - const urlPath = `/api/llm_analytics/personal_spend/`; + const urlPath = `/api/llm_analytics/@me/spend/`; const url = new URL(`${this.api.baseUrl}${urlPath}`); url.searchParams.set("days", String(days)); if (product) { diff --git a/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx b/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx index ffb873ef0..237e222b8 100644 --- a/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx +++ b/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx @@ -47,7 +47,9 @@ function formatDate(iso: string | null): string { function generateSuggestions(data: SpendAnalysisResponse): string[] { const suggestions: string[] = []; - const { summary, by_tool, top_traces } = data; + const { summary } = data; + const toolItems = data.by_tool.items; + const traceItems = data.top_traces.items; if (summary.total_cost_usd === 0) { return ["No LLM spend in the selected window."]; @@ -64,24 +66,23 @@ function generateSuggestions(data: SpendAnalysisResponse): string[] { const codeTotal = summary.scoped_cost_usd; // codeTotal is the scoped spend (PostHog Code, since the banner always // requests `product=posthog_code`). - if (codeTotal > 0 && by_tool.length > 0) { - const top = by_tool[0]; - const share = top.cost_usd / codeTotal; - if (share > 0.35 && top.tool) { + if (codeTotal > 0 && toolItems.length > 0) { + const top = toolItems[0]; + if (top.share_of_scoped > 0.35 && top.tool) { suggestions.push( - `${top.tool} drives ${Math.round(share * 100)}% of your PostHog Code spend — averaging ${formatTokens(top.avg_input_tokens)} input tokens per call.`, + `${top.tool} drives ${Math.round(top.share_of_scoped * 100)}% of your PostHog Code spend — averaging ${formatTokens(top.avg_input_tokens)} input tokens per call.`, ); } - const noToolRow = by_tool.find((r) => r.tool === null); - if (noToolRow && noToolRow.cost_usd / codeTotal > 0.1) { + const noToolRow = toolItems.find((r) => r.tool === null); + if (noToolRow && noToolRow.share_of_scoped > 0.1) { suggestions.push( - `${Math.round((noToolRow.cost_usd / codeTotal) * 100)}% is spent on generations that take no tool action — pure text replies. Consider tighter prompts or stopping the agent earlier.`, + `${Math.round(noToolRow.share_of_scoped * 100)}% is spent on generations that take no tool action — pure text replies. Consider tighter prompts or stopping the agent earlier.`, ); } } - if (top_traces.length > 0 && codeTotal > 0) { - const topTrace = top_traces[0]; + if (traceItems.length > 0 && codeTotal > 0) { + const topTrace = traceItems[0]; const share = topTrace.cost_usd / codeTotal; if (share > 0.15) { suggestions.push( @@ -332,10 +333,10 @@ export function TokenSpendAnalysisBanner() { - - - - + + + + { + items: TRow[]; + truncated: boolean; +} + export interface SpendAnalysisResponse { summary: SpendAnalysisSummary; - by_product: SpendAnalysisProductRow[]; - by_tool: SpendAnalysisToolRow[]; - by_model: SpendAnalysisModelRow[]; - top_traces: SpendAnalysisTraceRow[]; + by_product: SpendAnalysisBreakdown; + by_tool: SpendAnalysisBreakdown; + by_model: SpendAnalysisBreakdown; + top_traces: SpendAnalysisBreakdown; } From 2a5721bab00274d7cbb0ca48cb4c93fdeb873af3 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 19 May 2026 23:01:55 +0100 Subject: [PATCH 5/5] refactor(billing): match backend date_from/date_to + summary shape Tracks the backend rename: - Client method now takes `{ dateFrom, dateTo, product }` instead of `{ days, product }`. Maps to the `date_from` / `date_to` query params the backend now accepts. - SpendAnalysisSummary swaps `period_days` for `date_from` / `date_to` ISO strings. - Banner derives the "Window" stat card from the two timestamps. Generated-By: PostHog Code Task-Id: f9d5d152-49c6-46cf-8fde-079105ba2e67 --- apps/code/src/renderer/api/posthogClient.ts | 17 +++++++++++------ .../components/TokenSpendAnalysisBanner.tsx | 14 ++++++++++++-- .../features/billing/hooks/useSpendAnalysis.ts | 3 ++- .../features/billing/types/spend-analysis.ts | 3 ++- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 0047fb417..cfaff490b 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -2857,17 +2857,22 @@ export class PostHogAPIClient { } /** - * Fetch the requesting user's personal LLM spend analysis. When `product` is - * set the tool / model / trace breakdowns are scoped to that `ai_product` - * (e.g. `posthog_code`); when omitted they aggregate across every product. + * Fetch the requesting user's personal LLM spend analysis. `dateFrom` / `dateTo` + * accept absolute dates (`2026-04-23`) or relative strings (`-7d`, `-1m`), and + * default to the last 30 days. When `product` is set the tool / model / trace + * breakdowns are scoped to that `ai_product` (e.g. `posthog_code`); when omitted + * they aggregate across every product. */ async getPersonalSpendAnalysis( - options: { days?: number; product?: string } = {}, + options: { dateFrom?: string; dateTo?: string; product?: string } = {}, ): Promise { - const { days = 30, product } = options; + const { dateFrom = "-30d", dateTo, product } = options; const urlPath = `/api/llm_analytics/@me/spend/`; const url = new URL(`${this.api.baseUrl}${urlPath}`); - url.searchParams.set("days", String(days)); + url.searchParams.set("date_from", dateFrom); + if (dateTo) { + url.searchParams.set("date_to", dateTo); + } if (product) { url.searchParams.set("product", product); } diff --git a/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx b/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx index 237e222b8..bbcb37b34 100644 --- a/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx +++ b/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx @@ -37,6 +37,13 @@ function formatTrace(traceId: string | null): string { return `${traceId.slice(0, 8)}…${traceId.slice(-4)}`; } +function formatWindow(fromIso: string, toIso: string): string { + const fromMs = new Date(fromIso).getTime(); + const toMs = new Date(toIso).getTime(); + const days = Math.max(1, Math.round((toMs - fromMs) / (1000 * 60 * 60 * 24))); + return `${days} days`; +} + function formatDate(iso: string | null): string { if (!iso) return "—"; return new Date(iso).toLocaleDateString(undefined, { @@ -118,7 +125,10 @@ function SummaryRow({ data }: { data: SpendAnalysisResponse }) { label="Generations" value={summary.scoped_event_count.toLocaleString()} /> - + ); } @@ -303,7 +313,7 @@ function FooterLinks() { export function TokenSpendAnalysisBanner() { const { data, isLoading, error, run } = useSpendAnalysis(); const triggerRun = (): void => { - void run({ days: 30, product: "posthog_code" }); + void run({ dateFrom: "-30d", product: "posthog_code" }); }; if (data) { diff --git a/apps/code/src/renderer/features/billing/hooks/useSpendAnalysis.ts b/apps/code/src/renderer/features/billing/hooks/useSpendAnalysis.ts index d74a79f4f..99ad12267 100644 --- a/apps/code/src/renderer/features/billing/hooks/useSpendAnalysis.ts +++ b/apps/code/src/renderer/features/billing/hooks/useSpendAnalysis.ts @@ -6,7 +6,8 @@ import { useCallback, useState } from "react"; const log = logger.scope("spend-analysis"); interface RunOptions { - days?: number; + dateFrom?: string; + dateTo?: string; product?: string; } diff --git a/apps/code/src/renderer/features/billing/types/spend-analysis.ts b/apps/code/src/renderer/features/billing/types/spend-analysis.ts index 70a3e0f3f..63ceef9d9 100644 --- a/apps/code/src/renderer/features/billing/types/spend-analysis.ts +++ b/apps/code/src/renderer/features/billing/types/spend-analysis.ts @@ -1,5 +1,6 @@ export interface SpendAnalysisSummary { - period_days: number; + date_from: string; + date_to: string; product: string | null; total_cost_usd: number; event_count: number;