From 0d36aae4da6ab59ca00795121f8a8db326db51a7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 15 Oct 2026 15:07:38 +0200 Subject: [PATCH 1/2] feat(billing): price product events at $5 per million after 1M included Product events leave the free beta: 1M a month on the Startup plan, then $5 per million. Autumn bills them as $0.005 per 1,000 because it rounds overage up to whole billing blocks, so a 1M block would charge $5 for a single event over. The landing page, /pricing.md, the FAQ and the in-app plan and spend views all quote the rate per 1M. Page views stay unmetered; they ride on browser_sessions. --- apps/api/autumn.config.ts | 16 +++---- apps/landing/messages/en.json | 8 ++-- apps/landing/messages/ja.json | 6 +-- apps/landing/messages/ko.json | 6 +-- apps/landing/src/lib/pricing-faq.ts | 12 +++++- apps/landing/src/lib/pricing-offer.ts | 42 +++++++++++-------- apps/landing/src/pages/llms.txt.ts | 2 +- .../src/components/settings/pricing-cards.tsx | 17 +++++++- .../web/src/lab/onboarding/onboarding-lab.tsx | 27 ++++++++++-- .../web/src/lib/billing/cost-estimate.test.ts | 12 +++--- apps/web/src/lib/billing/cost-estimate.ts | 8 ++-- apps/web/src/lib/billing/spend.test.ts | 14 +++---- apps/web/src/lib/billing/spend.ts | 11 ++--- apps/web/src/lib/billing/usage.ts | 17 ++++++++ docs/product-events-funnels.md | 9 ++-- 15 files changed, 138 insertions(+), 69 deletions(-) diff --git a/apps/api/autumn.config.ts b/apps/api/autumn.config.ts index f92a8e1bb4..6eca3c6cc8 100644 --- a/apps/api/autumn.config.ts +++ b/apps/api/autumn.config.ts @@ -128,14 +128,16 @@ export const startup = plan({ }, }, { - // BETA (2026-08-17): free and unlimited while product events are in - // beta. Usage is still metered by the gateway and tracked in Autumn so - // we know real volumes before pricing it. To start charging, replace - // `unlimited` with e.g. `included: 1_000_000` and a - // `price: { amount: 0.05, billingUnits: 1000, billingMethod: - // "usage_based", interval: "month" }` ($0.05 per 1,000 events). + // $5 per 1M events past 1M. Billed per 1,000 because Autumn rounds + // overage up to whole blocks; a 1M block would charge $5 for one event. featureId: "product_events", - unlimited: true, + included: 1_000_000, + price: { + amount: 0.005, + billingUnits: 1000, + billingMethod: "usage_based", + interval: "month", + }, }, { // One credit is one dollar of model spend after markup. diff --git a/apps/landing/messages/en.json b/apps/landing/messages/en.json index 893253fc24..7e3ad925b0 100644 --- a/apps/landing/messages/en.json +++ b/apps/landing/messages/en.json @@ -128,14 +128,14 @@ "pe_point_1_body": "Set maple.product_event.name on a span and it becomes an event, in any language. The event keeps a link to the trace that ran it.", "pe_point_2_title": "One person, start to finish", "pe_point_2_body": "An anonymous visit to your site joins the user it signs up as. Funnels count people, not cookies.", - "pe_point_3_title": "Free while in beta", - "pe_point_3_body": "Product events are unlimited during the beta and kept for 365 days. Automatic page views are not counted.", + "pe_point_3_title": "1M events free every month", + "pe_point_3_body": "Then $5 per million. Automatic page views are never counted, and every event is kept for 365 days.", "pe_link_feature": "Product events →", "pe_link_docs": "Product events docs", "nav_product_events": "Product Events", "nav_desc_product_events": "Funnels, drop-off and paths on your events", "feat_pe_seo_title": "Product Analytics and Funnels | Maple", - "feat_pe_seo_desc": "Track product events from the browser, your backend or any OpenTelemetry span. Build funnels, drop-off and paths next to your traces and metrics. Unlimited while in beta.", + "feat_pe_seo_desc": "Track product events from the browser, your backend or any OpenTelemetry span. Build funnels, drop-off and paths next to your traces and metrics. 1M events a month included, then $5 per million.", "feat_pe_hero_title": "Product analytics, next to the trace", "feat_pe_hero_lede": "Page views arrive on their own. Track the rest from the browser, your backend or a span you already emit, and see where people stop on the same dashboards as your latency.", "feat_pe_artifact_title": "Find the step where people stop", @@ -802,7 +802,7 @@ "faq_metrics_q": "Do metrics cost extra?", "faq_metrics_a": "No. Metrics are metered by volume like logs and traces: {included} GB included, then {rate} per GB. There is no per-series, per-sample or custom-metric surcharge.", "faq_sessions_q": "How are browser sessions and product events billed?", - "faq_sessions_a": "A browser session is one visitor session recorded by the browser SDK, replay included. {included} are included each month, then {rate} per session. Product events are unlimited and free while they are in beta.", + "faq_sessions_a": "A browser session is one visitor session recorded by the browser SDK, replay included. {included} are included each month, then {rate} per session. Product events are your track() calls, /v1/events posts and annotated spans: {events_included} are included each month, then {events_rate} per million. Automatic page views are not counted.", "faq_spend_q": "Can I cap what I spend?", "faq_spend_a": "Yes. Set an overage cap per signal in the billing settings, and usage alerts at the thresholds you choose. When a signal reaches its cap, ingest for that signal is rejected until the billing cycle resets. Caps and alerts run on the same usage your invoice is built from.", "stack_eyebrow": "What it works with", diff --git a/apps/landing/messages/ja.json b/apps/landing/messages/ja.json index 2da766dd62..4e8dc9cdd1 100644 --- a/apps/landing/messages/ja.json +++ b/apps/landing/messages/ja.json @@ -6,14 +6,14 @@ "pe_point_1_body": "\u30b9\u30d1\u30f3\u306b maple.product_event.name \u3092\u8a2d\u5b9a\u3059\u308b\u3060\u3051\u3067\u30a4\u30d9\u30f3\u30c8\u306b\u306a\u308a\u307e\u3059\u3002\u8a00\u8a9e\u306f\u554f\u3044\u307e\u305b\u3093\u3002\u30a4\u30d9\u30f3\u30c8\u306f\u5b9f\u884c\u3057\u305f\u30c8\u30ec\u30fc\u30b9\u3078\u306e\u30ea\u30f3\u30af\u3092\u4fdd\u6301\u3057\u307e\u3059\u3002", "pe_point_2_title": "\u6700\u521d\u304b\u3089\u6700\u5f8c\u307e\u3067\u540c\u3058\u4eba", "pe_point_2_body": "\u30b5\u30a4\u30c8\u3078\u306e\u533f\u540d\u306e\u8a2a\u554f\u306f\u3001\u30b5\u30a4\u30f3\u30a2\u30c3\u30d7\u5f8c\u306e\u30e6\u30fc\u30b6\u30fc\u306b\u7d10\u3065\u304d\u307e\u3059\u3002\u30d5\u30a1\u30cd\u30eb\u306f Cookie \u3067\u306f\u306a\u304f\u4eba\u3092\u6570\u3048\u307e\u3059\u3002", - "pe_point_3_title": "\u30d9\u30fc\u30bf\u671f\u9593\u4e2d\u306f\u7121\u6599", - "pe_point_3_body": "\u30d7\u30ed\u30c0\u30af\u30c8\u30a4\u30d9\u30f3\u30c8\u306f\u30d9\u30fc\u30bf\u671f\u9593\u4e2d\u306f\u7121\u5236\u9650\u3067\u3001365 \u65e5\u9593\u4fdd\u5b58\u3055\u308c\u307e\u3059\u3002\u81ea\u52d5\u306e\u30da\u30fc\u30b8\u30d3\u30e5\u30fc\u306f\u30ab\u30a6\u30f3\u30c8\u3055\u308c\u307e\u305b\u3093\u3002", + "pe_point_3_title": "\u6bce\u6708 100 \u4e07\u30a4\u30d9\u30f3\u30c8\u307e\u3067\u7121\u6599", + "pe_point_3_body": "\u8d85\u904e\u5206\u306f 100 \u4e07\u30a4\u30d9\u30f3\u30c8\u3042\u305f\u308a $5\u3002\u81ea\u52d5\u306e\u30da\u30fc\u30b8\u30d3\u30e5\u30fc\u306f\u30ab\u30a6\u30f3\u30c8\u3055\u308c\u305a\u3001\u3059\u3079\u3066\u306e\u30a4\u30d9\u30f3\u30c8\u306f 365 \u65e5\u9593\u4fdd\u5b58\u3055\u308c\u307e\u3059\u3002", "pe_link_feature": "\u30d7\u30ed\u30c0\u30af\u30c8\u30a4\u30d9\u30f3\u30c8 \u2192", "pe_link_docs": "\u30d7\u30ed\u30c0\u30af\u30c8\u30a4\u30d9\u30f3\u30c8\u306e\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8", "nav_product_events": "\u30d7\u30ed\u30c0\u30af\u30c8\u30a4\u30d9\u30f3\u30c8", "nav_desc_product_events": "\u30a4\u30d9\u30f3\u30c8\u306e\u30d5\u30a1\u30cd\u30eb\u3001\u96e2\u8131\u3001\u30d1\u30b9", "feat_pe_seo_title": "\u30d7\u30ed\u30c0\u30af\u30c8\u5206\u6790\u3068\u30d5\u30a1\u30cd\u30eb | Maple", - "feat_pe_seo_desc": "\u30d6\u30e9\u30a6\u30b6\u3001\u30d0\u30c3\u30af\u30a8\u30f3\u30c9\u3001\u307e\u305f\u306f\u4efb\u610f\u306e OpenTelemetry \u30b9\u30d1\u30f3\u304b\u3089\u30d7\u30ed\u30c0\u30af\u30c8\u30a4\u30d9\u30f3\u30c8\u3092\u8a08\u6e2c\u3002\u30c8\u30ec\u30fc\u30b9\u3084\u30e1\u30c8\u30ea\u30af\u30b9\u306e\u96a3\u3067\u30d5\u30a1\u30cd\u30eb\u3001\u96e2\u8131\u3001\u30d1\u30b9\u3092\u69cb\u7bc9\u3067\u304d\u307e\u3059\u3002\u30d9\u30fc\u30bf\u671f\u9593\u4e2d\u306f\u7121\u5236\u9650\u3067\u3059\u3002", + "feat_pe_seo_desc": "\u30d6\u30e9\u30a6\u30b6\u3001\u30d0\u30c3\u30af\u30a8\u30f3\u30c9\u3001\u307e\u305f\u306f\u4efb\u610f\u306e OpenTelemetry \u30b9\u30d1\u30f3\u304b\u3089\u30d7\u30ed\u30c0\u30af\u30c8\u30a4\u30d9\u30f3\u30c8\u3092\u8a08\u6e2c\u3002\u30c8\u30ec\u30fc\u30b9\u3084\u30e1\u30c8\u30ea\u30af\u30b9\u306e\u96a3\u3067\u30d5\u30a1\u30cd\u30eb\u3001\u96e2\u8131\u3001\u30d1\u30b9\u3092\u69cb\u7bc9\u3067\u304d\u307e\u3059\u3002\u6bce\u6708 100 \u4e07\u30a4\u30d9\u30f3\u30c8\u307e\u3067\u542b\u307e\u308c\u3001\u8d85\u904e\u5206\u306f 100 \u4e07\u30a4\u30d9\u30f3\u30c8\u3042\u305f\u308a $5 \u3067\u3059\u3002", "feat_pe_hero_title": "\u30c8\u30ec\u30fc\u30b9\u306e\u96a3\u306b\u30d7\u30ed\u30c0\u30af\u30c8\u5206\u6790\u3092", "feat_pe_hero_lede": "\u30da\u30fc\u30b8\u30d3\u30e5\u30fc\u306f\u81ea\u52d5\u3067\u8a18\u9332\u3055\u308c\u307e\u3059\u3002\u305d\u308c\u4ee5\u5916\u306f\u30d6\u30e9\u30a6\u30b6\u3001\u30d0\u30c3\u30af\u30a8\u30f3\u30c9\u3001\u307e\u305f\u306f\u65e2\u5b58\u306e\u30b9\u30d1\u30f3\u304b\u3089\u8a08\u6e2c\u3057\u3001\u30e6\u30fc\u30b6\u30fc\u304c\u96e2\u8131\u3059\u308b\u5834\u6240\u3092\u30ec\u30a4\u30c6\u30f3\u30b7\u3068\u540c\u3058\u30c0\u30c3\u30b7\u30e5\u30dc\u30fc\u30c9\u3067\u78ba\u8a8d\u3067\u304d\u307e\u3059\u3002", "feat_pe_artifact_title": "\u30e6\u30fc\u30b6\u30fc\u304c\u96e2\u8131\u3059\u308b\u30b9\u30c6\u30c3\u30d7\u3092\u898b\u3064\u3051\u308b", diff --git a/apps/landing/messages/ko.json b/apps/landing/messages/ko.json index 24e54fbfe8..1b4dbcb530 100644 --- a/apps/landing/messages/ko.json +++ b/apps/landing/messages/ko.json @@ -6,14 +6,14 @@ "pe_point_1_body": "\uc2a4\ud32c\uc5d0 maple.product_event.name\uc744 \uc124\uc815\ud558\uba74 \uc774\ubca4\ud2b8\uac00 \ub429\ub2c8\ub2e4. \uc5b8\uc5b4\ub294 \uc0c1\uad00\uc5c6\uc2b5\ub2c8\ub2e4. \uc774\ubca4\ud2b8\ub294 \uc2e4\ud589\ub41c \ud2b8\ub808\uc774\uc2a4\ub85c\uc758 \ub9c1\ud06c\ub97c \uc720\uc9c0\ud569\ub2c8\ub2e4.", "pe_point_2_title": "\ucc98\uc74c\ubd80\ud130 \ub05d\uae4c\uc9c0 \ud55c \uc0ac\ub78c", "pe_point_2_body": "\uc0ac\uc774\ud2b8\uc758 \uc775\uba85 \ubc29\ubb38\uc740 \uac00\uc785\ud55c \uc0ac\uc6a9\uc790\uc640 \uc5f0\uacb0\ub429\ub2c8\ub2e4. \ud37c\ub110\uc740 \ucfe0\ud0a4\uac00 \uc544\ub2c8\ub77c \uc0ac\ub78c\uc744 \uc149\ub2c8\ub2e4.", - "pe_point_3_title": "\ubca0\ud0c0 \uae30\uac04 \ubb34\ub8cc", - "pe_point_3_body": "\ud504\ub85c\ub355\ud2b8 \uc774\ubca4\ud2b8\ub294 \ubca0\ud0c0 \uae30\uac04 \ub3d9\uc548 \ubb34\uc81c\ud55c\uc774\uba70 365\uc77c\uac04 \ubcf4\uad00\ub429\ub2c8\ub2e4. \uc790\ub3d9 \ud398\uc774\uc9c0\ubdf0\ub294 \uc9d1\uacc4\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.", + "pe_point_3_title": "\ub9e4\uc6d4 100\ub9cc \uc774\ubca4\ud2b8 \ubb34\ub8cc", + "pe_point_3_body": "\ucd08\uacfc\ubd84\uc740 100\ub9cc \uc774\ubca4\ud2b8\ub2f9 $5\uc785\ub2c8\ub2e4. \uc790\ub3d9 \ud398\uc774\uc9c0\ubdf0\ub294 \uc9d1\uacc4\ub418\uc9c0 \uc54a\uc73c\uba70 \ubaa8\ub4e0 \uc774\ubca4\ud2b8\ub294 365\uc77c\uac04 \ubcf4\uad00\ub429\ub2c8\ub2e4.", "pe_link_feature": "\ud504\ub85c\ub355\ud2b8 \uc774\ubca4\ud2b8 \u2192", "pe_link_docs": "\ud504\ub85c\ub355\ud2b8 \uc774\ubca4\ud2b8 \ubb38\uc11c", "nav_product_events": "\ud504\ub85c\ub355\ud2b8 \uc774\ubca4\ud2b8", "nav_desc_product_events": "\uc774\ubca4\ud2b8 \uae30\ubc18 \ud37c\ub110, \uc774\ud0c8, \uacbd\ub85c", "feat_pe_seo_title": "\ud504\ub85c\ub355\ud2b8 \ubd84\uc11d\uacfc \ud37c\ub110 | Maple", - "feat_pe_seo_desc": "\ube0c\ub77c\uc6b0\uc800, \ubc31\uc5d4\ub4dc \ub610\ub294 \ubaa8\ub4e0 OpenTelemetry \uc2a4\ud32c\uc5d0\uc11c \ud504\ub85c\ub355\ud2b8 \uc774\ubca4\ud2b8\ub97c \uc218\uc9d1\ud558\uc138\uc694. \ud2b8\ub808\uc774\uc2a4\uc640 \uba54\ud2b8\ub9ad \uc606\uc5d0\uc11c \ud37c\ub110, \uc774\ud0c8, \uacbd\ub85c\ub97c \ub9cc\ub4e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ubca0\ud0c0 \uae30\uac04 \ubb34\uc81c\ud55c.", + "feat_pe_seo_desc": "\ube0c\ub77c\uc6b0\uc800, \ubc31\uc5d4\ub4dc \ub610\ub294 \ubaa8\ub4e0 OpenTelemetry \uc2a4\ud32c\uc5d0\uc11c \ud504\ub85c\ub355\ud2b8 \uc774\ubca4\ud2b8\ub97c \uc218\uc9d1\ud558\uc138\uc694. \ud2b8\ub808\uc774\uc2a4\uc640 \uba54\ud2b8\ub9ad \uc606\uc5d0\uc11c \ud37c\ub110, \uc774\ud0c8, \uacbd\ub85c\ub97c \ub9cc\ub4e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ub9e4\uc6d4 100\ub9cc \uc774\ubca4\ud2b8 \ud3ec\ud568, \ucd08\uacfc\ubd84\uc740 100\ub9cc \uc774\ubca4\ud2b8\ub2f9 $5.", "feat_pe_hero_title": "\ud2b8\ub808\uc774\uc2a4 \uc606\uc758 \ud504\ub85c\ub355\ud2b8 \ubd84\uc11d", "feat_pe_hero_lede": "\ud398\uc774\uc9c0\ubdf0\ub294 \uc790\ub3d9\uc73c\ub85c \uae30\ub85d\ub429\ub2c8\ub2e4. \ub098\uba38\uc9c0\ub294 \ube0c\ub77c\uc6b0\uc800, \ubc31\uc5d4\ub4dc \ub610\ub294 \uc774\ubbf8 \ub0b4\ubcf4\ub0b4\ub294 \uc2a4\ud32c\uc5d0\uc11c \uc218\uc9d1\ud558\uace0, \uc0ac\uc6a9\uc790\uac00 \uba48\ucd94\ub294 \uacf3\uc744 \ub808\uc774\ud134\uc2dc\uc640 \uac19\uc740 \ub300\uc2dc\ubcf4\ub4dc\uc5d0\uc11c \ud655\uc778\ud558\uc138\uc694.", "feat_pe_artifact_title": "\uc0ac\uc6a9\uc790\uac00 \uba48\ucd94\ub294 \ub2e8\uacc4 \ucc3e\uae30", diff --git a/apps/landing/src/lib/pricing-faq.ts b/apps/landing/src/lib/pricing-faq.ts index 8bba62565f..5de87d2811 100644 --- a/apps/landing/src/lib/pricing-faq.ts +++ b/apps/landing/src/lib/pricing-faq.ts @@ -11,7 +11,7 @@ * resolves the locale per render (see `pricing-offer.ts`). */ import * as m from "../paraglide/messages.js" -import { getOffer, rateLabel } from "./pricing-offer" +import { getOffer, money, rateLabel, volume } from "./pricing-offer" export interface FaqItem { question: string @@ -27,6 +27,9 @@ export async function pricingFaq(): Promise { const gbRate = rateLabel(gb?.rate ?? 0) const includedSessions = (sessions?.included ?? 0).toLocaleString("en-US") const sessionRate = rateLabel(sessions?.rate ?? 0) + const events = offer.allotments.find((a) => a.unit === "events") + const eventsIncluded = events ? volume(events, events.included) : "0" + const eventsRate = money(events?.rate ?? 0) return [ { question: m.faq_measure_q(), answer: m.faq_measure_a({ included: includedGB }) }, @@ -35,7 +38,12 @@ export async function pricingFaq(): Promise { { question: m.faq_metrics_q(), answer: m.faq_metrics_a({ included: includedGB, rate: gbRate }) }, { question: m.faq_sessions_q(), - answer: m.faq_sessions_a({ included: includedSessions, rate: sessionRate }), + answer: m.faq_sessions_a({ + included: includedSessions, + rate: sessionRate, + events_included: eventsIncluded, + events_rate: eventsRate, + }), }, { question: m.faq_retention_q(), answer: m.faq_retention_a() }, { question: m.faq_trial_q(), answer: m.faq_trial_a() }, diff --git a/apps/landing/src/lib/pricing-offer.ts b/apps/landing/src/lib/pricing-offer.ts index 04f18b16bc..be3a1af4d7 100644 --- a/apps/landing/src/lib/pricing-offer.ts +++ b/apps/landing/src/lib/pricing-offer.ts @@ -37,8 +37,9 @@ export interface Allotment { /** Price per `rateUnits` units once `included` is used up. */ rate?: number /** - * Units the rate is quoted per: 1 for GB and sessions, 1,000 for events. - * Autumn's `billingUnits`; the rate is the price of one such block. + * Units the rate is quoted per: 1 for GB and sessions, 1M for events. Autumn's + * `billingUnits` except for events, which bill per 1,000 but are quoted per + * million. The rate is the price of one such block. */ rateUnits: number } @@ -73,6 +74,9 @@ const hasOwnUnit = (featureId: string): featureId is keyof typeof FEATURE_UNITS const unitFor = (featureId: string): Unit => (hasOwnUnit(featureId) ? FEATURE_UNITS[featureId] : "gb") +/** "$5 / 1M events" reads better than the billed "$0.005 / 1,000 events". */ +const EVENTS_QUOTE_UNITS = 1_000_000 + /** * Capitalized, localized labels for the metered rows, keyed by Autumn featureId * (Autumn returns lowercase names like "logs"). @@ -141,14 +145,18 @@ export async function getOffer(): Promise { .filter((item) => item.featureId && !HIDDEN_FEATURE_IDS.has(item.featureId)) .map((item) => { const featureId = item.featureId + const unit = unitFor(featureId) + const billingUnits = Math.max(1, item.price?.billingUnits ?? 1) + const rateUnits = unit === "events" ? EVENTS_QUOTE_UNITS : billingUnits + const amount = item.price?.amount return { featureId, label: labels[featureId] ?? item.feature?.name ?? featureId, - unit: unitFor(featureId), + unit, included: Number(item.included ?? 0), unlimited: item.unlimited === true, - rate: item.price?.amount ?? undefined, - rateUnits: Math.max(1, item.price?.billingUnits ?? 1), + rate: amount == null ? undefined : (amount / billingUnits) * rateUnits, + rateUnits, } }) .sort((a, b) => dataFeatureRank(a.featureId) - dataFeatureRank(b.featureId)), @@ -197,10 +205,10 @@ export async function getOffer(): Promise { featureId: "product_events", label: labels.product_events!, unit: "events", - // Free and unlimited during beta; mirrors `apps/api/autumn.config.ts`. - included: 0, - unlimited: true, - rateUnits: 1, + // Mirrors `apps/api/autumn.config.ts`: $0.005 per 1,000, quoted per 1M. + included: 1_000_000, + rate: 5, + rateUnits: EVENTS_QUOTE_UNITS, }, { featureId: "ai_credits", @@ -229,21 +237,19 @@ export const money = (n: number) => { /** Sub-cent rates (per session) need three places; per-GB rates need two. */ export const rateLabel = (n: number) => `$${n < 0.01 ? n.toFixed(3) : n.toFixed(2)}` +/** Whole millions as "1M"; anything else exact. */ +const count = (n: number) => + n >= 1_000_000 && n % 1_000_000 === 0 ? `${n / 1_000_000}M` : n.toLocaleString("en-US") + /** * The block a rate is quoted per, for the "then {rate} / …" sentence: 1 for - * GB and sessions, "1,000" for events. Localized unit words come from the + * GB and sessions, "1M" for events. Localized unit words come from the * message catalog; this only supplies the number. */ -export const rateBlock = (a: Allotment) => a.rateUnits.toLocaleString("en-US") +export const rateBlock = (a: Allotment) => count(a.rateUnits) export const volume = (a: Allotment, n: number) => - a.unlimited - ? m.pricing_unlimited() - : a.unit === "gb" - ? `${n} GB` - : a.unit === "usd" - ? money(n) - : n.toLocaleString("en-US") + a.unlimited ? m.pricing_unlimited() : a.unit === "gb" ? `${n} GB` : a.unit === "usd" ? money(n) : count(n) /** * AI usage has no flat rate: a credit is a dollar of model spend, priced per diff --git a/apps/landing/src/pages/llms.txt.ts b/apps/landing/src/pages/llms.txt.ts index 984fbc310f..b76a52a931 100644 --- a/apps/landing/src/pages/llms.txt.ts +++ b/apps/landing/src/pages/llms.txt.ts @@ -45,7 +45,7 @@ export const GET: APIRoute = ({ site }) => { "## Pricing", "", - "One plan: $39 a month with 100 GB each of logs, traces and metrics included, then $0.30 per GB per signal; 5,000 browser sessions included, then $0.002 per session; product events free during beta.", + "One plan: $39 a month with 100 GB each of logs, traces and metrics included, then $0.30 per GB per signal; 5,000 browser sessions included, then $0.002 per session; 1M product events included, then $5 per million (page views not counted).", "", ...both("Pricing", "/pricing"), "", diff --git a/apps/web/src/components/settings/pricing-cards.tsx b/apps/web/src/components/settings/pricing-cards.tsx index 2abfbe3baf..7d73e7cf9d 100644 --- a/apps/web/src/components/settings/pricing-cards.tsx +++ b/apps/web/src/components/settings/pricing-cards.tsx @@ -7,7 +7,7 @@ import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" import { billingCustomerAtom, billingPlansAtom } from "@/lib/services/atoms/billing-atoms" import { useBillingActions } from "@/hooks/use-billing-actions" import { showErrorToast } from "@/lib/error-toast" -import { formatCount } from "@/lib/billing/usage" +import { formatCount, formatQuoteUnits, quoteUnits } from "@/lib/billing/usage" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { formatDateInTimezone } from "@/lib/timezone-format" import { getTrialStatus } from "@/lib/billing/plan-gating" @@ -131,6 +131,19 @@ function normalizeDetailText(text: string): string { return text.replace(/\bper\s+(?:[\d,]+\s+)?(?:logs?|traces?|metrics?)\b/i, "per GB") } +// Autumn words the rate per billing block ("$0.005 per 1,000"); events are quoted per million, in its style. +function formatDetail(item: PlanItem): string | undefined { + const amount = item.price?.amount + const billingUnits = item.price?.billingUnits ?? 1 + const unit = item.featureId ? COUNT_UNITS[item.featureId] : undefined + const quote = item.featureId ? quoteUnits(item.featureId, billingUnits) : billingUnits + if (amount != null && unit !== undefined && quote !== billingUnits && billingUnits > 0) { + const perQuote = (amount / billingUnits) * quote + return `then $${Number(perQuote.toPrecision(6))} per ${formatQuoteUnits(quote)} ${unit}` + } + return item.display?.secondaryText ? normalizeDetailText(item.display.secondaryText) : undefined +} + function getFeatureRows(plan: Plan) { return plan.items .filter((item) => item.featureId && !HIDDEN_FEATURE_IDS.has(item.featureId)) @@ -141,7 +154,7 @@ function getFeatureRows(plan: Plan) { item.feature?.name ?? item.featureId, value: formatIncludedUsage(item), - detail: item.display?.secondaryText ? normalizeDetailText(item.display.secondaryText) : undefined, + detail: formatDetail(item), })) } diff --git a/apps/web/src/lab/onboarding/onboarding-lab.tsx b/apps/web/src/lab/onboarding/onboarding-lab.tsx index d7e0d90658..b777ae65ba 100644 --- a/apps/web/src/lab/onboarding/onboarding-lab.tsx +++ b/apps/web/src/lab/onboarding/onboarding-lab.tsx @@ -7,7 +7,7 @@ import { AnimatePresence } from "motion/react" import { Link } from "@tanstack/react-router" import { Button } from "@maple/ui/components/ui/button" import { MapleMark } from "@maple/ui/components/icons/maple-mark" -import { CatalogPlan } from "@maple/domain/http" +import { CatalogPlan, CatalogPlanItem, CatalogPlanItemPrice } from "@maple/domain/http" import { STEP_IDS } from "@/atoms/quick-start-atoms" import type { OnboardingRole } from "@/lib/onboarding-role" import { OnboardingLayout } from "@/components/onboarding/onboarding-layout" @@ -17,12 +17,33 @@ import { StepPlanLayout } from "@/components/onboarding/step-plan" import { PlanCards } from "@/components/settings/pricing-cards" // Illustrative catalog only. This lab never mounts billing or seed actions. +const meteredItem = ( + featureId: string, + included: number, + amount: number, + billingUnits: number, + secondaryText: string, +) => + new CatalogPlanItem({ + featureId, + included, + price: new CatalogPlanItemPrice({ amount, billingUnits, interval: "month" }), + display: { secondaryText }, + }) + +// Mirrors `apps/api/autumn.config.ts`, with Autumn's own display text. const PLANS = [ new CatalogPlan({ id: "startup", name: "Startup", - price: { amount: 25, interval: "month" }, - items: [], + price: { amount: 39, interval: "month" }, + items: [ + meteredItem("logs", 100, 0.3, 1, "then $0.3 per Logs"), + meteredItem("traces", 100, 0.3, 1, "then $0.3 per Traces"), + meteredItem("metrics", 100, 0.3, 1, "then $0.3 per Metrics"), + meteredItem("browser_sessions", 5000, 0.002, 1, "then $0.002 per browser session"), + meteredItem("product_events", 1_000_000, 0.005, 1000, "then $0.005 per 1,000 product events"), + ], customerEligibility: { trialAvailable: true }, }), ] diff --git a/apps/web/src/lib/billing/cost-estimate.test.ts b/apps/web/src/lib/billing/cost-estimate.test.ts index 93054bb598..2173ecc89c 100644 --- a/apps/web/src/lib/billing/cost-estimate.test.ts +++ b/apps/web/src/lib/billing/cost-estimate.test.ts @@ -31,7 +31,7 @@ function buildCustomer( } // Mirrors apps/api/autumn.config.ts: $39/mo base, 100 GB included per signal at -// $0.30/GB overage, 5000 sessions at $0.002/session, 1M events at $0.05/1,000. +// $0.30/GB overage, 5000 sessions at $0.002/session, 1M events at $0.005/1,000. const startupPlan = { id: "startup", name: "Startup", @@ -42,7 +42,7 @@ const startupPlan = { { featureId: "traces", included: 100, price: { amount: 0.3, billingUnits: 1 } }, { featureId: "metrics", included: 100, price: { amount: 0.3, billingUnits: 1 } }, { featureId: "browser_sessions", included: 5000, price: { amount: 0.002, billingUnits: 1 } }, - { featureId: "product_events", included: 1_000_000, price: { amount: 0.05, billingUnits: 1000 } }, + { featureId: "product_events", included: 1_000_000, price: { amount: 0.005, billingUnits: 1000 } }, ], } as Plan @@ -107,19 +107,19 @@ describe("estimateCycleCost", () => { expect(metrics!.detail).toContain("279.75 GB over included") }) - it("bills product events per 1,000-event block and says so in the detail", () => { + it("bills product events per 1,000-event block but quotes the rate per million", () => { const estimate = estimateCycleCost({ customer: buildCustomer([buildSubscription()], { product_events: { granted: 1_000_000 } }), plans: [startupPlan], usage: usage({ product_events: 1_250_500 }), }) const events = estimate!.lines.find((l) => l.key === "overage:product_events") - // 250,500 over → ceil(250.5) = 251 blocks × $0.05 + // 250,500 over → ceil(250.5) = 251 blocks × $0.005 expect(events).toMatchObject({ label: "Product Events overage", - detail: "250,500 events over included × $0.05 / 1,000 events", + detail: "250,500 events over included × $5.00 / 1M events", }) - expect(events!.amount).toBeCloseTo(12.55) + expect(events!.amount).toBeCloseTo(1.255) }) it("rounds overage up per billingUnits block", () => { diff --git a/apps/web/src/lib/billing/cost-estimate.ts b/apps/web/src/lib/billing/cost-estimate.ts index a9b5835f95..8efb134239 100644 --- a/apps/web/src/lib/billing/cost-estimate.ts +++ b/apps/web/src/lib/billing/cost-estimate.ts @@ -7,7 +7,7 @@ import { type PlanLike, } from "@maple/domain/billing" import { formatCurrency } from "@maple/domain/format" -import { formatCount, formatUsage } from "./usage" +import { formatCount, formatQuoteUnits, formatUsage, quoteUnits } from "./usage" // Metered features surfaced on the billing page. AI token features stay hidden, // matching HIDDEN_FEATURE_IDS in pricing-cards.tsx. @@ -144,9 +144,11 @@ export function estimateCycleCost({ const billingUnits = item?.price?.billingUnits ?? 1 const units = Math.ceil(over / (billingUnits > 0 ? billingUnits : 1)) const unit = featureUnit(featureId) + const block = quoteUnits(featureId, billingUnits > 0 ? billingUnits : 1) + const blockRate = (rate / (billingUnits > 0 ? billingUnits : 1)) * block const rateLabel = - billingUnits > 1 - ? `${formatUnitPrice(rate)} / ${formatCount(billingUnits)} ${unit}s` + block > 1 + ? `${formatUnitPrice(blockRate)} / ${formatQuoteUnits(block)} ${unit}s` : `${formatUnitPrice(rate)} / ${unit}` lines.push({ key: `overage:${featureId}`, diff --git a/apps/web/src/lib/billing/spend.test.ts b/apps/web/src/lib/billing/spend.test.ts index 08a2331dc2..2a6728301b 100644 --- a/apps/web/src/lib/billing/spend.test.ts +++ b/apps/web/src/lib/billing/spend.test.ts @@ -24,8 +24,8 @@ const startupPlan = { { featureId: "traces", included: 100, price: { amount: 0.3 } }, { featureId: "metrics", included: 100, price: { amount: 0.3 } }, { featureId: "browser_sessions", included: 5_000, price: { amount: 0.002 } }, - // Quoted per 1,000 events: `amount` is the price of one billing block. - { featureId: "product_events", included: 1_000_000, price: { amount: 0.05, billingUnits: 1_000 } }, + // Billed per 1,000 events: `amount` is the price of one billing block. + { featureId: "product_events", included: 1_000_000, price: { amount: 0.005, billingUnits: 1_000 } }, ], } as CatalogPlan @@ -203,7 +203,7 @@ describe("buildSpendModel", () => { expect(result?.partial).toBe(true) }) - it("prices product events per event from a rate quoted per 1,000", () => { + it("prices product events per event from a rate billed per 1,000", () => { const result = buildSpendModel({ customer: buildCustomer(), plans: [startupPlan], @@ -214,10 +214,10 @@ describe("buildSpendModel", () => { const events = result.features.find((feature) => feature.featureId === "product_events") if (events === undefined) throw new Error("expected product_events") - // 400,000 events over × $0.05 / 1,000 = $20 — NOT 400,000 × $0.05. + // 400,000 events over × $0.005 / 1,000 = $2, NOT 400,000 × $0.005. expect(events.overageUnits).toBe(400_000) - expect(events.overageCents).toBe(2_000) - expect(events.ratePerUnit).toBeCloseTo(0.00005, 10) + expect(events.overageCents).toBe(200) + expect(events.ratePerUnit).toBeCloseTo(0.000005, 12) expect(events.billingUnits).toBe(1_000) expect(result.topDriver?.featureId).toBe("product_events") }) @@ -233,7 +233,7 @@ describe("buildSpendModel", () => { traces: "$0.30/GB", metrics: "$0.30/GB", browser_sessions: "$0.002/session", - product_events: "$0.05/1,000 events", + product_events: "$5/1M events", }) expect(featureUnit("product_events")).toBe("events") expect(featureUnit("browser_sessions")).toBe("sessions") diff --git a/apps/web/src/lib/billing/spend.ts b/apps/web/src/lib/billing/spend.ts index 94f83d50a6..734c475f99 100644 --- a/apps/web/src/lib/billing/spend.ts +++ b/apps/web/src/lib/billing/spend.ts @@ -9,7 +9,7 @@ import { type FeatureUsagePricing, type PlanLike, } from "@maple/domain/billing" -import { formatCount } from "./usage" +import { formatQuoteUnits, quoteUnits } from "./usage" /** * The billing page's spend model: one place that turns the Autumn snapshot plus @@ -81,7 +81,7 @@ export const featureUnit = (featureId: string): "GB" | "sessions" | "events" => /** * The overage rate as the catalog quotes it: per GB for the byte signals, per - * unit for sessions, and per `billingUnits` for events ("$0.05/1,000 events") — + * unit for sessions, and per million for events ("$5/1M events"): * the per-event rate is a fraction of a cent and would read as "$0" if rendered * through the currency formatter. */ @@ -89,9 +89,10 @@ export const formatRateLabel = (feature: FeatureSpend): string | null => { if (feature.ratePerUnit === null) return null const unit = countUnitOf(feature.featureId) if (unit === undefined) return `$${feature.ratePerUnit.toFixed(2)}/GB` - if (feature.billingUnits > 1) { - const perBlock = feature.ratePerUnit * feature.billingUnits - return `$${Number(perBlock.toPrecision(6))}/${formatCount(feature.billingUnits)} ${unit}s` + const block = quoteUnits(feature.featureId, feature.billingUnits) + if (block > 1) { + const perBlock = feature.ratePerUnit * block + return `$${Number(perBlock.toPrecision(6))}/${formatQuoteUnits(block)} ${unit}s` } return `$${feature.ratePerUnit}/${unit}` } diff --git a/apps/web/src/lib/billing/usage.ts b/apps/web/src/lib/billing/usage.ts index acab45e1be..54325de035 100644 --- a/apps/web/src/lib/billing/usage.ts +++ b/apps/web/src/lib/billing/usage.ts @@ -14,3 +14,20 @@ export function formatUsage(gb: number): string { export function formatCount(value: number): string { return Math.max(0, Math.round(value)).toLocaleString("en-US") } + +/** + * Events bill per 1,000 (so overage rounds to half a cent) but are quoted per + * million: "$5/1M events" reads better than "$0.005/1,000 events". + */ +const QUOTE_UNITS = { product_events: 1_000_000 } as const +const hasQuoteUnits = (featureId: string): featureId is keyof typeof QUOTE_UNITS => featureId in QUOTE_UNITS + +/** Units a feature's rate is quoted per: its own quote block, else the catalog's `billingUnits`. */ +export function quoteUnits(featureId: string, billingUnits: number): number { + return hasQuoteUnits(featureId) ? QUOTE_UNITS[featureId] : billingUnits +} + +/** A quote block as a label: "1M" for whole millions, else exact ("1,000"). */ +export function formatQuoteUnits(units: number): string { + return units >= 1_000_000 && units % 1_000_000 === 0 ? `${units / 1_000_000}M` : formatCount(units) +} diff --git a/docs/product-events-funnels.md b/docs/product-events-funnels.md index 88ed1dea58..baa8c0b706 100644 --- a/docs/product-events-funnels.md +++ b/docs/product-events-funnels.md @@ -44,11 +44,10 @@ rows in the batch (a browser `track()` call is the same unit as a server-side ev entitlement REJECTION on `browser_sessions`. **Neither path rejects on `product_events`**: an exhausted (or un-provisioned) product-events allowance bills usage-based overage instead of 402-ing a whole session transcript or a backend's buffered batch. Automatic session events (clicks, -navigations, ...) stay unmetered. **Beta pricing (2026-08-17): the `startup` item is -`unlimited: true` with no price.** Usage is tracked in Autumn (and shown on the billing page as -"Unlimited · free during beta") so real volumes are known before a price is set. To start -charging, swap `unlimited` for an `included` allowance + `price` in `apps/api/autumn.config.ts` (the commented example is $0.05 per 1,000 events past 1M/month) and -push. `DailySpendService` emits `DailyVolume.productEvents` from `product_events` +navigations, ...) stay unmetered. **Pricing (2026-10-15): 1M events/month included, then $5 per +million**, configured as `$0.005` per 1,000 in `apps/api/autumn.config.ts` because Autumn rounds +overage up to whole billing blocks. The landing page and billing UI quote it per 1M. It was +`unlimited: true` during the beta (2026-08-17 to 2026-10-15). `DailySpendService` emits `DailyVolume.productEvents` from `product_events` (`Kind != 'navigation'`, matching what the gateway meters). ## Where we are From 1958df1a80430213464a49296dad70794b888d12 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 15 Oct 2026 15:12:25 +0200 Subject: [PATCH 2/2] fix(landing): quote an events rate in the FAQ only when the catalog prices it Until the Autumn config is pushed the live catalog keeps product events unlimited, so the FAQ would have read "Unlimited are included, then $0 per million". The events sentence is now its own message, with the beta wording when there is no rate. --- apps/landing/messages/en.json | 4 +++- apps/landing/src/lib/pricing-faq.ts | 14 ++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/landing/messages/en.json b/apps/landing/messages/en.json index 7e3ad925b0..da27624166 100644 --- a/apps/landing/messages/en.json +++ b/apps/landing/messages/en.json @@ -802,7 +802,9 @@ "faq_metrics_q": "Do metrics cost extra?", "faq_metrics_a": "No. Metrics are metered by volume like logs and traces: {included} GB included, then {rate} per GB. There is no per-series, per-sample or custom-metric surcharge.", "faq_sessions_q": "How are browser sessions and product events billed?", - "faq_sessions_a": "A browser session is one visitor session recorded by the browser SDK, replay included. {included} are included each month, then {rate} per session. Product events are your track() calls, /v1/events posts and annotated spans: {events_included} are included each month, then {events_rate} per million. Automatic page views are not counted.", + "faq_sessions_a": "A browser session is one visitor session recorded by the browser SDK, replay included. {included} are included each month, then {rate} per session.", + "faq_events_priced": "Product events are your track() calls, /v1/events posts and annotated spans: {included} are included each month, then {rate} per million. Automatic page views are not counted.", + "faq_events_beta": "Product events are unlimited and free while they are in beta.", "faq_spend_q": "Can I cap what I spend?", "faq_spend_a": "Yes. Set an overage cap per signal in the billing settings, and usage alerts at the thresholds you choose. When a signal reaches its cap, ingest for that signal is rejected until the billing cycle resets. Caps and alerts run on the same usage your invoice is built from.", "stack_eyebrow": "What it works with", diff --git a/apps/landing/src/lib/pricing-faq.ts b/apps/landing/src/lib/pricing-faq.ts index 5de87d2811..b4558f9bc8 100644 --- a/apps/landing/src/lib/pricing-faq.ts +++ b/apps/landing/src/lib/pricing-faq.ts @@ -27,9 +27,12 @@ export async function pricingFaq(): Promise { const gbRate = rateLabel(gb?.rate ?? 0) const includedSessions = (sessions?.included ?? 0).toLocaleString("en-US") const sessionRate = rateLabel(sessions?.rate ?? 0) + // Quote an events rate only when the catalog prices them; an unlimited item has none. const events = offer.allotments.find((a) => a.unit === "events") - const eventsIncluded = events ? volume(events, events.included) : "0" - const eventsRate = money(events?.rate ?? 0) + const eventsAnswer = + events === undefined || events.unlimited || events.rate === undefined + ? m.faq_events_beta() + : m.faq_events_priced({ included: volume(events, events.included), rate: money(events.rate) }) return [ { question: m.faq_measure_q(), answer: m.faq_measure_a({ included: includedGB }) }, @@ -38,12 +41,7 @@ export async function pricingFaq(): Promise { { question: m.faq_metrics_q(), answer: m.faq_metrics_a({ included: includedGB, rate: gbRate }) }, { question: m.faq_sessions_q(), - answer: m.faq_sessions_a({ - included: includedSessions, - rate: sessionRate, - events_included: eventsIncluded, - events_rate: eventsRate, - }), + answer: `${m.faq_sessions_a({ included: includedSessions, rate: sessionRate })} ${eventsAnswer}`, }, { question: m.faq_retention_q(), answer: m.faq_retention_a() }, { question: m.faq_trial_q(), answer: m.faq_trial_a() },