diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 4b41f5d7b..2006cbf17 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 { @@ -2855,4 +2856,35 @@ export class PostHogAPIClient { const blob = await response.blob(); return URL.createObjectURL(blob); } + + /** + * 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: { dateFrom?: string; dateTo?: string; product?: string } = {}, + ): Promise { + const { dateFrom = "-30d", dateTo, product } = options; + const urlPath = `/api/llm_analytics/@me/spend/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + url.searchParams.set("date_from", dateFrom); + if (dateTo) { + url.searchParams.set("date_to", dateTo); + } + if (product) { + url.searchParams.set("product", product); + } + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error(`Failed to fetch spend analysis: ${response.status}`); + } + 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..bbcb37b34 --- /dev/null +++ b/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx @@ -0,0 +1,428 @@ +import { useSpendAnalysis } from "@features/billing/hooks/useSpendAnalysis"; +import type { + SpendAnalysisModelRow, + 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 < 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 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, { + month: "short", + day: "numeric", + }); +} + +function generateSuggestions(data: SpendAnalysisResponse): string[] { + const suggestions: string[] = []; + 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."]; + } + + const codeShare = + 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.scoped_cost_usd; + // codeTotal is the scoped spend (PostHog Code, since the banner always + // requests `product=posthog_code`). + 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(top.share_of_scoped * 100)}% of your PostHog Code spend — averaging ${formatTokens(top.avg_input_tokens)} input tokens per call.`, + ); + } + const noToolRow = toolItems.find((r) => r.tool === null); + if (noToolRow && noToolRow.share_of_scoped > 0.1) { + suggestions.push( + `${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 (traceItems.length > 0 && codeTotal > 0) { + const topTrace = traceItems[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.scoped_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 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 ( + + {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 } = useSpendAnalysis(); + const triggerRun = (): void => { + void run({ dateFrom: "-30d", product: "posthog_code" }); + }; + + 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/useSpendAnalysis.ts b/apps/code/src/renderer/features/billing/hooks/useSpendAnalysis.ts new file mode 100644 index 000000000..99ad12267 --- /dev/null +++ b/apps/code/src/renderer/features/billing/hooks/useSpendAnalysis.ts @@ -0,0 +1,47 @@ +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("spend-analysis"); + +interface RunOptions { + dateFrom?: string; + dateTo?: string; + product?: string; +} + +interface UseSpendAnalysisReturn { + data: SpendAnalysisResponse | null; + isLoading: boolean; + error: string | null; + run: (options?: RunOptions) => Promise; +} + +export function useSpendAnalysis(): UseSpendAnalysisReturn { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const run = useCallback(async (options: RunOptions = {}) => { + setIsLoading(true); + setError(null); + try { + const client = await getAuthenticatedClient(); + if (!client) { + throw new Error("Not authenticated"); + } + const result = await client.getPersonalSpendAnalysis(options); + setData(result); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + log.warn("Failed to fetch spend analysis", { error: message }); + setData(null); + 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..63ceef9d9 --- /dev/null +++ b/apps/code/src/renderer/features/billing/types/spend-analysis.ts @@ -0,0 +1,51 @@ +export interface SpendAnalysisSummary { + date_from: string; + date_to: string; + product: string | null; + total_cost_usd: number; + event_count: number; + scoped_cost_usd: number; + scoped_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; + share_of_scoped: 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 SpendAnalysisBreakdown { + items: TRow[]; + truncated: boolean; +} + +export interface SpendAnalysisResponse { + summary: SpendAnalysisSummary; + by_product: SpendAnalysisBreakdown; + by_tool: SpendAnalysisBreakdown; + by_model: SpendAnalysisBreakdown; + top_traces: SpendAnalysisBreakdown; +} 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 && (