feat: 🎸 unlimited decutions + collapse plan id

This commit is contained in:
amianthus
2026-05-18 20:18:29 +01:00
parent bed48cb9cd
commit f9e475ef08
15 changed files with 568 additions and 106 deletions

View File

@@ -105,15 +105,21 @@ export const listByCursor = async ({
if (row.deductions) {
try {
const parsed = JSON.parse(row.deductions);
if (Array.isArray(parsed)) {
deductions = parsed as TrackDeduction[];
} else if (parsed && Array.isArray(parsed.list)) {
deductions = parsed.list as TrackDeduction[];
// Tinybird's JSON column re-encodes nested-object array items
// as strings; second parse brings them back to TrackDeduction.
const rawList = Array.isArray(parsed)
? parsed
: parsed && Array.isArray(parsed.list)
? parsed.list
: null;
if (rawList) {
deductions = rawList.map((item: unknown) =>
typeof item === "string"
? (JSON.parse(item) as TrackDeduction)
: (item as TrackDeduction),
);
}
} catch {
// Invalid JSON — leave null so the caller can distinguish missing
// vs explicit empty.
}
} catch {}
}
lastRowMicros = tinybirdTimestampToEpochMicros(row.timestamp);

View File

@@ -91,15 +91,21 @@ export const listEvents = async ({
if (row.deductions) {
try {
const parsed = JSON.parse(row.deductions);
if (Array.isArray(parsed)) {
deductions = parsed as TrackDeduction[];
} else if (parsed && Array.isArray(parsed.list)) {
deductions = parsed.list as TrackDeduction[];
// Tinybird's JSON column re-encodes nested-object array items
// as strings; second parse brings them back to TrackDeduction.
const rawList = Array.isArray(parsed)
? parsed
: parsed && Array.isArray(parsed.list)
? parsed.list
: null;
if (rawList) {
deductions = rawList.map((item: unknown) =>
typeof item === "string"
? (JSON.parse(item) as TrackDeduction)
: (item as TrackDeduction),
);
}
} catch {
// Invalid JSON — leave null so the caller can distinguish missing
// vs explicit empty.
}
} catch {}
}
return {

View File

@@ -14,8 +14,10 @@ import { assertTinybirdAvailable } from "@/external/tinybird/tinybirdUtils.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { getCustomerNames } from "@/internal/analytics/actions/getCustomerNames.js";
import { getEntityNames } from "@/internal/analytics/actions/getEntityNames.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { CusService } from "@/internal/customers/CusService.js";
import { eventActions } from "../actions/eventActions.js";
import { collapsePlanIdGroups } from "./utils/collapsePlanIdGroups.js";
const STANDARD_INTERVAL_DAYS: Record<string, number> = {
"24h": 1,
@@ -129,6 +131,30 @@ export const handleInternalAggregateEvents = createRoute({
return { start: aligned.getTime(), end: now.getTime() };
})();
const isPlanIdGrouping = group_by === "plan_id" || group_by === "$plan_id";
let internalIdToPublicId: Record<string, string> | undefined;
let planNames: Record<string, string> | undefined;
let effectiveMaxGroups: number | undefined;
if (isPlanIdGrouping) {
const allProductRows = await ProductService.listCachedAllVersions({
db,
orgId: org.id,
env,
});
effectiveMaxGroups = Math.max(
allProductRows.length + 1,
max_groups ?? 0,
10,
);
internalIdToPublicId = {};
planNames = {};
for (const p of allProductRows) {
internalIdToPublicId[p.internal_id] = p.id;
planNames[p.id] = p.name ?? p.id;
}
}
const [{ formatted: events, truncated }, totals] = await Promise.all([
eventActions.aggregate({
ctx,
@@ -143,7 +169,7 @@ export const handleInternalAggregateEvents = createRoute({
group_by: group_by,
customer,
timezone: timezone,
max_groups,
max_groups: isPlanIdGrouping ? effectiveMaxGroups : max_groups,
},
}),
eventActions.getCountAndSum({
@@ -161,6 +187,10 @@ export const handleInternalAggregateEvents = createRoute({
}),
]);
if (isPlanIdGrouping && events?.data && internalIdToPublicId) {
collapsePlanIdGroups({ events, internalIdToPublicId });
}
// When grouping by entity_id, resolve entity names from ClickHouse
let entityNames: Record<string, string> | undefined;
if (group_by === "entity_id" && events?.data) {
@@ -214,6 +244,7 @@ export const handleInternalAggregateEvents = createRoute({
truncated,
entityNames,
customerNames,
planNames,
});
},
});

View File

@@ -0,0 +1,42 @@
import { Decimal } from "decimal.js";
import type { ClickHouseResult } from "@autumn/shared";
export const collapsePlanIdGroups = ({
events,
internalIdToPublicId,
}: {
events: ClickHouseResult;
internalIdToPublicId: Record<string, string>;
}) => {
const collapsed = new Map<string, Record<string, string | number>>();
for (const row of events.data) {
const planId = String(row.plan_id ?? "");
const collapsedId =
planId === "" || planId === "AUTUMN_RESERVED"
? planId
: internalIdToPublicId[planId] ?? planId;
const key = `${row.period}|${collapsedId}`;
const existing = collapsed.get(key);
if (!existing) {
collapsed.set(key, {
...row,
plan_id: collapsedId,
});
continue;
}
for (const [k, v] of Object.entries(row)) {
if (k === "period" || k === "plan_id") continue;
existing[k] = new Decimal(existing[k] ?? 0)
.plus(new Decimal(v as number))
.toDecimalPlaces(10)
.toNumber();
}
}
events.data = Array.from(collapsed.values());
events.rows = events.data.length;
};

View File

@@ -109,6 +109,7 @@ export const executePostgresDeductionV2 = async ({
rollovers,
customerEntitlements,
unlimitedFeatureIds,
unlimitedCusEnt,
lock: preparedLock,
} = prepareFeatureDeductionV2({
ctx,
@@ -129,6 +130,25 @@ export const executePostgresDeductionV2 = async ({
redisInstance: ctx.redisV2,
});
}
// Attribute the event to the unlimited plan even though we skip
// the actual deduction. Without this, resolveInternalProductIdForEvent
// gets an empty mutation log and the event lands in "No plan".
if (unlimitedCusEnt) {
const syntheticDelta = -(toDeduct ?? deduction.deduction ?? 1);
if (syntheticDelta !== 0) {
allMutationLogs.push({
target_type: "customer_entitlement",
customer_entitlement_id: unlimitedCusEnt.id,
rollover_id: null,
entity_id: entityId ?? null,
credit_cost: 1,
balance_delta: syntheticDelta,
adjustment_delta: 0,
usage_delta: 0,
value_delta: 0,
});
}
}
continue;
}

View File

@@ -120,6 +120,7 @@ export const executeRedisDeductionV2 = async ({
rollovers,
customerEntitlements,
unlimitedFeatureIds,
unlimitedCusEnt,
lock: preparedLock,
} = prepareFeatureDeductionV2({
ctx,
@@ -140,6 +141,25 @@ export const executeRedisDeductionV2 = async ({
redisInstance: redisInstance ?? ctx.redisV2,
});
}
// Attribute the event to the unlimited plan even though we skip
// the actual deduction. Without this, resolveInternalProductIdForEvent
// gets an empty mutation log and the event lands in "No plan".
if (unlimitedCusEnt) {
const syntheticDelta = -(toDeduct ?? deduction.deduction ?? 1);
if (syntheticDelta !== 0) {
allMutationLogs.push({
target_type: "customer_entitlement",
customer_entitlement_id: unlimitedCusEnt.id,
rollover_id: null,
entity_id: entityId ?? null,
credit_cost: 1,
balance_delta: syntheticDelta,
adjustment_delta: 0,
usage_delta: 0,
value_delta: 0,
});
}
}
continue;
}

View File

@@ -1,5 +1,7 @@
import {
AllowanceType,
cusEntToStartingBalance,
type FullCusEntWithFullCusProduct,
type FullSubject,
fullSubjectToCustomerEntitlements,
fullSubjectToOverageAllowedByFeatureId,
@@ -58,6 +60,15 @@ export const prepareFeatureDeductionV2 = ({
});
const unlimitedFeatureIds: string[] = [];
// Track the chosen unlimited cusEnt so the deduction short-circuit can
// still attribute the event to a plan. Prefer cusEnts whose feature
// matches the tracked one (over credit-system parents); within that,
// take the first match in the already-sorted customerEntitlements list.
let unlimitedCusEntPrimary: FullCusEntWithFullCusProduct | undefined;
let unlimitedCusEntFallback: FullCusEntWithFullCusProduct | undefined;
const isUnlimitedCusEnt = (ce: FullCusEntWithFullCusProduct): boolean =>
ce.entitlement.allowance_type === AllowanceType.Unlimited ||
Boolean(ce.unlimited);
for (const relevantFeature of relevantFeatures) {
const { unlimited: featureUnlimited } = getUnlimitedAndUsageAllowed({
@@ -67,9 +78,22 @@ export const prepareFeatureDeductionV2 = ({
if (featureUnlimited) {
unlimitedFeatureIds.push(relevantFeature.id);
const matchingCusEnt = customerEntitlements.find(
(ce) =>
ce.internal_feature_id === relevantFeature.internal_id &&
isUnlimitedCusEnt(ce),
);
if (!matchingCusEnt) continue;
if (relevantFeature.id === feature.id) {
unlimitedCusEntPrimary ??= matchingCusEnt;
} else {
unlimitedCusEntFallback ??= matchingCusEnt;
}
}
}
const unlimitedCusEnt = unlimitedCusEntPrimary ?? unlimitedCusEntFallback;
const effectiveFeatureIds = relevantFeatures.map((candidate) => candidate.id);
const spendLimitByFeatureId = fullSubjectToSpendLimitByFeatureId({
fullSubject,
@@ -194,6 +218,7 @@ export const prepareFeatureDeductionV2 = ({
credit_cost: rollover.credit_cost,
})),
unlimitedFeatureIds,
unlimitedCusEnt,
lock: preparedLock,
};
};

View File

@@ -45,6 +45,11 @@ export type PreparedFeatureDeduction = {
// rolloverIds: string[];
rollovers: RolloverDeduction[];
unlimitedFeatureIds: string[];
// Chosen unlimited cusEnt to attribute events to when the deduction
// short-circuits via unlimitedFeatureIds. Prefers a cusEnt matching the
// tracked feature over a credit-system parent. Undefined when no
// unlimited cusEnt is present.
unlimitedCusEnt?: FullCusEntWithFullCusProduct;
lock?: {
enabled: true;
lock_id?: string;

View File

@@ -28,7 +28,11 @@ import {
import { StatusCodes } from "http-status-codes";
import type { Logger } from "@/external/logtail/logtailUtils";
import { queryWithCache } from "@/utils/cacheUtils/queryWithCache";
import { buildProductsCacheKey, PRODUCTS_CACHE_TTL } from "./productCacheUtils";
import {
buildAllVersionsProductsCacheKey,
buildProductsCacheKey,
PRODUCTS_CACHE_TTL,
} from "./productCacheUtils";
import { getLatestProducts, isFreeProduct } from "./productUtils";
import { sortFullProducts } from "./productUtils/sortProductUtils";
@@ -297,6 +301,31 @@ export class ProductService {
});
}
static async listCachedAllVersions({
db,
orgId,
env,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
}): Promise<Array<{ internal_id: string; id: string; name: string; version: number }>> {
return queryWithCache({
key: buildAllVersionsProductsCacheKey({ orgId, env }),
ttl: PRODUCTS_CACHE_TTL,
fn: () =>
db
.select({
internal_id: products.internal_id,
id: products.id,
name: products.name,
version: products.version,
})
.from(products)
.where(and(eq(products.org_id, orgId), eq(products.env, env))),
});
}
static async listFull({
db,
orgId,

View File

@@ -57,6 +57,17 @@ export const buildProductsCacheKey = ({
return `${prefix}:${hash}`;
};
/** Builds the cache key for all product versions (unfiltered, no joins) */
export const buildAllVersionsProductsCacheKey = ({
orgId,
env,
}: {
orgId: string;
env: AppEnv;
}) => {
return `${buildProductsCacheKeyPrefix({ orgId, env })}:all_versions`;
};
/** All possible archived query param values that can be cached */
const ARCHIVED_VARIANTS = [undefined, false, true] as const;
@@ -71,13 +82,16 @@ export const invalidateProductsCache = async ({
if (redis.status !== "ready") return;
// Build all possible cache keys (deterministic based on archived param variants)
const keysToDelete = ARCHIVED_VARIANTS.map((archived) =>
buildProductsCacheKey({
orgId,
env,
queryParams: archived !== undefined ? { archived } : undefined,
}),
);
const keysToDelete = [
...ARCHIVED_VARIANTS.map((archived) =>
buildProductsCacheKey({
orgId,
env,
queryParams: archived !== undefined ? { archived } : undefined,
}),
),
buildAllVersionsProductsCacheKey({ orgId, env }),
];
const regions = getConfiguredRegions();

View File

@@ -12,7 +12,9 @@ import { initDrizzle } from "@/db/initDrizzle.js";
import { logger } from "@/external/logtail/logtailUtils.js";
import { redis } from "@/external/redis/initRedis.js";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import { getSqsClient } from "@/queue/initSqs.js";
import { deletePlatformSubOrg } from "@/internal/orgs/deleteOrg/deletePlatformSubOrg.js";
import { PurgeQueueCommand } from "@aws-sdk/client-sqs";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { clearOrg } from "./utils/setup/clearOrg.js";
import { setupOrg } from "./utils/setup/setupOrg.js";
@@ -138,6 +140,20 @@ export const clearMasterOrg = async () => {
);
}
}
const purgeQueue = async (queueUrl: string | undefined, label: string) => {
if (!queueUrl) return;
try {
const sqs = getSqsClient({ queueUrl });
await sqs.send(new PurgeQueueCommand({ QueueUrl: queueUrl }));
console.log(chalk.green(`✅ Purged ${label} SQS queue.`));
} catch (err) {
console.log(chalk.yellow(`⚠️ Skipped ${label} SQS purge: ${err}`));
}
};
await purgeQueue(process.env.SQS_QUEUE_URL_V2, "primary");
await purgeQueue(process.env.TRACK_SQS_QUEUE_URL, "track");
console.log(chalk.green("\n✅ Master org setup complete!\n"));
} catch (error) {
console.error(chalk.red("\n❌ Error:"), error);

View File

@@ -0,0 +1,204 @@
/**
* TDD test: when a customer has both an unlimited and a limited cusEnt for the
* same feature, a track event currently short-circuits in
* executePostgresDeductionV2 / executeRedisDeductionV2 (`unlimitedFeatureIds.length > 0`)
* with zero mutation logs. resolveInternalProductIdForEvent then returns null,
* so the persisted event has `internal_product_id = null` and an empty
* `deductions` array — exactly the "No plan" bucket we see in Mintlify's
* analytics for Anthropic.
*
* Red-failure mode (current behavior):
* - event.internal_product_id IS NULL
* - event.deductions IS NULL (or empty)
* - track response `deductions` is empty/undefined
*
* Green-success criteria (after fix):
* - event.internal_product_id === unlimited plan's internal_id
* - event.deductions contains a synthetic entry pointing at the unlimited
* cusEnt with the track call's value (echoed entity_id when present)
* - track response `deductions` contains the same synthetic entry
* - the limited cusEnt's balance is NOT mutated (we only attribute, never
* actually deduct on the unlimited path)
*/
import { expect, test } from "bun:test";
import {
type ApiCustomerV5,
type TrackResponseV3,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { timeout } from "@tests/utils/genUtils.js";
import {
cleanupOrgRollout,
setOrgRolloutPercent,
} from "@tests/utils/rolloutTestUtils.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { and, desc, eq } from "drizzle-orm";
import { db } from "@/db/initDrizzle.js";
import {
customerEntitlements,
events,
products as productsTable,
} from "@autumn/shared";
const TINYBIRD_INGEST_WAIT_MS = 3000;
test(
`${chalk.yellowBright("unlimited-cusent-attribution: track on customer with mixed unlimited+limited entitlements attributes the event to the unlimited plan")}`,
async () => {
const customerId = `unlimited-cusent-attribution-${Date.now()}`;
// Base plan: limited messages (100/month).
const baseProd = products.base({
id: "base-limited",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
// Add-on plan: unlimited messages. Anthropic's analogue is enterprise v1
// with allowance_type=unlimited stacked alongside enterprise v2.
const unlimitedAddon = products.base({
id: "unlimited-addon",
items: [items.unlimitedMessages()],
isAddOn: true,
});
const { ctx, autumnV2_2 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false }),
s.products({ list: [baseProd, unlimitedAddon] }),
],
actions: [
s.attach({ productId: baseProd.id }),
s.attach({ productId: unlimitedAddon.id }),
],
});
const orgId = ctx.org.id;
try {
// Force V3 path (executeRedisDeductionV2 → resolveInternalProductIdForEvent).
await setOrgRolloutPercent({ orgId, percent: 100 });
// initProductsV0 mutates each product's `id` in-place to suffix it
// with productPrefix (defaults to customerId), so `unlimitedAddon.id`
// is already the final stored id.
const unlimitedPlanId = unlimitedAddon.id;
const unlimitedProductRow = await db
.select({ internal_id: productsTable.internal_id })
.from(productsTable)
.where(
and(
eq(productsTable.org_id, orgId),
eq(productsTable.env, ctx.env),
eq(productsTable.id, unlimitedPlanId),
),
)
.limit(1);
const expectedUnlimitedInternalId =
unlimitedProductRow[0]?.internal_id ?? null;
expect(expectedUnlimitedInternalId).not.toBeNull();
// Track usage. Anthropic's calls are AI_CREDITS with value 1..N; we
// use value=7 here so the synthetic deduction is unambiguous.
const TRACK_VALUE = 7;
const trackResponse = (await autumnV2_2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: TRACK_VALUE,
})) as TrackResponseV3;
// ---- Synchronous track response assertions ----------------------
// After the fix, the synthetic mutation log flows through
// projectMutationLogsToTrackDeductionsV2 → response.deductions.
expect(trackResponse.deductions).toBeDefined();
expect(trackResponse.deductions ?? []).toHaveLength(1);
const responseDeduction = (trackResponse.deductions ?? [])[0];
expect(responseDeduction?.feature_id).toBe(TestFeature.Messages);
expect(responseDeduction?.plan_id).toBe(unlimitedPlanId);
expect(responseDeduction?.value).toBe(TRACK_VALUE);
// ---- Persisted event assertions ---------------------------------
// Wait for the event batch flush (globalEventBatchingManager).
await timeout(TINYBIRD_INGEST_WAIT_MS);
const customer = (await autumnV2_2.customers.get(customerId, {
with_autumn_id: true,
})) as ApiCustomerV5 & { autumn_id?: string };
const internalCustomerId = customer.autumn_id;
expect(internalCustomerId).toBeDefined();
const eventRows = await db
.select({
id: events.id,
event_name: events.event_name,
value: events.value,
entity_id: events.entity_id,
internal_product_id: events.internal_product_id,
deductions: events.deductions,
})
.from(events)
.where(
and(
eq(events.org_id, orgId),
eq(events.env, ctx.env),
eq(events.internal_customer_id, internalCustomerId as string),
),
)
.orderBy(desc(events.created_at))
.limit(5);
expect(eventRows.length).toBeGreaterThan(0);
const latestEvent = eventRows[0];
// The fix: the event is attributed to the unlimited plan.
expect(latestEvent.internal_product_id).toBe(
expectedUnlimitedInternalId,
);
// The deductions array on the persisted event mirrors the synthetic
// mutation log: one entry with the unlimited plan's id and the
// tracked value.
expect(latestEvent.deductions).not.toBeNull();
expect(latestEvent.deductions ?? []).toHaveLength(1);
const persistedDeduction = (latestEvent.deductions ?? [])[0];
expect(persistedDeduction?.feature_id).toBe(TestFeature.Messages);
expect(persistedDeduction?.plan_id).toBe(unlimitedPlanId);
expect(persistedDeduction?.value).toBe(TRACK_VALUE);
// The track call had no entity_id, so the synthetic mutation log
// should echo null and the persisted event row should have entity_id
// = null. (See "Echo the track calls entity ID" requirement.)
expect(latestEvent.entity_id).toBeNull();
// ---- Non-mutation invariant -------------------------------------
// The synthetic mutation log is in-memory only; no cusEnt row should
// be touched. Query both cusEnt balances directly: the limited
// one stays at 100 (its starting allowance), and the unlimited one
// stays at whatever it started at.
const cusEntRows = await db
.select({
id: customerEntitlements.id,
balance: customerEntitlements.balance,
unlimited: customerEntitlements.unlimited,
})
.from(customerEntitlements)
.where(
eq(
customerEntitlements.internal_customer_id,
internalCustomerId as string,
),
);
const limitedRow = cusEntRows.find((r) => !r.unlimited);
const unlimitedRow = cusEntRows.find((r) => r.unlimited);
expect(limitedRow?.balance).toBe(100);
expect(unlimitedRow?.unlimited).toBe(true);
} finally {
await cleanupOrgRollout({ orgId });
}
},
{ timeout: 120_000 },
);

View File

@@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
import { PageContainer } from "@/components/general/PageContainer";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useFeatureFlags } from "@/hooks/useFeatureFlags";
import { useEnv } from "@/utils/envUtils";
import { OnboardingGuide } from "@/views/onboarding4/OnboardingGuide";
@@ -32,6 +31,7 @@ export const AnalyticsView = () => {
const [clickHouseDisabled, setClickHouseDisabled] = useState(false);
const [hasCleared, setHasCleared] = useState(false);
const [groupFilter, setGroupFilter] = useState<string | null>(null);
const [planDeselected, setPlanDeselected] = useState<Set<string>>(new Set());
const navigate = useNavigate();
const env = useEnv();
@@ -50,61 +50,11 @@ export const AnalyticsView = () => {
truncated,
entityNames,
customerNames,
planNames,
totals,
eventNames: responseEventNames,
} = useAnalyticsData({ hasCleared });
// Build internal_product_id → display name from the products cache so the
// chart can label plan_id groups (backend ships raw internal ids).
// `/products/products` only returns the latest version per public id, so
// historical versions (e.g. a customer still on v1 after we ship v2) get
// merged in from `customer.customer_products`. When the same public id has
// multiple versions in scope, suffix with ` v{version}`.
const { products } = useProductsQuery({ allVersions: true });
const planNames = useMemo(() => {
type Entry = {
internal_id: string;
id: string;
name: string;
version: number;
};
const entries: Entry[] = [];
const seen = new Set<string>();
const push = (e: Partial<Entry> & { internal_id?: string | null }) => {
if (!e.internal_id || seen.has(e.internal_id)) return;
seen.add(e.internal_id);
entries.push({
internal_id: e.internal_id,
id: e.id ?? e.internal_id,
name: e.name ?? e.id ?? e.internal_id,
version: e.version ?? 1,
});
};
for (const p of products) push(p);
for (const cp of customer?.customer_products ?? []) {
push({
internal_id: cp.product?.internal_id,
id: cp.product?.id,
name: cp.product?.name,
version: cp.product?.version,
});
}
// Count public id occurrences so we only suffix when there's ambiguity.
const idCount = new Map<string, number>();
for (const e of entries) {
idCount.set(e.id, (idCount.get(e.id) ?? 0) + 1);
}
const map: Record<string, string> = {};
for (const e of entries) {
const showVersion = (idCount.get(e.id) ?? 0) >= 2;
map[e.internal_id] = showVersion ? `${e.name} v${e.version}` : e.name;
}
return map;
}, [products, customer]);
// Show toast when data is truncated due to too many unique group values
const hasShownTruncationToast = useRef(false);
useEffect(() => {
@@ -119,9 +69,9 @@ export const AnalyticsView = () => {
}
}, [truncated, groupBy]);
// Clear the filter when groupBy changes
useEffect(() => {
setGroupFilter(null);
setPlanDeselected(new Set());
}, [groupBy]);
// Extract unique group values from events data for filtering
@@ -165,16 +115,20 @@ export const AnalyticsView = () => {
return { chartData: null, chartConfig: null };
}
// Apply frontend filter if a group filter is selected. Use an
// explicit null check because empty string is a meaningful filter
// value for plan_id ("no plan").
let filteredEvents = events;
if (groupBy && groupFilter !== null) {
// Handle special case for column-based operators (not a property)
if (groupBy === "plan_id" && planDeselected.size > 0) {
const filteredData = events.data.filter(
(row: Record<string, string | number>) =>
!planDeselected.has(String(row.plan_id ?? "")),
);
filteredEvents = {
...events,
data: filteredData,
rows: filteredData.length,
};
} else if (groupBy && groupBy !== "plan_id" && groupFilter !== null) {
const groupByColumn =
groupBy === "customer_id" ||
groupBy === "entity_id" ||
groupBy === "plan_id"
groupBy === "customer_id" || groupBy === "entity_id"
? groupBy
: `properties.${groupBy}`;
const filteredData = events.data.filter(
@@ -206,7 +160,7 @@ export const AnalyticsView = () => {
});
return { chartData: transformed, chartConfig: config };
}, [events, features, groupBy, groupFilter, entityNames, customerNames, planNames]);
}, [events, features, groupBy, groupFilter, planDeselected, entityNames, customerNames, planNames]);
// Build legend entries (sorted desc, zero-values filtered). The
// width-aware overflow logic lives in ChartLegend.
@@ -313,6 +267,8 @@ export const AnalyticsView = () => {
propertyKeys,
groupFilter,
setGroupFilter,
planDeselected,
setPlanDeselected,
availableGroupValues,
entityNames,
customerNames,
@@ -356,7 +312,7 @@ export const AnalyticsView = () => {
</div>
)}
{!chartData && !queryLoading && (
{(!chartData || chartData.data.length === 0) && !queryLoading && (
<div className="flex-1 px-10 pt-6">
<p className="text-t3 text-sm">
No events found. Please widen your filters.{" "}

View File

@@ -7,13 +7,16 @@ import { Check } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { Checkbox } from "@/components/v2/checkboxes/Checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/v2/dropdowns/DropdownMenu";
import { cn } from "@/lib/utils";
@@ -34,12 +37,30 @@ export const SelectGroupByDropdown = ({
const {
groupFilter,
setGroupFilter,
planDeselected,
setPlanDeselected,
availableGroupValues,
entityNames,
customerNames,
planNames,
} = useAnalyticsContext();
const togglePlanDeselected = (value: string) => {
setPlanDeselected((prev: Set<string>) => {
const next = new Set(prev);
if (next.has(value)) next.delete(value);
else next.add(value);
return next;
});
};
const allPlansSelected = (planDeselected?.size ?? 0) === 0;
const handlePlanSelectAll = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setPlanDeselected(new Set());
};
const currentGroupBy = searchParams.get("group_by") || "";
const customerId = searchParams.get("customer_id");
const showCustomerIdOption = !customerId;
@@ -244,11 +265,82 @@ export const SelectGroupByDropdown = ({
</>
)}
{/* Filter section - only shown when a groupBy is selected */}
{currentGroupBy && availableGroupValues.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{currentGroupBy === "plan_id" &&
availableGroupValues.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex items-center gap-2 cursor-pointer">
<span className="text-xs">Filter plans</span>
{!allPlansSelected && (
<span className="text-xs text-t3 bg-muted px-1 py-0 rounded-md">
{availableGroupValues.length -
(planDeselected?.size ?? 0)}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-56 max-w-none w-max !overflow-x-visible">
<div className="flex items-center justify-between px-2 h-6">
<button
type="button"
onClick={handlePlanSelectAll}
className={cn(
"px-1 h-5 flex items-center gap-1 text-t2 text-xs hover:text-t1 bg-accent cursor-pointer rounded-md",
allPlansSelected &&
"bg-primary/10 text-primary hover:text-primary/80",
)}
>
Select all
</button>
</div>
<DropdownMenuSeparator />
<div className="max-h-64 overflow-y-auto overflow-x-visible">
{availableGroupValues.map((value: string) => {
const displayValue =
value === "AUTUMN_RESERVED"
? "Other values"
: value === ""
? "No plan"
: (planNames?.[value] ?? value);
const isChecked = !planDeselected?.has(value);
const wouldDeselectLast =
isChecked &&
availableGroupValues.length -
(planDeselected?.size ?? 0) ===
1;
return (
<DropdownMenuItem
key={value}
closeOnClick={false}
disabled={wouldDeselectLast}
onClick={(e) => {
e.preventDefault();
if (wouldDeselectLast) return;
togglePlanDeselected(value);
}}
className="flex items-center gap-2 cursor-pointer"
>
<Checkbox
checked={isChecked}
className="border-border"
/>
<span className="text-xs whitespace-nowrap">
{displayValue}
</span>
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
{currentGroupBy &&
currentGroupBy !== "plan_id" &&
availableGroupValues.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-t4 font-normal">
Filter by value
</DropdownMenuLabel>
@@ -266,15 +358,11 @@ export const SelectGroupByDropdown = ({
const displayValue =
value === "AUTUMN_RESERVED"
? "Other values"
: value === "" && currentGroupBy === "plan_id"
? "No plan"
: currentGroupBy === "entity_id"
? (entityNames?.[value] ?? value)
: currentGroupBy === "customer_id"
? (customerNames?.[value] ?? value)
: currentGroupBy === "plan_id"
? (planNames?.[value] ?? value)
: value;
: currentGroupBy === "entity_id"
? (entityNames?.[value] ?? value)
: currentGroupBy === "customer_id"
? (customerNames?.[value] ?? value)
: value;
return (
<DropdownMenuItem
key={value}
@@ -291,9 +379,8 @@ export const SelectGroupByDropdown = ({
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</>
)}
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -100,6 +100,7 @@ export const useAnalyticsData = ({
truncated: data?.truncated ?? false,
entityNames: (data?.entityNames as Record<string, string>) ?? undefined,
customerNames: (data?.customerNames as Record<string, string>) ?? undefined,
planNames: (data?.planNames as Record<string, string>) ?? undefined,
totals:
(data?.totals as
| Record<string, { count: number; sum: number }>