diff --git a/packages/api-client/src/spend-analysis.ts b/packages/api-client/src/spend-analysis.ts index b45e0dab83..a289ca389d 100644 --- a/packages/api-client/src/spend-analysis.ts +++ b/packages/api-client/src/spend-analysis.ts @@ -30,6 +30,12 @@ export interface SpendAnalysisModelRow { output_tokens: number; } +export interface SpendAnalysisDayRow { + day: string; + event_count: number; + cost_usd: number; +} + export interface SpendAnalysisBreakdown { items: TRow[]; truncated: boolean; @@ -40,6 +46,8 @@ export interface SpendAnalysisResponse { by_product: SpendAnalysisBreakdown; by_tool: SpendAnalysisBreakdown; by_model: SpendAnalysisBreakdown; + // Optional until the backend by_day rollout reaches every deployment. + by_day?: SpendAnalysisBreakdown; // `top_traces` is still in the backend response shape (always empty) per // posthog/posthog#59796. Renderer code does not consume it; left out of the // TS type so future readers see only what we actually use. diff --git a/packages/core/src/billing/spendAnalysisFormat.test.ts b/packages/core/src/billing/spendAnalysisFormat.test.ts index 2844b75feb..c4891949ce 100644 --- a/packages/core/src/billing/spendAnalysisFormat.test.ts +++ b/packages/core/src/billing/spendAnalysisFormat.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { formatTokens } from "./spendAnalysisFormat"; +import { + fillSpendDays, + formatTokens, + type SpendAnalysisWindow, + windowToDateFrom, + windowToDays, +} from "./spendAnalysisFormat"; describe("formatTokens", () => { it.each([ @@ -15,3 +21,67 @@ describe("formatTokens", () => { expect(formatTokens(input)).toBe(expected); }); }); + +describe("windowToDateFrom", () => { + it.each<[SpendAnalysisWindow, string]>([ + ["7d", "-6dStart"], + ["30d", "-29dStart"], + ["90d", "-89dStart"], + ])("maps %s to the day-aligned %s", (window, expected) => { + expect(windowToDateFrom(window)).toBe(expected); + }); +}); + +describe("windowToDays", () => { + it.each<[SpendAnalysisWindow, number]>([ + ["7d", 7], + ["30d", 30], + ["90d", 90], + ])("maps %s to %d", (window, expected) => { + expect(windowToDays(window)).toBe(expected); + }); +}); + +describe("fillSpendDays", () => { + it("zero-fills days without rows across the window", () => { + const filled = fillSpendDays( + [ + { day: "2026-07-01", event_count: 3, cost_usd: 1.5 }, + { day: "2026-07-03", event_count: 1, cost_usd: 0.25 }, + ], + "2026-07-01T00:00:00Z", + "2026-07-04T12:00:00Z", + ); + expect(filled).toEqual([ + { day: "2026-07-01", event_count: 3, cost_usd: 1.5 }, + { day: "2026-07-02", event_count: 0, cost_usd: 0 }, + { day: "2026-07-03", event_count: 1, cost_usd: 0.25 }, + { day: "2026-07-04", event_count: 0, cost_usd: 0 }, + ]); + }); + + it("returns one zeroed row per day when there are no rows", () => { + const filled = fillSpendDays( + [], + "2026-07-01T06:00:00Z", + "2026-07-03T00:00:00Z", + ); + expect(filled.map((d) => d.day)).toEqual([ + "2026-07-01", + "2026-07-02", + "2026-07-03", + ]); + expect(filled.every((d) => d.cost_usd === 0 && d.event_count === 0)).toBe( + true, + ); + }); + + it("caps runaway windows instead of looping unbounded", () => { + const filled = fillSpendDays( + [], + "2020-01-01T00:00:00Z", + "2026-01-01T00:00:00Z", + ); + expect(filled.length).toBe(100); + }); +}); diff --git a/packages/core/src/billing/spendAnalysisFormat.ts b/packages/core/src/billing/spendAnalysisFormat.ts index cafcc3c8b6..14dff94430 100644 --- a/packages/core/src/billing/spendAnalysisFormat.ts +++ b/packages/core/src/billing/spendAnalysisFormat.ts @@ -1,3 +1,5 @@ +import type { SpendAnalysisDayRow } from "./spendAnalysisTypes"; + export function formatUsd(amount: number): string { if (amount === 0) return "$0"; if (amount < 0.01) return "<$0.01"; @@ -12,12 +14,64 @@ export function formatTokens(n: number): string { return n.toString(); } +export type SpendAnalysisWindow = "7d" | "30d" | "90d"; + +// Day-aligned (`dStart`) so an N-day window resolves to exactly N UTC calendar +// days including today, giving the daily chart exactly N bars. +export function windowToDateFrom(window: SpendAnalysisWindow): string { + return `-${windowToDays(window) - 1}dStart`; +} + +export function windowToDays(window: SpendAnalysisWindow): number { + return Number.parseInt(window, 10); +} + export function windowDays(fromIso: string, toIso: string): number { const fromMs = new Date(fromIso).getTime(); const toMs = new Date(toIso).getTime(); - return Math.max(1, Math.round((toMs - fromMs) / (1000 * 60 * 60 * 24))); + // Ceil: a day-aligned window ending mid-day still covers N calendar days. + return Math.max(1, Math.ceil((toMs - fromMs) / DAY_MS)); } export function formatWindow(fromIso: string, toIso: string): string { return `${windowDays(fromIso, toIso)} days`; } + +const DAY_MS = 86_400_000; +const MAX_FILLED_DAYS = 100; + +export interface SpendAnalysisFilledDay { + day: string; + event_count: number; + cost_usd: number; +} + +export function fillSpendDays( + items: SpendAnalysisDayRow[], + fromIso: string, + toIso: string, +): SpendAnalysisFilledDay[] { + const byDay = new Map(items.map((row) => [row.day, row])); + const from = new Date(fromIso); + const start = Date.UTC( + from.getUTCFullYear(), + from.getUTCMonth(), + from.getUTCDate(), + ); + const end = new Date(toIso).getTime(); + const filled: SpendAnalysisFilledDay[] = []; + for ( + let t = start; + t <= end && filled.length < MAX_FILLED_DAYS; + t += DAY_MS + ) { + const day = new Date(t).toISOString().slice(0, 10); + const row = byDay.get(day); + filled.push({ + day, + event_count: row?.event_count ?? 0, + cost_usd: row?.cost_usd ?? 0, + }); + } + return filled; +} diff --git a/packages/core/src/billing/spendAnalysisPrompt.test.ts b/packages/core/src/billing/spendAnalysisPrompt.test.ts index ffa1ef592c..97cbe7c323 100644 --- a/packages/core/src/billing/spendAnalysisPrompt.test.ts +++ b/packages/core/src/billing/spendAnalysisPrompt.test.ts @@ -188,7 +188,7 @@ describe("buildAnalysisPrompt", () => { it("instructs the agent not to query external data", () => { const prompt = buildAnalysisPrompt(makeResponse()); expect(prompt).toContain( - "do **not** try to query PostHog AI observability or any external data source", + "Do **not** try to query PostHog AI observability or any external data source", ); }); }); diff --git a/packages/core/src/billing/spendAnalysisPrompt.ts b/packages/core/src/billing/spendAnalysisPrompt.ts index 8ada318cd4..c81dc12537 100644 --- a/packages/core/src/billing/spendAnalysisPrompt.ts +++ b/packages/core/src/billing/spendAnalysisPrompt.ts @@ -41,13 +41,13 @@ const PLAYBOOK = `## What to look at Use this playbook to interpret the numbers above. Apply the levers in order of impact; not every lever applies to every user. -1. **Input tokens are the bill, not the tool calls themselves.** "Avg input" per tool is the context size dragged along on every call. A tool being expensive almost never means the tool itself is expensive — it means there were many calls each carrying a fat context. The biggest lever is conversation length, not which tool gets called: compact aggressively at logical checkpoints, start fresh sessions for unrelated tasks, avoid backtracking ("actually try X instead") because that re-runs all the prior context plus the alternative. +1. **Input tokens are the bill, not the tool calls themselves.** "Avg input" per tool is the context size dragged along on every call. A tool being expensive almost never means the tool itself is expensive. It means there were many calls each carrying a fat context. The biggest lever is conversation length, not which tool gets called: compact aggressively at logical checkpoints, start fresh sessions for unrelated tasks, avoid backtracking ("actually try X instead") because that re-runs all the prior context plus the alternative. 2. **Model choice.** Look at the "By model" table. If most generations are on the most expensive available model, switching the default to a mid-tier model and only escalating for genuinely hard reasoning is often the single biggest dollar saver. The cheapest tier is essentially free per call for routine work (run a test, check git status, grep for a string). -3. **Subagent hygiene.** The Agent / subagent tool typically has a high avg input because subagents inherit a brief plus the tool registry. They're worth their cost when they protect the main conversation from a long exploration; they're not worth it for "read one file" or "grep one pattern" — use the direct tool. +3. **Subagent hygiene.** The Agent / subagent tool typically has a high avg input because subagents inherit a brief plus the tool registry. They're worth their cost when they protect the main conversation from a long exploration; they're not worth it for "read one file" or "grep one pattern". Use the direct tool for those. -4. **No-tool replies.** If the "By tool" table has a "(no tool)" row, that's the model replying with pure text — no action. Some of that is unavoidable (answering a question), some is the model thinking out loud or asking clarifying questions when it could just act. If this share is greater than ~10% of spend, more directive prompts ("Just do X" instead of "What do you think about X?") cut a round-trip per task. +4. **No-tool replies.** If the "By tool" table has a "(no tool)" row, that's the model replying with pure text, no action. Some of that is unavoidable (answering a question), some is the model thinking out loud or asking clarifying questions when it could just act. If this share is greater than ~10% of spend, more directive prompts ("Just do X" instead of "What do you think about X?") cut a round-trip per task. 5. **MCP / tool-registry overhead.** Tool calls that route through MCP (or any plugin layer that ships a tool registry on every turn) often show inflated avg input. If the user has many MCP servers enabled, pruning the ones they don't use shrinks the per-call overhead. @@ -93,7 +93,7 @@ export function buildAnalysisPrompt(data: SpendAnalysisResponse): string { return `Here is my PostHog Code LLM spend for the last ${windowLabel}. Help me understand what's driving the cost and what concrete changes I should make to reduce it. -Work only from the tables below — do **not** try to query PostHog AI observability or any external data source. The numbers here are everything you have. Rank advice by impact, lead with the biggest lever, and keep each suggestion concrete and actionable. +Work only from the tables below. Do **not** try to query PostHog AI observability or any external data source. The numbers here are everything you have. Rank advice by impact, lead with the biggest lever, and keep each suggestion concrete and actionable. ## My spend diff --git a/packages/core/src/billing/spendAnalysisTypes.ts b/packages/core/src/billing/spendAnalysisTypes.ts index e8220111bf..36ae669812 100644 --- a/packages/core/src/billing/spendAnalysisTypes.ts +++ b/packages/core/src/billing/spendAnalysisTypes.ts @@ -30,6 +30,12 @@ export interface SpendAnalysisModelRow { output_tokens: number; } +export interface SpendAnalysisDayRow { + day: string; + event_count: number; + cost_usd: number; +} + export interface SpendAnalysisBreakdown { items: TRow[]; truncated: boolean; @@ -40,4 +46,6 @@ export interface SpendAnalysisResponse { by_product: SpendAnalysisBreakdown; by_tool: SpendAnalysisBreakdown; by_model: SpendAnalysisBreakdown; + // Optional until the backend by_day rollout reaches every deployment. + by_day?: SpendAnalysisBreakdown; } diff --git a/packages/core/src/billing/spendSuggestions.ts b/packages/core/src/billing/spendSuggestions.ts index 4857855450..9b0a818672 100644 --- a/packages/core/src/billing/spendSuggestions.ts +++ b/packages/core/src/billing/spendSuggestions.ts @@ -23,20 +23,20 @@ export function deriveSpendSuggestions(data: SpendAnalysisResponse): string[] { 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.`, + `${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.`, + `${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 (suggestions.length === 0) { suggestions.push( - "Your spend is fairly evenly distributed across tools — no single hotspot stands out.", + "Your spend is fairly evenly distributed across tools. No single hotspot stands out.", ); } diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index ddf9db77c4..d0367d357b 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -626,6 +626,14 @@ export interface InboxReportScrolledProperties { time_since_open_ms: number; } +export interface UsageViewedProperties { + is_pro: boolean; + /** Monthly bucket percent (0-100), null when usage is unavailable. */ + sustained_used_percent: number | null; + /** Daily bucket percent (0-100), null when usage is unavailable. */ + burst_used_percent: number | null; +} + export interface SpendAnalysisTaskOpenedProperties { /** Total LLM spend in USD across all products for the analysed window. */ total_cost_usd: number; @@ -1129,7 +1137,8 @@ export const ANALYTICS_EVENTS = { SCOUT_CHAT_STARTED: "Scout chat started", SCOUT_ACTION: "Scout action", - // Spend analysis events + // Usage and spend analysis events + USAGE_VIEWED: "Usage viewed", SPEND_ANALYSIS_TASK_OPENED: "Spend analysis task opened", // Prompt history events @@ -1281,7 +1290,8 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.SCOUT_CHAT_STARTED]: ScoutChatStartedProperties; [ANALYTICS_EVENTS.SCOUT_ACTION]: ScoutActionProperties; - // Spend analysis events + // Usage and spend analysis events + [ANALYTICS_EVENTS.USAGE_VIEWED]: UsageViewedProperties; [ANALYTICS_EVENTS.SPEND_ANALYSIS_TASK_OPENED]: SpendAnalysisTaskOpenedProperties; // Prompt history events diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index a74f368588..1a36acf9fa 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -1,4 +1,5 @@ export const BILLING_FLAG = "posthog-code-billing"; +export const SPEND_ANALYSIS_FLAG = "posthog-code-spend-analysis"; export const EXPERIMENT_SUGGESTIONS_FLAG = "posthog-code-experiment-suggestions"; export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; diff --git a/packages/ui/src/features/billing/TokenSpendAnalysisBanner.tsx b/packages/ui/src/features/billing/TokenSpendAnalysisBanner.tsx deleted file mode 100644 index 48f725b0b4..0000000000 --- a/packages/ui/src/features/billing/TokenSpendAnalysisBanner.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import { - ArrowSquareOut, - ChartLine, - Lightning, - Sparkle, - WarningCircle, -} from "@phosphor-icons/react"; -import { - formatTokens, - formatUsd, - formatWindow, - windowDays, -} from "@posthog/core/billing/spendAnalysisFormat"; -import { buildAnalysisPrompt } from "@posthog/core/billing/spendAnalysisPrompt"; -import type { - SpendAnalysisModelRow, - SpendAnalysisProductRow, - SpendAnalysisResponse, - SpendAnalysisToolRow, -} from "@posthog/core/billing/spendAnalysisTypes"; -import { deriveSpendSuggestions } from "@posthog/core/billing/spendSuggestions"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { useSpendAnalysis } from "@posthog/ui/features/billing/useSpendAnalysis"; -import { closeSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; -import { openTaskInput } from "@posthog/ui/router/useOpenTask"; -import { track } from "@posthog/ui/shell/analytics"; -import { Button, Callout, Flex, Spinner, Table, Text } from "@radix-ui/themes"; - -const DOCS_URL = "https://posthog.com/docs/ai-observability"; - -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 SectionTable({ - title, - headers, - widths, - children, -}: { - title: string; - headers: string[]; - widths: string[]; - children: React.ReactNode; -}) { - return ( - - {title} - - - - {headers.map((h, i) => ( - - {h} - - ))} - - - {children} - - - ); -} - -function FooterLinks({ data }: { data: SpendAnalysisResponse }) { - const handleAnalyseClick = (): void => { - track(ANALYTICS_EVENTS.SPEND_ANALYSIS_TASK_OPENED, { - total_cost_usd: data.summary.total_cost_usd, - scoped_cost_usd: data.summary.scoped_cost_usd, - scoped_event_count: data.summary.scoped_event_count, - window_days: windowDays(data.summary.date_from, data.summary.date_to), - tool_row_count: Math.min(data.by_tool.items.length, 10), - model_row_count: data.by_model.items.length, - }); - // This banner lives inside the Settings dialog (modal). `navigateToTaskInput` - // changes the underlying view but the dialog stays mounted on top, so the user - // doesn't see the prefilled task input. Close the dialog first. - closeSettings(); - openTaskInput({ - initialPrompt: buildAnalysisPrompt(data), - }); - }; - - return ( - - - Use{" "} - - PostHog AI observability - {" "} - in your own project for the full slice-and-dice experience. - - - - ); -} - -export function TokenSpendAnalysisBanner() { - const { data, isLoading, error, run } = useSpendAnalysis(); - const triggerRun = (): void => { - void run({ dateFrom: "-30d", product: "posthog_code" }); - }; - - if (data) { - const suggestions = deriveSpendSuggestions(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 AI observability - - - See where your spend goes — by product, tool, and model — over the - last 30 days, and get tips on where to optimise. - - - - - - ); -} diff --git a/packages/ui/src/features/billing/UsageMeter.tsx b/packages/ui/src/features/billing/UsageMeter.tsx new file mode 100644 index 0000000000..65bb71ddd9 --- /dev/null +++ b/packages/ui/src/features/billing/UsageMeter.tsx @@ -0,0 +1,40 @@ +import { formatResetTime } from "@posthog/core/billing/usageDisplay"; +import type { UsageBucket } from "@posthog/core/usage/schemas"; +import { Flex, Progress, Text } from "@radix-ui/themes"; + +interface UsageMeterProps { + label: string; + bucket: UsageBucket; + color?: "red"; +} + +export function UsageMeter({ label, bucket, color }: UsageMeterProps) { + const percentage = bucket.used_percent; + + const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)"; + + return ( + + + {label} + {percentage.toFixed(2)}% + + + + {`${bucket.exceeded ? "Limit exceeded. " : ""}${formatResetTime(bucket.reset_at)}`} + + + ); +} diff --git a/packages/ui/src/features/billing/useSpendAnalysis.ts b/packages/ui/src/features/billing/useSpendAnalysis.ts deleted file mode 100644 index 4aad13e7d1..0000000000 --- a/packages/ui/src/features/billing/useSpendAnalysis.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { SpendAnalysisResponse } from "@posthog/api-client/spend-analysis"; -import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; -import { logger } from "@posthog/ui/shell/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 client = useOptionalAuthenticatedClient(); - 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 { - 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); - } - }, - [client], - ); - - return { data, isLoading, error, run }; -} diff --git a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx index 9f20e8141a..ae09e510b6 100644 --- a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx +++ b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx @@ -4,20 +4,18 @@ import { Info, WarningCircle, } from "@phosphor-icons/react"; -import { - formatResetTime, - PRO_USAGE_MULTIPLIER, -} from "@posthog/core/billing/usageDisplay"; -import type { UsageBucket } from "@posthog/core/usage/schemas"; +import { PRO_USAGE_MULTIPLIER } from "@posthog/core/billing/usageDisplay"; import { PLAN_PRO_ALPHA } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { useSwitchOrgMutation } from "@posthog/ui/features/auth/useAuthMutations"; import { useSeatStore } from "@posthog/ui/features/billing/seatStore"; -import { TokenSpendAnalysisBanner } from "@posthog/ui/features/billing/TokenSpendAnalysisBanner"; +import { UsageMeter } from "@posthog/ui/features/billing/UsageMeter"; import { useSeat } from "@posthog/ui/features/billing/useSeat"; import { useUsage } from "@posthog/ui/features/billing/useUsage"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { closeSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; +import { useSpendAnalysisEnabled } from "@posthog/ui/features/usage/useSpendAnalysisEnabled"; +import { navigateToUsage } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; import { logger } from "@posthog/ui/shell/logger"; import { getBillingUrl, getPostHogUrl } from "@posthog/ui/utils/urls"; @@ -27,7 +25,6 @@ import { Callout, Dialog, Flex, - Progress, Spinner, Text, } from "@radix-ui/themes"; @@ -35,8 +32,6 @@ import { useEffect, useState } from "react"; const log = logger.scope("plan-usage"); -const SPEND_ANALYSIS_FLAG = "posthog-code-spend-analysis"; - export function PlanUsageSettings() { const { seat, @@ -85,8 +80,7 @@ export function PlanUsageSettings() { ? (getPostHogUrl(redirectUrl, cloudRegion) ?? billingUrl) : null; const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); - const spendAnalysisEnabled = - useFeatureFlag(SPEND_ANALYSIS_FLAG) || import.meta.env.DEV; + const spendAnalysisEnabled = useSpendAnalysisEnabled(); const isAlpha = orgSeat?.plan_key === PLAN_PRO_ALPHA; const { @@ -185,8 +179,6 @@ export function PlanUsageSettings() { )} - {spendAnalysisEnabled && } - {hasBetterPlanElsewhere && seat?.organization_name && ( @@ -330,7 +322,22 @@ export function PlanUsageSettings() { )} - Usage + + Usage + {spendAnalysisEnabled && ( + + )} + {usageLoading ? ( - - {label} - {percentage.toFixed(2)}% - - - - {bucket.exceeded ? "Limit exceeded" : formatResetTime(bucket.reset_at)} - - - ); -} - interface PlanCardProps { name: string; price: string; diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx index 211d722d96..77162f0759 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx @@ -4,6 +4,7 @@ import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFla import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { useSpendAnalysisEnabled } from "@posthog/ui/features/usage/useSpendAnalysisEnabled"; import { navigateToAgents, navigateToCommandCenter, @@ -11,6 +12,7 @@ import { navigateToInbox, navigateToMcpServers, navigateToSkills, + navigateToUsage, navigateToWebsiteCommandCenter, navigateToWebsiteHome, navigateToWebsiteMcpServers, @@ -29,6 +31,7 @@ import { McpServersItem } from "./items/McpServersItem"; import { NewTaskItem } from "./items/NewTaskItem"; import { SearchItem } from "./items/SearchItem"; import { SkillsItem } from "./items/SkillsItem"; +import { UsageItem } from "./items/UsageItem"; const SIDEBAR_INBOX_REFETCH_INTERVAL_MS = 60_000; @@ -46,18 +49,19 @@ interface SidebarNavSectionProps { // state, badge count, and click handler is wired here — so it can be dropped // into either layout. In the Channels space, destinations with a /website // mirror (Home, Skills, MCP servers, Command Center) stay in that space; -// Inbox, Agents and New task have no mirror yet and jump back to Code. Search -// opens the command menu in place. +// Inbox, Agents, Usage and New task have no mirror yet and jump back to Code. +// Search opens the command menu in place. export function SidebarNavSection({ commandCenterActiveCount: providedActiveCount, }: SidebarNavSectionProps = {}) { const view = useAppView(); const homeTabEnabled = useFeatureFlag(HOME_TAB_FLAG); + const usageEnabled = useSpendAnalysisEnabled(); // When this section renders inside the Channels space, the destinations that // have a /website mirror stay in that space; everything else (and the whole - // section in the Code space) uses the canonical routes. Inbox, Agents and New - // task have no mirror yet, so they intentionally jump back to Code. + // section in the Code space) uses the canonical routes. Inbox, Agents, Usage + // and New task have no mirror yet, so they intentionally jump back to Code. const inChannels = useRouterState({ select: (s) => s.location.pathname.startsWith("/website"), }); @@ -82,6 +86,7 @@ export function SidebarNavSection({ const isCommandCenterActive = view.type === "command-center"; const isSkillsActive = view.type === "skills"; const isMcpServersActive = view.type === "mcp-servers"; + const isUsageActive = view.type === "usage"; // Open pull requests in the inbox — the main CTA, and the same count the inbox // Pull requests tab shows, so the badge and the tab always agree. @@ -152,13 +157,19 @@ export function SidebarNavSection({ - + + + {usageEnabled && ( + + + + )} ); } diff --git a/packages/ui/src/features/sidebar/components/items/UsageItem.tsx b/packages/ui/src/features/sidebar/components/items/UsageItem.tsx new file mode 100644 index 0000000000..a634a71741 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/items/UsageItem.tsx @@ -0,0 +1,19 @@ +import { ChartLine } from "@phosphor-icons/react"; +import { SidebarItem } from "../SidebarItem"; + +interface UsageItemProps { + isActive: boolean; + onClick: () => void; +} + +export function UsageItem({ isActive, onClick }: UsageItemProps) { + return ( + } + label="Usage" + isActive={isActive} + onClick={onClick} + /> + ); +} diff --git a/packages/ui/src/features/usage/UsageView.tsx b/packages/ui/src/features/usage/UsageView.tsx new file mode 100644 index 0000000000..8fbe216c8d --- /dev/null +++ b/packages/ui/src/features/usage/UsageView.tsx @@ -0,0 +1,246 @@ +import { ChartLine, CreditCard, WarningCircle } from "@phosphor-icons/react"; +import { + fillSpendDays, + type SpendAnalysisWindow, +} from "@posthog/core/billing/spendAnalysisFormat"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@posthog/quill"; +import { BILLING_FLAG } from "@posthog/shared"; +import { UsageMeter } from "@posthog/ui/features/billing/UsageMeter"; +import { useSeat } from "@posthog/ui/features/billing/useSeat"; +import { useUsage } from "@posthog/ui/features/billing/useUsage"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { + Badge, + Box, + Button, + Callout, + Flex, + ScrollArea, + Spinner, + Text, +} from "@radix-ui/themes"; +import { useMemo, useState } from "react"; +import { ModelBreakdownCards } from "./components/ModelBreakdownCards"; +import { + ProductBreakdownCard, + ToolBreakdownCard, +} from "./components/SpendBreakdownTables"; +import { SpendInsights } from "./components/SpendInsights"; +import { SpendKpiStrip } from "./components/SpendKpiStrip"; +import { SpendOverTimeCard } from "./components/SpendOverTimeCard"; +import { UsageCard } from "./components/UsageCard"; +import { WindowSelector } from "./components/WindowSelector"; +import { useSpendAnalysis } from "./useSpendAnalysis"; +import { useSpendAnalysisEnabled } from "./useSpendAnalysisEnabled"; +import { useTrackUsageViewed } from "./useTrackUsageViewed"; + +const PRODUCT_SCOPE = "posthog_code"; + +export function UsageView() { + const headerContent = useMemo( + () => ( + + + + Usage + + + ), + [], + ); + useSetHeaderContent(headerContent); + + const billingEnabled = useFeatureFlag(BILLING_FLAG); + const spendAnalysisEnabled = useSpendAnalysisEnabled(); + + const { seat, isPro, planLabel, isLoading: seatLoading } = useSeat(); + const { usage, isLoading: usageLoading } = useUsage({ + enabled: billingEnabled && seat !== null, + }); + + const [spendWindow, setSpendWindow] = useState("30d"); + const { data, isLoading, isFetching, error, refetch } = useSpendAnalysis({ + window: spendWindow, + product: PRODUCT_SCOPE, + }); + + const filledDays = useMemo(() => { + if (!data?.by_day) return null; + return fillSpendDays( + data.by_day.items, + data.summary.date_from, + data.summary.date_to, + ); + }, [data]); + + useTrackUsageViewed({ + isLoading: billingEnabled && (seatLoading || usageLoading), + isPro, + sustainedUsedPercent: usage?.sustained.used_percent ?? null, + burstUsedPercent: usage?.burst.used_percent ?? null, + }); + + if (!billingEnabled && !spendAnalysisEnabled) { + return ( + + + + + + + Usage isn't available + + Usage reporting isn't enabled for your account yet. + + + + + ); + } + + return ( + + + + + + + Usage + + + Token spend and plan limits for PostHog Code + + + + {spendAnalysisEnabled && ( + + + + + )} + + + {spendAnalysisEnabled && + (error ? ( + + + + + + + + Couldn't load spend analysis + + + {error} + + + + + + ) : isLoading ? ( + + + + ) : data ? ( + <> + + {filledDays && } + + + ) : null)} + + {spendAnalysisEnabled && data && ( + <> +
+ + +
+ + + )} + + {billingEnabled && ( + } + title="Plan limits" + actions={ + + {seat !== null && ( + + {planLabel} + + )} + + + } + > + {seatLoading || usageLoading ? ( + + + + ) : usage ? ( +
+ + +
+ ) : ( + + Unable to load usage data + + )} +
+ )} +
+
+
+ ); +} diff --git a/packages/ui/src/features/usage/components/ModelBreakdownCards.tsx b/packages/ui/src/features/usage/components/ModelBreakdownCards.tsx new file mode 100644 index 0000000000..9b03a9906f --- /dev/null +++ b/packages/ui/src/features/usage/components/ModelBreakdownCards.tsx @@ -0,0 +1,78 @@ +import { Robot } from "@phosphor-icons/react"; +import { + formatTokens, + formatUsd, +} from "@posthog/core/billing/spendAnalysisFormat"; +import type { SpendAnalysisModelRow } from "@posthog/core/billing/spendAnalysisTypes"; +import { Flex, Text } from "@radix-ui/themes"; +import { UsageCard } from "./UsageCard"; + +function ModelStat({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +interface ModelBreakdownCardsProps { + rows: SpendAnalysisModelRow[]; + scopedCostUsd: number; +} + +export function ModelBreakdownCards({ + rows, + scopedCostUsd, +}: ModelBreakdownCardsProps) { + if (rows.length === 0) return null; + return ( + } + title="Cost by model" + > +
+ {rows.map((row) => { + const share = + scopedCostUsd > 0 + ? Math.round((row.cost_usd / scopedCostUsd) * 100) + : 0; + return ( + + + + {row.model ?? "(unknown)"} + + + + {formatUsd(row.cost_usd)} + + +
+ + + + +
+
+ ); + })} +
+
+ ); +} diff --git a/packages/ui/src/features/usage/components/SpendBreakdownTables.tsx b/packages/ui/src/features/usage/components/SpendBreakdownTables.tsx new file mode 100644 index 0000000000..05eacfe81b --- /dev/null +++ b/packages/ui/src/features/usage/components/SpendBreakdownTables.tsx @@ -0,0 +1,96 @@ +import { Stack, Wrench } from "@phosphor-icons/react"; +import { + formatTokens, + formatUsd, +} from "@posthog/core/billing/spendAnalysisFormat"; +import type { + SpendAnalysisProductRow, + SpendAnalysisToolRow, +} from "@posthog/core/billing/spendAnalysisTypes"; +import { Table, Text } from "@radix-ui/themes"; +import { UsageCard } from "./UsageCard"; + +function BreakdownTable({ + headers, + widths, + children, +}: { + headers: string[]; + widths: string[]; + children: React.ReactNode; +}) { + return ( + + + + {headers.map((h, i) => ( + + {h} + + ))} + + + {children} + + ); +} + +export function ToolBreakdownCard({ rows }: { rows: SpendAnalysisToolRow[] }) { + if (rows.length === 0) return null; + return ( + } + title="By tool" + > + + {rows.slice(0, 10).map((r) => ( + + {r.tool ?? "(no tool)"} + {r.generation_count.toLocaleString()} + {formatTokens(r.avg_input_tokens)} + {formatUsd(r.cost_usd)} + + ))} + + + ); +} + +export function ProductBreakdownCard({ + rows, +}: { + rows: SpendAnalysisProductRow[]; +}) { + if (rows.length === 0) return null; + return ( + } + title="By product" + > + + {rows.map((r) => ( + + + {r.product ?? "(none)"} + + {r.event_count.toLocaleString()} + {formatUsd(r.cost_usd)} + + ))} + + + ); +} diff --git a/packages/ui/src/features/usage/components/SpendInsights.tsx b/packages/ui/src/features/usage/components/SpendInsights.tsx new file mode 100644 index 0000000000..deb02cfa43 --- /dev/null +++ b/packages/ui/src/features/usage/components/SpendInsights.tsx @@ -0,0 +1,69 @@ +import { Sparkle } from "@phosphor-icons/react"; +import { windowDays } from "@posthog/core/billing/spendAnalysisFormat"; +import { buildAnalysisPrompt } from "@posthog/core/billing/spendAnalysisPrompt"; +import type { SpendAnalysisResponse } from "@posthog/core/billing/spendAnalysisTypes"; +import { deriveSpendSuggestions } from "@posthog/core/billing/spendSuggestions"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; +import { track } from "@posthog/ui/shell/analytics"; +import { Button, Flex, Separator, Text } from "@radix-ui/themes"; +import { UsageCard } from "./UsageCard"; + +const DOCS_URL = "https://posthog.com/docs/ai-observability"; + +export function SpendInsights({ data }: { data: SpendAnalysisResponse }) { + const suggestions = deriveSpendSuggestions(data); + + const handleAnalyseClick = (): void => { + track(ANALYTICS_EVENTS.SPEND_ANALYSIS_TASK_OPENED, { + total_cost_usd: data.summary.total_cost_usd, + scoped_cost_usd: data.summary.scoped_cost_usd, + scoped_event_count: data.summary.scoped_event_count, + window_days: windowDays(data.summary.date_from, data.summary.date_to), + tool_row_count: Math.min(data.by_tool.items.length, 10), + model_row_count: data.by_model.items.length, + }); + openTaskInput({ + initialPrompt: buildAnalysisPrompt(data), + }); + }; + + return ( + } + title="Where to look" + > + + {suggestions.map((s) => ( + + {s} + + ))} + + + + + Use{" "} + + PostHog AI observability + {" "} + in your own project for the full slice-and-dice experience. + + + + + ); +} diff --git a/packages/ui/src/features/usage/components/SpendKpiStrip.tsx b/packages/ui/src/features/usage/components/SpendKpiStrip.tsx new file mode 100644 index 0000000000..bcd83dd250 --- /dev/null +++ b/packages/ui/src/features/usage/components/SpendKpiStrip.tsx @@ -0,0 +1,104 @@ +import { + formatUsd, + type SpendAnalysisFilledDay, + windowDays, +} from "@posthog/core/billing/spendAnalysisFormat"; +import type { SpendAnalysisResponse } from "@posthog/core/billing/spendAnalysisTypes"; +import { MetricCard, useChartTheme } from "@posthog/quill-charts"; +import { Flex } from "@radix-ui/themes"; +import type { ReactNode } from "react"; +import { spendDayLabel } from "./spendDayLabel"; + +const tileTitle = (label: string): ReactNode => ( + + {label} + +); + +function KpiCell({ children, last }: { children: ReactNode; last?: boolean }) { + return ( +
+ {children} +
+ ); +} + +interface SpendKpiStripProps { + data: SpendAnalysisResponse; + filledDays: SpendAnalysisFilledDay[] | null; +} + +export function SpendKpiStrip({ data, filledDays }: SpendKpiStripProps) { + const theme = useChartTheme(); + const { summary } = data; + const codeShare = + summary.total_cost_usd > 0 + ? Math.round((summary.scoped_cost_usd / summary.total_cost_usd) * 100) + : 0; + const labels = filledDays?.map((d) => spendDayLabel(d.day)); + const costSeries = filledDays?.map((d) => Math.max(0, d.cost_usd)); + const eventSeries = filledDays?.map((d) => d.event_count); + const latestDay = filledDays?.at(-1); + + return ( + + + + + + + + + v.toLocaleString()} + change={null} + sparklineHeight={28} + /> + + + {latestDay ? ( + + ) : ( + `${v} days`} + change={null} + /> + )} + + + ); +} diff --git a/packages/ui/src/features/usage/components/SpendOverTimeCard.tsx b/packages/ui/src/features/usage/components/SpendOverTimeCard.tsx new file mode 100644 index 0000000000..8354c41efd --- /dev/null +++ b/packages/ui/src/features/usage/components/SpendOverTimeCard.tsx @@ -0,0 +1,54 @@ +import { ChartBar } from "@phosphor-icons/react"; +import { + formatUsd, + type SpendAnalysisFilledDay, +} from "@posthog/core/billing/spendAnalysisFormat"; +import { + type Series, + TimeSeriesBarChart, + useChartTheme, +} from "@posthog/quill-charts"; +import { UsageCard } from "./UsageCard"; + +const VALUE_LABEL_MAX_BARS = 31; + +interface SpendOverTimeCardProps { + filledDays: SpendAnalysisFilledDay[]; +} + +export function SpendOverTimeCard({ filledDays }: SpendOverTimeCardProps) { + const theme = useChartTheme(); + const series: Series[] = [ + { + key: "cost", + label: "Cost (USD)", + data: filledDays.map((d) => Math.max(0, d.cost_usd)), + }, + ]; + const showValueLabels = filledDays.length <= VALUE_LABEL_MAX_BARS; + return ( + } + title="Cost over time" + > + {/* flex-col + fixed height: the quill chart sizes its canvas by filling + a flex-column parent; a plain block collapses it to 0. */} +
+ d.day)} + config={{ + xAxis: { timezone: "UTC", interval: "day" }, + yAxis: { tickFormatter: formatUsd }, + valueLabels: showValueLabels + ? { formatter: (value) => (value > 0 ? formatUsd(value) : "") } + : false, + barCornerRadius: 2, + showCrosshair: true, + }} + theme={theme} + /> +
+
+ ); +} diff --git a/packages/ui/src/features/usage/components/UsageCard.tsx b/packages/ui/src/features/usage/components/UsageCard.tsx new file mode 100644 index 0000000000..eba55c7776 --- /dev/null +++ b/packages/ui/src/features/usage/components/UsageCard.tsx @@ -0,0 +1,28 @@ +import { Flex, Text } from "@radix-ui/themes"; +import type { ReactNode } from "react"; + +interface UsageCardProps { + icon?: ReactNode; + title: string; + actions?: ReactNode; + children: ReactNode; +} + +export function UsageCard({ icon, title, actions, children }: UsageCardProps) { + return ( + + + {icon} + {title} + + {actions} + + {children} + + ); +} diff --git a/packages/ui/src/features/usage/components/WindowSelector.tsx b/packages/ui/src/features/usage/components/WindowSelector.tsx new file mode 100644 index 0000000000..208ef94c5e --- /dev/null +++ b/packages/ui/src/features/usage/components/WindowSelector.tsx @@ -0,0 +1,33 @@ +import type { SpendAnalysisWindow } from "@posthog/core/billing/spendAnalysisFormat"; +import { SegmentedControl } from "@radix-ui/themes"; + +const WINDOW_OPTIONS: { value: SpendAnalysisWindow; label: string }[] = [ + { value: "7d", label: "7 days" }, + { value: "30d", label: "30 days" }, + { value: "90d", label: "90 days" }, +]; + +interface WindowSelectorProps { + value: SpendAnalysisWindow; + onChange: (window: SpendAnalysisWindow) => void; +} + +export function WindowSelector({ value, onChange }: WindowSelectorProps) { + return ( + { + const found = WINDOW_OPTIONS.find((option) => option.value === next); + if (found) onChange(found.value); + }} + aria-label="Spend analysis window" + > + {WINDOW_OPTIONS.map((option) => ( + + {option.label} + + ))} + + ); +} diff --git a/packages/ui/src/features/usage/components/spendDayLabel.ts b/packages/ui/src/features/usage/components/spendDayLabel.ts new file mode 100644 index 0000000000..172fad959d --- /dev/null +++ b/packages/ui/src/features/usage/components/spendDayLabel.ts @@ -0,0 +1,7 @@ +export function spendDayLabel(day: string): string { + return new Date(`${day}T00:00:00Z`).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + timeZone: "UTC", + }); +} diff --git a/packages/ui/src/features/usage/useSpendAnalysis.ts b/packages/ui/src/features/usage/useSpendAnalysis.ts new file mode 100644 index 0000000000..8f025ef93d --- /dev/null +++ b/packages/ui/src/features/usage/useSpendAnalysis.ts @@ -0,0 +1,61 @@ +import type { SpendAnalysisResponse } from "@posthog/api-client/spend-analysis"; +import { + type SpendAnalysisWindow, + windowToDateFrom, +} from "@posthog/core/billing/spendAnalysisFormat"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { logger } from "@posthog/ui/shell/logger"; +import { useQuery } from "@tanstack/react-query"; + +const log = logger.scope("spend-analysis"); + +const SPEND_ANALYSIS_STALE_TIME_MS = 60_000; + +interface UseSpendAnalysisOptions { + window: SpendAnalysisWindow; + product?: string; +} + +interface UseSpendAnalysisReturn { + data: SpendAnalysisResponse | null; + isLoading: boolean; + isFetching: boolean; + error: string | null; + refetch: () => void; +} + +export function useSpendAnalysis({ + window, + product, +}: UseSpendAnalysisOptions): UseSpendAnalysisReturn { + const client = useOptionalAuthenticatedClient(); + const query = useQuery({ + queryKey: ["billing", "spend-analysis", window, product ?? "all"], + queryFn: async (): Promise => { + if (!client) throw new Error("Not authenticated"); + try { + return await client.getPersonalSpendAnalysis({ + dateFrom: windowToDateFrom(window), + product, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + log.warn("Failed to fetch spend analysis", { error: message }); + throw err; + } + }, + enabled: client !== null, + staleTime: SPEND_ANALYSIS_STALE_TIME_MS, + }); + + return { + data: query.data ?? null, + // Not isPending: it stays true forever while the query is disabled pre-auth. + isLoading: query.isLoading, + isFetching: query.isFetching, + error: query.error instanceof Error ? query.error.message : null, + refetch: () => { + void query.refetch(); + }, + }; +} diff --git a/packages/ui/src/features/usage/useSpendAnalysisEnabled.ts b/packages/ui/src/features/usage/useSpendAnalysisEnabled.ts new file mode 100644 index 0000000000..b60abfa567 --- /dev/null +++ b/packages/ui/src/features/usage/useSpendAnalysisEnabled.ts @@ -0,0 +1,6 @@ +import { SPEND_ANALYSIS_FLAG } from "@posthog/shared"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; + +export function useSpendAnalysisEnabled(): boolean { + return useFeatureFlag(SPEND_ANALYSIS_FLAG) || import.meta.env.DEV; +} diff --git a/packages/ui/src/features/usage/useTrackUsageViewed.ts b/packages/ui/src/features/usage/useTrackUsageViewed.ts new file mode 100644 index 0000000000..32775c6ceb --- /dev/null +++ b/packages/ui/src/features/usage/useTrackUsageViewed.ts @@ -0,0 +1,27 @@ +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { track } from "@posthog/ui/shell/analytics"; +import { useEffect, useRef } from "react"; + +export interface TrackUsageViewedInput { + isLoading: boolean; + isPro: boolean; + sustainedUsedPercent: number | null; + burstUsedPercent: number | null; +} + +export function useTrackUsageViewed(input: TrackUsageViewedInput): void { + const { isLoading, isPro, sustainedUsedPercent, burstUsedPercent } = input; + + const firedRef = useRef(false); + useEffect(() => { + if (firedRef.current) return; + // Wait for data to settle so the once-only event doesn't lock in defaults. + if (isLoading) return; + firedRef.current = true; + track(ANALYTICS_EVENTS.USAGE_VIEWED, { + is_pro: isPro, + sustained_used_percent: sustainedUsedPercent, + burst_used_percent: burstUsedPercent, + }); + }, [isLoading, isPro, sustainedUsedPercent, burstUsedPercent]); +} diff --git a/packages/ui/src/router/navigationBridge.ts b/packages/ui/src/router/navigationBridge.ts index f5859e384e..a882a8cd89 100644 --- a/packages/ui/src/router/navigationBridge.ts +++ b/packages/ui/src/router/navigationBridge.ts @@ -174,6 +174,10 @@ export function navigateToMcpServers(): void { void getRouterOrNull()?.navigate({ to: "/mcp-servers" }); } +export function navigateToUsage(): void { + void getRouterOrNull()?.navigate({ to: "/usage" }); +} + // Channels-space mirrors. These render the same shared views as their /code (or // top-level) counterparts but under /website, so navigating from the channels // sidebar keeps the channels chrome instead of switching back to Code. The diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index c378c10fa9..ca412482a9 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WebsiteRouteImport } from './routes/website' +import { Route as UsageRouteImport } from './routes/usage' import { Route as SkillsRouteImport } from './routes/skills' import { Route as McpServersRouteImport } from './routes/mcp-servers' import { Route as CommandCenterRouteImport } from './routes/command-center' @@ -79,6 +80,11 @@ const WebsiteRoute = WebsiteRouteImport.update({ path: '/website', getParentRoute: () => rootRouteImport, } as any) +const UsageRoute = UsageRouteImport.update({ + id: '/usage', + path: '/usage', + getParentRoute: () => rootRouteImport, +} as any) const SkillsRoute = SkillsRouteImport.update({ id: '/skills', path: '/skills', @@ -422,6 +428,7 @@ export interface FileRoutesByFullPath { '/command-center': typeof CommandCenterRoute '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute + '/usage': typeof UsageRoute '/website': typeof WebsiteRouteWithChildren '/code/agents': typeof CodeAgentsRouteWithChildren '/code/archived': typeof CodeArchivedRoute @@ -488,6 +495,7 @@ export interface FileRoutesByTo { '/command-center': typeof CommandCenterRoute '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute + '/usage': typeof UsageRoute '/code/archived': typeof CodeArchivedRoute '/code/home': typeof CodeHomeRoute '/folders/$folderId': typeof FoldersFolderIdRoute @@ -544,6 +552,7 @@ export interface FileRoutesById { '/command-center': typeof CommandCenterRoute '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute + '/usage': typeof UsageRoute '/website': typeof WebsiteRouteWithChildren '/code/agents': typeof CodeAgentsRouteWithChildren '/code/archived': typeof CodeArchivedRoute @@ -612,6 +621,7 @@ export interface FileRouteTypes { | '/command-center' | '/mcp-servers' | '/skills' + | '/usage' | '/website' | '/code/agents' | '/code/archived' @@ -678,6 +688,7 @@ export interface FileRouteTypes { | '/command-center' | '/mcp-servers' | '/skills' + | '/usage' | '/code/archived' | '/code/home' | '/folders/$folderId' @@ -733,6 +744,7 @@ export interface FileRouteTypes { | '/command-center' | '/mcp-servers' | '/skills' + | '/usage' | '/website' | '/code/agents' | '/code/archived' @@ -800,6 +812,7 @@ export interface RootRouteChildren { CommandCenterRoute: typeof CommandCenterRoute McpServersRoute: typeof McpServersRoute SkillsRoute: typeof SkillsRoute + UsageRoute: typeof UsageRoute WebsiteRoute: typeof WebsiteRouteWithChildren CodeAgentsRoute: typeof CodeAgentsRouteWithChildren CodeArchivedRoute: typeof CodeArchivedRoute @@ -822,6 +835,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WebsiteRouteImport parentRoute: typeof rootRouteImport } + '/usage': { + id: '/usage' + path: '/usage' + fullPath: '/usage' + preLoaderRoute: typeof UsageRouteImport + parentRoute: typeof rootRouteImport + } '/skills': { id: '/skills' path: '/skills' @@ -1492,6 +1512,7 @@ const rootRouteChildren: RootRouteChildren = { CommandCenterRoute: CommandCenterRoute, McpServersRoute: McpServersRoute, SkillsRoute: SkillsRoute, + UsageRoute: UsageRoute, WebsiteRoute: WebsiteRouteWithChildren, CodeAgentsRoute: CodeAgentsRouteWithChildren, CodeArchivedRoute: CodeArchivedRoute, diff --git a/packages/ui/src/router/routes/usage.tsx b/packages/ui/src/router/routes/usage.tsx new file mode 100644 index 0000000000..b85bc95cce --- /dev/null +++ b/packages/ui/src/router/routes/usage.tsx @@ -0,0 +1,6 @@ +import { UsageView } from "@posthog/ui/features/usage/UsageView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/usage")({ + component: UsageView, +}); diff --git a/packages/ui/src/router/useAppView.ts b/packages/ui/src/router/useAppView.ts index ec2fad08d3..a3eed99447 100644 --- a/packages/ui/src/router/useAppView.ts +++ b/packages/ui/src/router/useAppView.ts @@ -18,6 +18,7 @@ export type AppViewType = | "command-center" | "skills" | "mcp-servers" + | "usage" | "settings"; export interface AppView { @@ -80,6 +81,8 @@ function deriveFromMatches(matches: Match[]): AppView { case "/mcp-servers": case "/website/mcp-servers": return { type: "mcp-servers" }; + case "/usage": + return { type: "usage" }; case "/settings/$category": case "/settings/": return { type: "settings" };