From 0192812a6422808b4cce692735bd8408cc86f50d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 21 Feb 2026 13:17:21 +0100 Subject: [PATCH 1/4] init --- apps/api/autumn.config.ts | 23 ++++++------- apps/api/src/autumn/sync-usage.ts | 14 ++++---- .../components/settings/billing-section.tsx | 6 ++-- .../src/components/settings/pricing-cards.tsx | 5 ++- .../src/components/settings/usage-meters.tsx | 28 ++++++++-------- apps/web/src/lib/billing/plans.ts | 10 +++--- apps/web/src/lib/billing/usage.ts | 33 ++++++++++--------- 7 files changed, 62 insertions(+), 57 deletions(-) diff --git a/apps/api/autumn.config.ts b/apps/api/autumn.config.ts index 9158ae6..208dd9d 100644 --- a/apps/api/autumn.config.ts +++ b/apps/api/autumn.config.ts @@ -33,28 +33,29 @@ export const starter = plan({ items: [ planFeature({ feature_id: 'logs', - included: 50, + included: 50_000_000, reset: { interval: 'month', }, }), planFeature({ + feature_id: 'metrics', - included: 50, + included: 50_000_000, reset: { interval: 'month', }, }), planFeature({ feature_id: 'traces', - included: 50, + included: 50_000_000, reset: { interval: 'month', }, }), ], free_trial: { - duration_length: 14, + duration_length: 30, duration_type: 'day', card_required: true, }, @@ -64,36 +65,36 @@ export const startup = plan({ id: 'startup', name: 'Startup', price: { - amount: 29, + amount: 39, interval: 'month', }, items: [ planFeature({ feature_id: 'logs', - included: 100, + included: 100_000_000, price: { amount: 0.25, - billing_units: 1, + billing_units: 1_000_000, billing_method: 'usage_based', interval: 'month', }, }), planFeature({ feature_id: 'metrics', - included: 100, + included: 100_000_000, price: { amount: 0.25, - billing_units: 1, + billing_units: 1_000_000, billing_method: 'usage_based', interval: 'month', }, }), planFeature({ feature_id: 'traces', - included: 100, + included: 100_000_000, price: { amount: 0.25, - billing_units: 1, + billing_units: 1_000_000, billing_method: 'usage_based', interval: 'month', }, diff --git a/apps/api/src/autumn/sync-usage.ts b/apps/api/src/autumn/sync-usage.ts index 309ddec..5ef0b93 100644 --- a/apps/api/src/autumn/sync-usage.ts +++ b/apps/api/src/autumn/sync-usage.ts @@ -1,6 +1,6 @@ import { Autumn } from "autumn-js" -const BYTES_PER_GB = 1_000_000_000 +const BYTES_PER_KB = 1_000 interface ServiceUsageRow { totalLogSizeBytes: number | bigint @@ -27,9 +27,9 @@ function aggregateUsage(rows: ServiceUsageRow[]) { } return { - logsGB: logBytes / BYTES_PER_GB, - tracesGB: traceBytes / BYTES_PER_GB, - metricsGB: metricBytes / BYTES_PER_GB, + logsKB: logBytes / BYTES_PER_KB, + tracesKB: traceBytes / BYTES_PER_KB, + metricsKB: metricBytes / BYTES_PER_KB, } } @@ -41,9 +41,9 @@ export async function syncOrgUsage( const usage = aggregateUsage(usageRows) await Promise.all([ - autumn.track({ customer_id: orgId, feature_id: "logs_gb", value: usage.logsGB }), - autumn.track({ customer_id: orgId, feature_id: "traces_gb", value: usage.tracesGB }), - autumn.track({ customer_id: orgId, feature_id: "metrics_gb", value: usage.metricsGB }), + autumn.track({ customer_id: orgId, feature_id: "logs", value: usage.logsKB }), + autumn.track({ customer_id: orgId, feature_id: "traces", value: usage.tracesKB }), + autumn.track({ customer_id: orgId, feature_id: "metrics", value: usage.metricsKB }), ]) return usage diff --git a/apps/web/src/components/settings/billing-section.tsx b/apps/web/src/components/settings/billing-section.tsx index fbd8a44..7bb656b 100644 --- a/apps/web/src/components/settings/billing-section.tsx +++ b/apps/web/src/components/settings/billing-section.tsx @@ -18,9 +18,9 @@ function limitsFromCustomer( if (!features) return null const defaults = getPlanLimits("free") return { - logsGB: features.logs_gb?.included_usage ?? defaults.logsGB, - tracesGB: features.traces_gb?.included_usage ?? defaults.tracesGB, - metricsGB: features.metrics_gb?.included_usage ?? defaults.metricsGB, + logsKB: features.logs?.included_usage ?? defaults.logsKB, + tracesKB: features.traces?.included_usage ?? defaults.tracesKB, + metricsKB: features.metrics?.included_usage ?? defaults.metricsKB, retentionDays: features.retention_days?.balance ?? defaults.retentionDays, } } diff --git a/apps/web/src/components/settings/pricing-cards.tsx b/apps/web/src/components/settings/pricing-cards.tsx index f24245d..36ccaa5 100644 --- a/apps/web/src/components/settings/pricing-cards.tsx +++ b/apps/web/src/components/settings/pricing-cards.tsx @@ -72,7 +72,10 @@ function getProductPrice(product: Product): { function formatIncludedUsage(item: ProductItem): string { if (item.included_usage === "inf") return "Unlimited" - if (item.included_usage != null) return `${item.included_usage} GB` + if (item.included_usage != null) { + const gb = Number(item.included_usage) / 1_000_000 + return `${gb} GB` + } return "" } diff --git a/apps/web/src/components/settings/usage-meters.tsx b/apps/web/src/components/settings/usage-meters.tsx index 8cef8a8..6c7cd1f 100644 --- a/apps/web/src/components/settings/usage-meters.tsx +++ b/apps/web/src/components/settings/usage-meters.tsx @@ -14,21 +14,21 @@ import { type IconComponent, } from "@/components/icons" import type { AggregatedUsage } from "@/lib/billing/usage" -import { formatGB, usagePercentage } from "@/lib/billing/usage" +import { formatUsage, usagePercentage } from "@/lib/billing/usage" import type { PlanLimits } from "@/lib/billing/plans" import { cn } from "@maple/ui/utils" interface MeterRowProps { icon: IconComponent label: string - usedGB: number - limitGB: number + usedKB: number + limitKB: number } -function MeterRow({ icon: Icon, label, usedGB, limitGB }: MeterRowProps) { - const pct = usagePercentage(usedGB, limitGB) - const isUnlimited = limitGB === Infinity - const limitLabel = isUnlimited ? "Unlimited" : formatGB(limitGB) +function MeterRow({ icon: Icon, label, usedKB, limitKB }: MeterRowProps) { + const pct = usagePercentage(usedKB, limitKB) + const isUnlimited = limitKB === Infinity + const limitLabel = isUnlimited ? "Unlimited" : formatUsage(limitKB) return ( @@ -38,7 +38,7 @@ function MeterRow({ icon: Icon, label, usedGB, limitGB }: MeterRowProps) { {label} - {formatGB(usedGB)} / {limitLabel} + {formatUsage(usedKB)} / {limitLabel} @@ -78,20 +78,20 @@ export function UsageMeters({ diff --git a/apps/web/src/lib/billing/plans.ts b/apps/web/src/lib/billing/plans.ts index 04ae90d..2acba9d 100644 --- a/apps/web/src/lib/billing/plans.ts +++ b/apps/web/src/lib/billing/plans.ts @@ -1,13 +1,13 @@ export interface PlanLimits { - logsGB: number - tracesGB: number - metricsGB: number + logsKB: number + tracesKB: number + metricsKB: number retentionDays: number } export const PLAN_LIMITS: Record = { - free: { logsGB: 10, tracesGB: 10, metricsGB: 10, retentionDays: 7 }, - startup: { logsGB: 40, tracesGB: 40, metricsGB: 40, retentionDays: 30 }, + free: { logsKB: 10_000_000, tracesKB: 10_000_000, metricsKB: 10_000_000, retentionDays: 7 }, + startup: { logsKB: 40_000_000, tracesKB: 40_000_000, metricsKB: 40_000_000, retentionDays: 30 }, } const DEFAULT_PLAN = "free" diff --git a/apps/web/src/lib/billing/usage.ts b/apps/web/src/lib/billing/usage.ts index 4572929..d2296d0 100644 --- a/apps/web/src/lib/billing/usage.ts +++ b/apps/web/src/lib/billing/usage.ts @@ -1,32 +1,33 @@ import type { ServiceUsage } from "@/api/tinybird/service-usage" -const BYTES_PER_GB = 1_000_000_000 +const BYTES_PER_KB = 1_000 export interface AggregatedUsage { - logsGB: number - tracesGB: number - metricsGB: number + logsKB: number + tracesKB: number + metricsKB: number } export function aggregateUsage(services: ServiceUsage[]): AggregatedUsage { return services.reduce( (acc, s) => ({ - logsGB: acc.logsGB + s.logSizeBytes / BYTES_PER_GB, - tracesGB: acc.tracesGB + s.traceSizeBytes / BYTES_PER_GB, - metricsGB: acc.metricsGB + s.metricSizeBytes / BYTES_PER_GB, + logsKB: acc.logsKB + s.logSizeBytes / BYTES_PER_KB, + tracesKB: acc.tracesKB + s.traceSizeBytes / BYTES_PER_KB, + metricsKB: acc.metricsKB + s.metricSizeBytes / BYTES_PER_KB, }), - { logsGB: 0, tracesGB: 0, metricsGB: 0 }, + { logsKB: 0, tracesKB: 0, metricsKB: 0 }, ) } -export function usagePercentage(usedGB: number, limitGB: number): number { - if (limitGB === Infinity) return 0 - if (limitGB === 0) return 100 - return (usedGB / limitGB) * 100 +export function usagePercentage(usedKB: number, limitKB: number): number { + if (limitKB === Infinity) return 0 + if (limitKB === 0) return 100 + return (usedKB / limitKB) * 100 } -export function formatGB(gb: number): string { - if (gb < 0.01) return "0 GB" - if (gb < 1) return `${(gb * 1000).toFixed(0)} MB` - return `${gb.toFixed(2)} GB` +export function formatUsage(kb: number): string { + if (kb < 1) return "0 KB" + if (kb < 1_000) return `${kb.toFixed(0)} KB` + if (kb < 1_000_000) return `${(kb / 1_000).toFixed(0)} MB` + return `${(kb / 1_000_000).toFixed(2)} GB` } From 36f2e3bab11b57852305c09fa877607ba566e0da Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 21 Feb 2026 13:32:37 +0100 Subject: [PATCH 2/4] fix --- .../landing/src/components/PricingTable.astro | 6 +- .../components/settings/billing-section.tsx | 90 ++++++++----------- .../src/components/settings/pricing-cards.tsx | 8 +- apps/web/src/lib/billing/plans.ts | 4 +- 4 files changed, 45 insertions(+), 63 deletions(-) diff --git a/apps/landing/src/components/PricingTable.astro b/apps/landing/src/components/PricingTable.astro index 41f560e..f749a6d 100644 --- a/apps/landing/src/components/PricingTable.astro +++ b/apps/landing/src/components/PricingTable.astro @@ -60,11 +60,11 @@ try { .filter((item) => item.feature_id) .map((item) => ({ label: item.feature?.name ?? item.feature_id ?? "", - value: item.included_usage === "inf" ? "Unlimited" : `${item.included_usage ?? 0} GB`, + value: item.included_usage === "inf" ? "Unlimited" : `${Number(item.included_usage ?? 0) / 1_000_000} GB`, detail: item.display?.secondary_text - ? item.display.secondary_text.replace(/\bper\s+(Logs|Traces|Metrics)\b/i, "per GB") + ? item.display.secondary_text.replace(/\bper\s+[\d,]+\s+(Logs|Traces|Metrics)\b/i, "per GB") : item.price != null - ? `then $${item.price}/${item.billing_units ?? 1} GB` + ? `then $${item.price} per GB` : undefined, })) diff --git a/apps/web/src/components/settings/billing-section.tsx b/apps/web/src/components/settings/billing-section.tsx index 7bb656b..8084913 100644 --- a/apps/web/src/components/settings/billing-section.tsx +++ b/apps/web/src/components/settings/billing-section.tsx @@ -1,22 +1,19 @@ import { useMemo } from "react" -import { Result, useAtomValue } from "@effect-atom/atom-react" import { useCustomer } from "autumn-js/react" import { PricingCards } from "./pricing-cards" import { format } from "date-fns" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { Card, CardContent, CardHeader, CardTitle } from "@maple/ui/components/ui/card" -import { getServiceUsageResultAtom } from "@/lib/services/atoms/tinybird-query-atoms" -import { formatForTinybird } from "@/lib/time-utils" -import { aggregateUsage } from "@/lib/billing/usage" +import { Card, CardContent, CardHeader } from "@maple/ui/components/ui/card" import { getPlanLimits, type PlanLimits } from "@/lib/billing/plans" +import type { AggregatedUsage } from "@/lib/billing/usage" import { UsageMeters } from "./usage-meters" -function limitsFromCustomer( - features: Record | undefined, -): PlanLimits | null { +type CustomerFeatures = Record | undefined + +function limitsFromCustomer(features: CustomerFeatures): PlanLimits | null { if (!features) return null - const defaults = getPlanLimits("free") + const defaults = getPlanLimits("starter") return { logsKB: features.logs?.included_usage ?? defaults.logsKB, tracesKB: features.traces?.included_usage ?? defaults.tracesKB, @@ -25,6 +22,15 @@ function limitsFromCustomer( } } +function usageFromCustomer(features: CustomerFeatures): AggregatedUsage { + if (!features) return { logsKB: 0, tracesKB: 0, metricsKB: 0 } + return { + logsKB: features.logs?.usage ?? 0, + tracesKB: features.traces?.usage ?? 0, + metricsKB: features.metrics?.usage ?? 0, + } +} + export function BillingSection() { const { customer, isLoading: isCustomerLoading } = useCustomer() @@ -33,56 +39,32 @@ export function BillingSection() { () => new Date(now.getFullYear(), now.getMonth(), 1), [now], ) - const startTime = useMemo(() => formatForTinybird(startOfMonth), [startOfMonth]) - const endTime = useMemo(() => formatForTinybird(now), [now]) - const billingPeriodLabel = `${format(startOfMonth, "MMM d")} – ${format(now, "MMM d, yyyy")}` - const limits = limitsFromCustomer(customer?.features) ?? getPlanLimits("free") - - const usageResult = useAtomValue( - getServiceUsageResultAtom({ data: { startTime, endTime } }), - ) + const limits = limitsFromCustomer(customer?.features) ?? getPlanLimits("starter") + const usage = usageFromCustomer(customer?.features) return (
- {Result.builder(usageResult) - .onInitial(() => ( - - - - - - - - - - - - )) - .onError(() => ( - - - Current Usage - - -

- Unable to load usage data. -

-
-
- )) - .onSuccess((response) => { - const usage = aggregateUsage(response.data) - return ( - - ) - }) - .render()} + {isCustomerLoading ? ( + + + + + + + + + + + + ) : ( + + )}

Plans

diff --git a/apps/web/src/components/settings/pricing-cards.tsx b/apps/web/src/components/settings/pricing-cards.tsx index 36ccaa5..dcd90f7 100644 --- a/apps/web/src/components/settings/pricing-cards.tsx +++ b/apps/web/src/components/settings/pricing-cards.tsx @@ -45,11 +45,11 @@ const FEATURE_ICONS: Record = { } function getProductSlug(product: Product): string { - if (product.properties.is_free) return "free" + if (product.properties.is_free) return "starter" const id = product.id?.toLowerCase() - if (id === "free" || id === "startup") return id + if (id === "starter" || id === "startup") return id const name = product.name?.toLowerCase() - if (name === "free" || name === "startup") return name + if (name === "starter" || name === "startup") return name return "startup" } @@ -80,7 +80,7 @@ function formatIncludedUsage(item: ProductItem): string { } function normalizeDetailText(text: string): string { - return text.replace(/\bper\s+(Logs|Traces|Metrics)\b/i, "per GB") + return text.replace(/\bper\s+[\d,]+\s+(Logs|Traces|Metrics)\b/i, "per GB") } function getFeatureRows(product: Product) { diff --git a/apps/web/src/lib/billing/plans.ts b/apps/web/src/lib/billing/plans.ts index 2acba9d..1e21c2f 100644 --- a/apps/web/src/lib/billing/plans.ts +++ b/apps/web/src/lib/billing/plans.ts @@ -6,11 +6,11 @@ export interface PlanLimits { } export const PLAN_LIMITS: Record = { - free: { logsKB: 10_000_000, tracesKB: 10_000_000, metricsKB: 10_000_000, retentionDays: 7 }, + starter: { logsKB: 10_000_000, tracesKB: 10_000_000, metricsKB: 10_000_000, retentionDays: 7 }, startup: { logsKB: 40_000_000, tracesKB: 40_000_000, metricsKB: 40_000_000, retentionDays: 30 }, } -const DEFAULT_PLAN = "free" +const DEFAULT_PLAN = "starter" export function getPlanLimits(planSlug: string | undefined): PlanLimits { return PLAN_LIMITS[planSlug ?? DEFAULT_PLAN] ?? PLAN_LIMITS[DEFAULT_PLAN] From c45670340d1ddf128f12b4192daf0858aaed8883 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 21 Feb 2026 13:59:26 +0100 Subject: [PATCH 3/4] fix --- apps/api/@useautumn-sdk.d.ts | 1 - apps/api/autumn.config.ts | 20 +- apps/api/src/autumn/sync-usage.ts | 50 ---- apps/ingest/Cargo.lock | 3 + apps/ingest/Cargo.toml | 3 +- apps/ingest/src/autumn.rs | 239 ++++++++++++++++++ apps/ingest/src/main.rs | 52 +++- .../landing/src/components/PricingTable.astro | 32 +-- apps/landing/src/pages/pricing.astro | 2 +- .../components/settings/billing-section.tsx | 14 +- .../src/components/settings/pricing-cards.tsx | 15 +- .../src/components/settings/usage-meters.tsx | 26 +- apps/web/src/lib/billing/plans.ts | 19 +- apps/web/src/lib/billing/usage.ts | 33 ++- packages/infra/src/railway/index.ts | 1 + 15 files changed, 376 insertions(+), 134 deletions(-) delete mode 100644 apps/api/src/autumn/sync-usage.ts create mode 100644 apps/ingest/src/autumn.rs diff --git a/apps/api/@useautumn-sdk.d.ts b/apps/api/@useautumn-sdk.d.ts index eb713bb..ca3d256 100644 --- a/apps/api/@useautumn-sdk.d.ts +++ b/apps/api/@useautumn-sdk.d.ts @@ -11,7 +11,6 @@ declare module '@useautumn/sdk' { export const free: Plan; export const starter: Plan; export const startup: Plan; - export const team: Plan; // Base types export type Feature = import('./autumn.config').Feature; diff --git a/apps/api/autumn.config.ts b/apps/api/autumn.config.ts index 208dd9d..76a8731 100644 --- a/apps/api/autumn.config.ts +++ b/apps/api/autumn.config.ts @@ -33,22 +33,22 @@ export const starter = plan({ items: [ planFeature({ feature_id: 'logs', - included: 50_000_000, + included: 50, reset: { interval: 'month', }, }), planFeature({ - + feature_id: 'metrics', - included: 50_000_000, + included: 50, reset: { interval: 'month', }, }), planFeature({ feature_id: 'traces', - included: 50_000_000, + included: 50, reset: { interval: 'month', }, @@ -71,30 +71,30 @@ export const startup = plan({ items: [ planFeature({ feature_id: 'logs', - included: 100_000_000, + included: 100, price: { amount: 0.25, - billing_units: 1_000_000, + billing_units: 1, billing_method: 'usage_based', interval: 'month', }, }), planFeature({ feature_id: 'metrics', - included: 100_000_000, + included: 100, price: { amount: 0.25, - billing_units: 1_000_000, + billing_units: 1, billing_method: 'usage_based', interval: 'month', }, }), planFeature({ feature_id: 'traces', - included: 100_000_000, + included: 100, price: { amount: 0.25, - billing_units: 1_000_000, + billing_units: 1, billing_method: 'usage_based', interval: 'month', }, diff --git a/apps/api/src/autumn/sync-usage.ts b/apps/api/src/autumn/sync-usage.ts deleted file mode 100644 index 5ef0b93..0000000 --- a/apps/api/src/autumn/sync-usage.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Autumn } from "autumn-js" - -const BYTES_PER_KB = 1_000 - -interface ServiceUsageRow { - totalLogSizeBytes: number | bigint - totalTraceSizeBytes: number | bigint - totalSumMetricSizeBytes: number | bigint - totalGaugeMetricSizeBytes: number | bigint - totalHistogramMetricSizeBytes: number | bigint - totalExpHistogramMetricSizeBytes: number | bigint -} - -function aggregateUsage(rows: ServiceUsageRow[]) { - let logBytes = 0 - let traceBytes = 0 - let metricBytes = 0 - - for (const row of rows) { - logBytes += Number(row.totalLogSizeBytes ?? 0) - traceBytes += Number(row.totalTraceSizeBytes ?? 0) - metricBytes += - Number(row.totalSumMetricSizeBytes ?? 0) + - Number(row.totalGaugeMetricSizeBytes ?? 0) + - Number(row.totalHistogramMetricSizeBytes ?? 0) + - Number(row.totalExpHistogramMetricSizeBytes ?? 0) - } - - return { - logsKB: logBytes / BYTES_PER_KB, - tracesKB: traceBytes / BYTES_PER_KB, - metricsKB: metricBytes / BYTES_PER_KB, - } -} - -export async function syncOrgUsage( - autumn: Autumn, - orgId: string, - usageRows: ServiceUsageRow[], -) { - const usage = aggregateUsage(usageRows) - - await Promise.all([ - autumn.track({ customer_id: orgId, feature_id: "logs", value: usage.logsKB }), - autumn.track({ customer_id: orgId, feature_id: "traces", value: usage.tracesKB }), - autumn.track({ customer_id: orgId, feature_id: "metrics", value: usage.metricsKB }), - ]) - - return usage -} diff --git a/apps/ingest/Cargo.lock b/apps/ingest/Cargo.lock index 412ef7c..903384e 100644 --- a/apps/ingest/Cargo.lock +++ b/apps/ingest/Cargo.lock @@ -1508,6 +1508,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "uuid", ] [[package]] @@ -2153,6 +2154,8 @@ dependencies = [ "rustls 0.23.36", "rustls-pki-types", "rustls-platform-verifier", + "serde", + "serde_json", "sync_wrapper 1.0.2", "tokio", "tokio-rustls 0.26.4", diff --git a/apps/ingest/Cargo.toml b/apps/ingest/Cargo.toml index cc9d043..79e00e0 100644 --- a/apps/ingest/Cargo.toml +++ b/apps/ingest/Cargo.toml @@ -12,7 +12,7 @@ libsql = "0.9.29" dotenvy = "0.15.7" opentelemetry-proto = { version = "0.31.0", features = ["gen-tonic-messages", "trace", "logs", "metrics", "with-serde"] } prost = "0.14.3" -reqwest = { version = "0.13.2", default-features = false, features = ["rustls", "http2"] } +reqwest = { version = "0.13.2", default-features = false, features = ["rustls", "http2", "json"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" sha2 = "0.10.9" @@ -21,5 +21,6 @@ tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter"] } tower-http = { version = "0.6", features = ["cors"] } url = "2.5.7" +uuid = { version = "1", features = ["v4"] } metrics = "0.24" metrics-exporter-prometheus = "0.16" diff --git a/apps/ingest/src/autumn.rs b/apps/ingest/src/autumn.rs new file mode 100644 index 0000000..0712e5c --- /dev/null +++ b/apps/ingest/src/autumn.rs @@ -0,0 +1,239 @@ +use std::collections::HashMap; +use std::time::Duration; + +use metrics::{counter, gauge, histogram}; +use reqwest::Client; +use serde::Serialize; +use tokio::sync::mpsc; +use tokio::time::Instant; +use tracing::{error, info, warn}; +use uuid::Uuid; + +pub struct UsageEvent { + pub org_id: String, + pub feature_id: &'static str, + pub value_gb: f64, +} + +#[derive(Clone)] +pub struct AutumnTracker { + tx: mpsc::UnboundedSender, +} + +#[derive(Serialize)] +struct TrackRequest<'a> { + customer_id: &'a str, + feature_id: &'a str, + value: f64, + idempotency_key: String, +} + +impl AutumnTracker { + pub fn spawn(secret_key: String, api_url: &str, flush_interval_secs: u64) -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let api_url = api_url.trim_end_matches('/').to_string(); + let flush_interval = Duration::from_secs(flush_interval_secs); + + tokio::spawn(flush_loop(rx, secret_key, api_url, flush_interval)); + + info!( + flush_interval_secs, + "Autumn usage tracker started" + ); + + Self { tx } + } + + pub fn track(&self, org_id: &str, feature_id: &'static str, value_gb: f64) { + let _ = self.tx.send(UsageEvent { + org_id: org_id.to_string(), + feature_id, + value_gb, + }); + } +} + +type AccumulatorKey = (String, &'static str); // (org_id, feature_id) + +async fn flush_loop( + mut rx: mpsc::UnboundedReceiver, + secret_key: String, + api_url: String, + flush_interval: Duration, +) { + let client = Client::new(); + let mut accumulator: HashMap = HashMap::new(); + let mut consecutive_failures: u64 = 0; + let critical_threshold: u64 = (300 / flush_interval.as_secs().max(1)).max(1); + + let mut interval = tokio::time::interval(flush_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => { + if accumulator.is_empty() { + continue; + } + + let flush_start = Instant::now(); + let mut all_ok = true; + + // Collect entries to flush + let entries: Vec<(AccumulatorKey, f64)> = accumulator + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); + + let mut flushed_keys: Vec = Vec::new(); + + for ((org_id, feature_id), value_gb) in &entries { + let body = TrackRequest { + customer_id: org_id, + feature_id, + value: *value_gb, + idempotency_key: Uuid::new_v4().to_string(), + }; + + let result: Result = client + .post(format!("{}/v1/track", api_url)) + .header("Authorization", format!("Bearer {}", secret_key)) + .json(&body) + .send() + .await; + + match result { + Ok(resp) if resp.status().is_success() => { + flushed_keys.push((org_id.clone(), feature_id)); + } + Ok(resp) => { + let status = resp.status(); + let body_text = resp.text().await.unwrap_or_default(); + warn!( + org_id, + feature_id, + status = %status, + body = %body_text, + "Autumn track request failed" + ); + all_ok = false; + } + Err(err) => { + warn!( + org_id, + feature_id, + error = %err, + "Autumn track request failed" + ); + all_ok = false; + } + } + } + + // Remove successfully flushed entries + for key in &flushed_keys { + accumulator.remove(key); + } + + let flush_duration = flush_start.elapsed(); + histogram!("autumn_track_flush_duration_seconds") + .record(flush_duration.as_secs_f64()); + + if all_ok { + consecutive_failures = 0; + counter!("autumn_track_flushes_total", "status" => "ok") + .increment(1); + } else { + consecutive_failures += 1; + counter!("autumn_track_flushes_total", "status" => "error") + .increment(1); + + if consecutive_failures >= critical_threshold { + let total_pending_gb: f64 = accumulator.values().sum(); + error!( + consecutive_failures, + pending_entries = accumulator.len(), + total_pending_gb, + "CRITICAL: Autumn tracking has failed for ~5 minutes. Usage data is accumulating in memory." + ); + } + } + + // Update pending gauge + let total_pending: f64 = accumulator.values().sum(); + gauge!("autumn_track_pending_gb").set(total_pending); + } + + event = rx.recv() => { + match event { + Some(event) => { + *accumulator + .entry((event.org_id, event.feature_id)) + .or_insert(0.0) += event.value_gb; + } + None => { + // Channel closed, do a final flush attempt + if !accumulator.is_empty() { + info!( + pending_entries = accumulator.len(), + "Autumn tracker shutting down, attempting final flush" + ); + flush_all(&client, &secret_key, &api_url, &mut accumulator).await; + } + break; + } + } + } + } + } +} + +async fn flush_all( + client: &Client, + secret_key: &str, + api_url: &str, + accumulator: &mut HashMap, +) { + let entries: Vec<(AccumulatorKey, f64)> = accumulator + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); + + for ((org_id, feature_id), value_gb) in &entries { + let body = TrackRequest { + customer_id: org_id, + feature_id, + value: *value_gb, + idempotency_key: Uuid::new_v4().to_string(), + }; + + let result: Result = client + .post(format!("{}/v1/track", api_url)) + .header("Authorization", format!("Bearer {}", secret_key)) + .json(&body) + .send() + .await; + + match result { + Ok(resp) if resp.status().is_success() => { + accumulator.remove(&(org_id.clone(), feature_id)); + } + Ok(resp) => { + warn!( + org_id, + feature_id, + status = %resp.status(), + "Final flush failed for entry" + ); + } + Err(err) => { + warn!( + org_id, + feature_id, + error = %err, + "Final flush failed for entry" + ); + } + } + } +} diff --git a/apps/ingest/src/main.rs b/apps/ingest/src/main.rs index f53fe05..9a7123d 100644 --- a/apps/ingest/src/main.rs +++ b/apps/ingest/src/main.rs @@ -1,8 +1,11 @@ +mod autumn; + use std::io::{Read, Write}; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; +use autumn::AutumnTracker; use axum::body::Bytes; use axum::extract::DefaultBodyLimit; use axum::extract::State; @@ -45,6 +48,9 @@ struct AppConfig { db_url: Option, db_auth_token: Option, lookup_hmac_key: String, + autumn_secret_key: Option, + autumn_api_url: String, + autumn_flush_interval_secs: u64, } impl AppConfig { @@ -111,6 +117,23 @@ impl AppConfig { return Err("MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY is required".to_string()); } + let autumn_secret_key = std::env::var("AUTUMN_SECRET_KEY") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()); + + let autumn_api_url = std::env::var("AUTUMN_API_URL") + .unwrap_or_else(|_| "https://api.useautumn.com".to_string()) + .trim() + .trim_end_matches('/') + .to_string(); + + let autumn_flush_interval_secs = parse_u64( + "AUTUMN_FLUSH_INTERVAL_SECS", + std::env::var("AUTUMN_FLUSH_INTERVAL_SECS").ok(), + 1, + )?; + Ok(Self { port, forward_endpoint, @@ -120,6 +143,9 @@ impl AppConfig { db_url, db_auth_token, lookup_hmac_key, + autumn_secret_key, + autumn_api_url, + autumn_flush_interval_secs, }) } } @@ -130,12 +156,12 @@ struct IngestKeyResolver { lookup_hmac_key: String, } -#[derive(Clone)] struct AppState { config: AppConfig, http_client: Client, resolver: IngestKeyResolver, metrics_handle: metrics_exporter_prometheus::PrometheusHandle, + autumn_tracker: Option, } #[derive(Clone)] @@ -282,6 +308,14 @@ async fn main() { } }; + let autumn_tracker = config.autumn_secret_key.as_ref().map(|key| { + AutumnTracker::spawn( + key.clone(), + &config.autumn_api_url, + config.autumn_flush_interval_secs, + ) + }); + let state = Arc::new(AppState { resolver: IngestKeyResolver { db: Arc::new(database), @@ -290,6 +324,7 @@ async fn main() { http_client, config: config.clone(), metrics_handle: prometheus_handle, + autumn_tracker, }); let cors = CorsLayer::new() @@ -392,12 +427,17 @@ async fn handle_signal( let duration_ms = duration.as_millis() as u64; match result { - Ok((response, item_count, _org_id)) => { + Ok((response, item_count, org_id, decoded_bytes)) => { let status_code = response.status().as_u16(); histogram!("ingest_request_duration_seconds", "signal" => signal.path(), "status" => "ok") .record(duration.as_secs_f64()); counter!("ingest_requests_total", "signal" => signal.path(), "status" => "ok", "error_kind" => "none") .increment(1); + if let Some(tracker) = &state.autumn_tracker { + let feature_id = signal.path(); + let value_gb = decoded_bytes as f64 / 1_000_000_000.0; + tracker.track(&org_id, feature_id, value_gb); + } info!( status = status_code, duration_ms, @@ -416,13 +456,13 @@ async fn handle_signal( } } -/// Returns Ok((response, item_count, org_id)) or Err((ApiError, error_kind_label)) +/// Returns Ok((response, item_count, org_id, decoded_bytes)) or Err((ApiError, error_kind_label)) async fn handle_signal_inner( state: &AppState, headers: &HeaderMap, body: Bytes, signal: Signal, -) -> Result<(Response, usize, String), (ApiError, &'static str)> { +) -> Result<(Response, usize, String, usize), (ApiError, &'static str)> { // --- Auth --- let ingest_key = extract_ingest_key(headers).ok_or_else(|| { warn!("Missing ingest key"); @@ -521,6 +561,8 @@ async fn handle_signal_inner( ) .increment(enrich_result.item_count as u64); + let decoded_bytes = decoded_payload.len(); + // --- Encode & Forward --- let outbound_body = encode_payload(&enrich_result.payload, content_encoding.as_deref()).map_err(|e| { @@ -538,7 +580,7 @@ async fn handle_signal_inner( .await .map_err(|e| (e, "forward"))?; - Ok((response, enrich_result.item_count, resolved_key.org_id.clone())) + Ok((response, enrich_result.item_count, resolved_key.org_id.clone(), decoded_bytes)) } fn extract_ingest_key(headers: &HeaderMap) -> Option { diff --git a/apps/landing/src/components/PricingTable.astro b/apps/landing/src/components/PricingTable.astro index f749a6d..c4ea21d 100644 --- a/apps/landing/src/components/PricingTable.astro +++ b/apps/landing/src/components/PricingTable.astro @@ -60,7 +60,7 @@ try { .filter((item) => item.feature_id) .map((item) => ({ label: item.feature?.name ?? item.feature_id ?? "", - value: item.included_usage === "inf" ? "Unlimited" : `${Number(item.included_usage ?? 0) / 1_000_000} GB`, + value: item.included_usage === "inf" ? "Unlimited" : `${Number(item.included_usage ?? 0)} GB`, detail: item.display?.secondary_text ? item.display.secondary_text.replace(/\bper\s+[\d,]+\s+(Logs|Traces|Metrics)\b/i, "per GB") : item.price != null @@ -78,10 +78,10 @@ try { interval: interval ? `/${interval}` : undefined, isFree, hasTrial: product.properties.has_trial, - subtitle: isFree ? "Free forever" : (product.display?.description ?? "For growing teams"), + subtitle: isFree ? "Free forever" : (product.display?.description ?? (slug === "starter" ? "For individuals and small projects" : "For growing teams")), dataFeatures, platformFeatures, - ctaLabel: isFree ? "Get started free" : (product.free_trial ? "Start free trial" : "Subscribe"), + ctaLabel: isFree ? "Get started free" : (product.free_trial ? `Start ${product.free_trial.length}-day free trial` : "Subscribe"), ctaStyle: isFree ? "outline" as const : "accent" as const, } }) @@ -123,7 +123,7 @@ if (plans.length === 0) { { label: "Metrics", value: "100 GB", detail: "then $0.25/GB" }, ], platformFeatures: PLAN_FEATURES["startup"]!.map((f) => ({ label: f.label })), - ctaLabel: "Start free trial", + ctaLabel: "Start 30-day free trial", ctaStyle: "accent", }, ] @@ -134,8 +134,8 @@ if (plans.length === 0) {
{plans.map((plan) => ( -
-
+
+
{plan.name}
${plan.price} @@ -185,15 +185,17 @@ if (plans.length === 0) {
- {plan.ctaStyle === "accent" ? ( - - {plan.ctaLabel} - - ) : ( - - {plan.ctaLabel} - - )} +
+ {plan.ctaStyle === "accent" ? ( + + {plan.ctaLabel} + + ) : ( + + {plan.ctaLabel} + + )} +
))} diff --git a/apps/landing/src/pages/pricing.astro b/apps/landing/src/pages/pricing.astro index b7433d2..e7aa729 100644 --- a/apps/landing/src/pages/pricing.astro +++ b/apps/landing/src/pages/pricing.astro @@ -34,7 +34,7 @@ const faqItems = [ }, { question: "Do you offer a free trial for paid plans?", - answer: "Yes, paid plans include a free trial so you can explore all features before committing." + answer: "Yes, paid plans include a 30-day free trial so you can explore all features before committing." } ]; --- diff --git a/apps/web/src/components/settings/billing-section.tsx b/apps/web/src/components/settings/billing-section.tsx index 8084913..02d9e49 100644 --- a/apps/web/src/components/settings/billing-section.tsx +++ b/apps/web/src/components/settings/billing-section.tsx @@ -15,19 +15,19 @@ function limitsFromCustomer(features: CustomerFeatures): PlanLimits | null { if (!features) return null const defaults = getPlanLimits("starter") return { - logsKB: features.logs?.included_usage ?? defaults.logsKB, - tracesKB: features.traces?.included_usage ?? defaults.tracesKB, - metricsKB: features.metrics?.included_usage ?? defaults.metricsKB, + logsGB: features.logs?.included_usage ?? defaults.logsGB, + tracesGB: features.traces?.included_usage ?? defaults.tracesGB, + metricsGB: features.metrics?.included_usage ?? defaults.metricsGB, retentionDays: features.retention_days?.balance ?? defaults.retentionDays, } } function usageFromCustomer(features: CustomerFeatures): AggregatedUsage { - if (!features) return { logsKB: 0, tracesKB: 0, metricsKB: 0 } + if (!features) return { logsGB: 0, tracesGB: 0, metricsGB: 0 } return { - logsKB: features.logs?.usage ?? 0, - tracesKB: features.traces?.usage ?? 0, - metricsKB: features.metrics?.usage ?? 0, + logsGB: features.logs?.usage ?? 0, + tracesGB: features.traces?.usage ?? 0, + metricsGB: features.metrics?.usage ?? 0, } } diff --git a/apps/web/src/components/settings/pricing-cards.tsx b/apps/web/src/components/settings/pricing-cards.tsx index dcd90f7..5c3da04 100644 --- a/apps/web/src/components/settings/pricing-cards.tsx +++ b/apps/web/src/components/settings/pricing-cards.tsx @@ -8,7 +8,7 @@ type Product = NonNullable< type ProductItem = Product["items"][number] import { cn } from "@maple/ui/utils" -import { getPlanFeatures } from "@/lib/billing/plans" +import { getPlanFeatures, getPlanDescription } from "@/lib/billing/plans" import { Card, CardContent, @@ -73,8 +73,7 @@ function getProductPrice(product: Product): { function formatIncludedUsage(item: ProductItem): string { if (item.included_usage === "inf") return "Unlimited" if (item.included_usage != null) { - const gb = Number(item.included_usage) / 1_000_000 - return `${gb} GB` + return `${Number(item.included_usage)} GB` } return "" } @@ -350,11 +349,9 @@ export function PricingCards() { )}
- {product.display?.description && ( - - {product.display.description} - - )} + + {product.display?.description ?? getPlanDescription(getProductSlug(product))} + {product.display?.everything_from && (

Everything in {product.display.everything_from}, plus: @@ -442,7 +439,7 @@ export function PricingCards() { ) : ( <> {btn.label} - {trialAvailable && !btn.disabled && " — Start free trial"} + {trialAvailable && !btn.disabled && ` — Start ${product.free_trial?.length}-day free trial`} )} diff --git a/apps/web/src/components/settings/usage-meters.tsx b/apps/web/src/components/settings/usage-meters.tsx index 6c7cd1f..e460921 100644 --- a/apps/web/src/components/settings/usage-meters.tsx +++ b/apps/web/src/components/settings/usage-meters.tsx @@ -21,14 +21,14 @@ import { cn } from "@maple/ui/utils" interface MeterRowProps { icon: IconComponent label: string - usedKB: number - limitKB: number + usedGB: number + limitGB: number } -function MeterRow({ icon: Icon, label, usedKB, limitKB }: MeterRowProps) { - const pct = usagePercentage(usedKB, limitKB) - const isUnlimited = limitKB === Infinity - const limitLabel = isUnlimited ? "Unlimited" : formatUsage(limitKB) +function MeterRow({ icon: Icon, label, usedGB, limitGB }: MeterRowProps) { + const pct = usagePercentage(usedGB, limitGB) + const isUnlimited = limitGB === Infinity + const limitLabel = isUnlimited ? "Unlimited" : formatUsage(limitGB) return ( @@ -38,7 +38,7 @@ function MeterRow({ icon: Icon, label, usedKB, limitKB }: MeterRowProps) { {label} - {formatUsage(usedKB)} / {limitLabel} + {formatUsage(usedGB)} / {limitLabel}

@@ -78,20 +78,20 @@ export function UsageMeters({ diff --git a/apps/web/src/lib/billing/plans.ts b/apps/web/src/lib/billing/plans.ts index 1e21c2f..e6aac9d 100644 --- a/apps/web/src/lib/billing/plans.ts +++ b/apps/web/src/lib/billing/plans.ts @@ -1,13 +1,13 @@ export interface PlanLimits { - logsKB: number - tracesKB: number - metricsKB: number + logsGB: number + tracesGB: number + metricsGB: number retentionDays: number } export const PLAN_LIMITS: Record = { - starter: { logsKB: 10_000_000, tracesKB: 10_000_000, metricsKB: 10_000_000, retentionDays: 7 }, - startup: { logsKB: 40_000_000, tracesKB: 40_000_000, metricsKB: 40_000_000, retentionDays: 30 }, + starter: { logsGB: 10, tracesGB: 10, metricsGB: 10, retentionDays: 7 }, + startup: { logsGB: 40, tracesGB: 40, metricsGB: 40, retentionDays: 30 }, } const DEFAULT_PLAN = "starter" @@ -49,3 +49,12 @@ export const PLAN_FEATURES: Record = { export function getPlanFeatures(planSlug: string | undefined): PlanFeature[] { return PLAN_FEATURES[planSlug ?? DEFAULT_PLAN] ?? PLAN_FEATURES[DEFAULT_PLAN] } + +export const PLAN_DESCRIPTIONS: Record = { + starter: "For individuals and small projects", + startup: "For growing teams", +} + +export function getPlanDescription(planSlug: string): string { + return PLAN_DESCRIPTIONS[planSlug] ?? PLAN_DESCRIPTIONS["startup"] +} diff --git a/apps/web/src/lib/billing/usage.ts b/apps/web/src/lib/billing/usage.ts index d2296d0..fb6a069 100644 --- a/apps/web/src/lib/billing/usage.ts +++ b/apps/web/src/lib/billing/usage.ts @@ -1,33 +1,32 @@ import type { ServiceUsage } from "@/api/tinybird/service-usage" -const BYTES_PER_KB = 1_000 +const BYTES_PER_GB = 1_000_000_000 export interface AggregatedUsage { - logsKB: number - tracesKB: number - metricsKB: number + logsGB: number + tracesGB: number + metricsGB: number } export function aggregateUsage(services: ServiceUsage[]): AggregatedUsage { return services.reduce( (acc, s) => ({ - logsKB: acc.logsKB + s.logSizeBytes / BYTES_PER_KB, - tracesKB: acc.tracesKB + s.traceSizeBytes / BYTES_PER_KB, - metricsKB: acc.metricsKB + s.metricSizeBytes / BYTES_PER_KB, + logsGB: acc.logsGB + s.logSizeBytes / BYTES_PER_GB, + tracesGB: acc.tracesGB + s.traceSizeBytes / BYTES_PER_GB, + metricsGB: acc.metricsGB + s.metricSizeBytes / BYTES_PER_GB, }), - { logsKB: 0, tracesKB: 0, metricsKB: 0 }, + { logsGB: 0, tracesGB: 0, metricsGB: 0 }, ) } -export function usagePercentage(usedKB: number, limitKB: number): number { - if (limitKB === Infinity) return 0 - if (limitKB === 0) return 100 - return (usedKB / limitKB) * 100 +export function usagePercentage(usedGB: number, limitGB: number): number { + if (limitGB === Infinity) return 0 + if (limitGB === 0) return 100 + return (usedGB / limitGB) * 100 } -export function formatUsage(kb: number): string { - if (kb < 1) return "0 KB" - if (kb < 1_000) return `${kb.toFixed(0)} KB` - if (kb < 1_000_000) return `${(kb / 1_000).toFixed(0)} MB` - return `${(kb / 1_000_000).toFixed(2)} GB` +export function formatUsage(gb: number): string { + if (gb < 0.01) return "0 GB" + if (gb < 1) return `${(gb * 1000).toFixed(2)} MB` + return `${gb.toFixed(2)} GB` } diff --git a/packages/infra/src/railway/index.ts b/packages/infra/src/railway/index.ts index 0af876b..c8743f7 100644 --- a/packages/infra/src/railway/index.ts +++ b/packages/infra/src/railway/index.ts @@ -237,6 +237,7 @@ export async function provisionRailwayStack( MAPLE_DB_URL: reqEnv("MAPLE_DB_URL"), MAPLE_DB_AUTH_TOKEN: reqEnv("MAPLE_DB_AUTH_TOKEN"), MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: reqEnv("MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY"), + AUTUMN_SECRET_KEY: env.AUTUMN_SECRET_KEY?.trim() || "", }, }) From f1acc0799ec15bf16a9977884040ad285e444481 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 21 Feb 2026 14:00:10 +0100 Subject: [PATCH 4/4] fux --- packages/infra/src/railway/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/infra/src/railway/index.ts b/packages/infra/src/railway/index.ts index c8743f7..cae5594 100644 --- a/packages/infra/src/railway/index.ts +++ b/packages/infra/src/railway/index.ts @@ -237,7 +237,7 @@ export async function provisionRailwayStack( MAPLE_DB_URL: reqEnv("MAPLE_DB_URL"), MAPLE_DB_AUTH_TOKEN: reqEnv("MAPLE_DB_AUTH_TOKEN"), MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: reqEnv("MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY"), - AUTUMN_SECRET_KEY: env.AUTUMN_SECRET_KEY?.trim() || "", + AUTUMN_SECRET_KEY: reqEnv("AUTUMN_SECRET_KEY"), }, })