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..da27624166 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,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 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.", + "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/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..b4558f9bc8 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,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 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 }) }, @@ -35,7 +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 }), + 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() }, 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