Skip to content

Commit 41a7061

Browse files
committed
feat(features): add Custom Expression toggle to meter creation
Adds a "+ Custom Expression" button alongside the existing aggregation options that swaps the "Aggregation Field" input for a "Custom Expression" (CEL formula) input. Mutually exclusive at the UI level — toggling clears the inactive side's value so the request body never carries both. Only shown for SUM, AVG, MAX, LATEST (matches the backend's SupportsExpression classifier). Help text under the Custom Expression input lists the available math functions (max, min, abs, ceil, floor, round, pow, sqrt, log) and four example formulas. EN + AR locales updated.
1 parent 7094016 commit 41a7061

5 files changed

Lines changed: 116 additions & 19 deletions

File tree

src/i18n/locales/ar/catalog.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,11 @@
732732
"aggregationFieldPh": "tokens",
733733
"aggregationFieldHelp": "حدد الخاصية في بيانات الحدث التي ستُجمَّع، مثل tokens وmessages_sent وstorage_used.",
734734
"aggregationMultiplier": "مضاعف التجميع*",
735+
"customExpressionButton": "تعبير مخصص",
736+
"aggregationFieldButton": "حقل التجميع",
737+
"customExpression": "تعبير مخصص*",
738+
"customExpressionPh": "ceil(duration_ms / 1000)",
739+
"customExpressionHelp": "اكتب صيغة CEL تُحسب لكل حدث لحساب الكمية. المتغيرات هي أسماء الخصائص ذات المستوى الأعلى في الأحداث (مثل duration_ms وtokens). الدوال المتاحة: max(a, b)، min(a, b)، abs(x)، ceil(x)، floor(x)، round(x)، pow(x, y)، sqrt(x)، log(x). أمثلة: \"input_tokens + output_tokens\"، \"ceil(duration_ms / 1000)\"، \"min(usage, quota)\". متاح فقط لـ SUM وAVG وMAX وLATEST.",
735740
"aggregationMultiplierPh": "1",
736741
"aggregationMultiplierHelp": "حدد المضاعف للتجميع، مثل 1.5 أو 0.25 أو 1000.",
737742
"bucketSizeButton": "حجم الدفعة",

src/i18n/locales/en/catalog.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,11 @@
731731
"aggregationField": "Aggregation Field*",
732732
"aggregationFieldPh": "tokens",
733733
"aggregationFieldHelp": "Specify the property in the event data that will be aggregated. e.g. tokens, messages_sent, storage_used.",
734+
"customExpressionButton": "Custom expression",
735+
"aggregationFieldButton": "Aggregation field",
736+
"customExpression": "Custom Expression*",
737+
"customExpressionPh": "ceil(duration_ms / 1000)",
738+
"customExpressionHelp": "Write a CEL formula evaluated per event to compute the quantity. Variables are top-level property names on your events (e.g. duration_ms, tokens). Available functions: max(a, b), min(a, b), abs(x), ceil(x), floor(x), round(x), pow(x, y), sqrt(x), log(x). Examples: \"input_tokens + output_tokens\", \"ceil(duration_ms / 1000)\", \"min(usage, quota)\", \"max(cores * hours * rate, 0.01)\". Only available for SUM, AVG, MAX and LATEST.",
734739
"aggregationMultiplier": "Aggregation Multiplier*",
735740
"aggregationMultiplierPh": "1",
736741
"aggregationMultiplierHelp": "Specify the multiplier for the aggregation. e.g. 1.5, 0.25, or 1000.",

src/models/Meter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { BaseModel } from './base';
33
export interface Meter extends BaseModel {
44
readonly aggregation: {
55
field: string;
6+
expression?: string;
67
type: METER_AGGREGATION_TYPE;
78
multiplier?: number;
89
bucket_size?: BUCKET_SIZE;

src/pages/product-catalog/features/AddFeature.tsx

Lines changed: 102 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@ const AGGREGATION_OPTIONS: SelectOption[] = [
105105
},
106106
];
107107

108+
// Aggregation types that accept a CEL expression in place of a field.
109+
// Mirrors the backend's AggregationType.SupportsExpression() classifier
110+
// (internal/types/aggregation.go).
111+
const EXPRESSION_SUPPORTED_TYPES: METER_AGGREGATION_TYPE[] = [
112+
METER_AGGREGATION_TYPE.SUM,
113+
METER_AGGREGATION_TYPE.AVG,
114+
METER_AGGREGATION_TYPE.MAX,
115+
METER_AGGREGATION_TYPE.LATEST,
116+
];
117+
108118
const BUCKET_SIZE_OPTIONS: SelectOption[] = [
109119
{
110120
label: 'Minute',
@@ -177,6 +187,7 @@ interface FeatureFormState {
177187
showEventFilters: boolean;
178188
showBucketSize: boolean;
179189
showGroupBy: boolean;
190+
showCustomExpression: boolean;
180191
}
181192

182193
type FeatureFormData = Omit<CreateFeatureRequest, 'name' | 'type' | 'meter'> & {
@@ -186,7 +197,9 @@ type FeatureFormData = Omit<CreateFeatureRequest, 'name' | 'type' | 'meter'> & {
186197
};
187198

188199
type FeatureErrors = Partial<Record<keyof CreateFeatureRequest, string>>;
189-
type MeterErrors = Partial<Record<keyof CreateMeterRequest | 'aggregation_type' | 'aggregation_field' | 'aggregation_multiplier', string>>;
200+
type MeterErrors = Partial<
201+
Record<keyof CreateMeterRequest | 'aggregation_type' | 'aggregation_field' | 'aggregation_expression' | 'aggregation_multiplier', string>
202+
>;
190203

191204
// Custom hook for feature form logic
192205
const useFeatureForm = () => {
@@ -201,6 +214,7 @@ const useFeatureForm = () => {
201214
showEventFilters: false,
202215
showBucketSize: false,
203216
showGroupBy: false,
217+
showCustomExpression: false,
204218
});
205219

206220
const updateFeatureData = useCallback((updates: Partial<FeatureFormData>) => {
@@ -230,7 +244,7 @@ const useFeatureForm = () => {
230244
return true;
231245
}, []);
232246

233-
const validateMeter = useCallback((meterData: Partial<CreateMeterRequest> | undefined): boolean => {
247+
const validateMeter = useCallback((meterData: Partial<CreateMeterRequest> | undefined, formState: FeatureFormState): boolean => {
234248
if (!meterData) return false;
235249

236250
const errors: Record<string, string> = {};
@@ -243,9 +257,15 @@ const useFeatureForm = () => {
243257
errors.aggregation_type = 'Aggregation type is required';
244258
}
245259

246-
// Only validate field if aggregation type is not COUNT
260+
// COUNT needs no per-event field/expression. For everything else, the
261+
// user is either in "Aggregation Field" mode (default) or "Custom
262+
// Expression" mode (toggled). Validate whichever input is currently shown.
247263
if (meterData.aggregation?.type !== METER_AGGREGATION_TYPE.COUNT) {
248-
if (!meterData.aggregation?.field?.trim()) {
264+
if (formState.showCustomExpression) {
265+
if (!meterData.aggregation?.expression?.trim()) {
266+
errors.aggregation_expression = 'Custom expression is required';
267+
}
268+
} else if (!meterData.aggregation?.field?.trim()) {
249269
errors.aggregation_field = 'Aggregation field is required for this aggregation type';
250270
}
251271
}
@@ -698,18 +718,26 @@ const AggregationSection = ({
698718
const { t } = useTranslation(['catalog', 'common']);
699719
const handleAggregationTypeChange = useCallback(
700720
(type: string) => {
721+
const newType = type as METER_AGGREGATION_TYPE;
722+
const stillSupportsExpression = EXPRESSION_SUPPORTED_TYPES.includes(newType);
701723
onUpdateFeature({
702724
meter: {
703725
...meter,
704726
aggregation: {
705727
...meter?.aggregation,
706-
type: type as METER_AGGREGATION_TYPE,
728+
type: newType,
707729
field: meter?.aggregation?.field ?? '',
730+
// Drop expression when switching to a type that can't carry one
731+
// (e.g. COUNT, COUNT_UNIQUE, SUM_WITH_MULTIPLIER, WEIGHTED_SUM).
732+
expression: stillSupportsExpression ? meter?.aggregation?.expression : '',
708733
},
709734
},
710735
});
736+
if (!stillSupportsExpression && formState.showCustomExpression) {
737+
onUpdateFormState({ showCustomExpression: false });
738+
}
711739
},
712-
[onUpdateFeature, meter],
740+
[onUpdateFeature, onUpdateFormState, meter, formState.showCustomExpression],
713741
);
714742

715743
const handleAggregationFieldChange = useCallback(
@@ -728,6 +756,39 @@ const AggregationSection = ({
728756
[onUpdateFeature, meter],
729757
);
730758

759+
const handleAggregationExpressionChange = useCallback(
760+
(expression: string) => {
761+
onUpdateFeature({
762+
meter: {
763+
...meter,
764+
aggregation: {
765+
...meter?.aggregation,
766+
type: meter?.aggregation?.type || METER_AGGREGATION_TYPE.SUM,
767+
expression,
768+
},
769+
},
770+
});
771+
},
772+
[onUpdateFeature, meter],
773+
);
774+
775+
// Toggling the Custom Expression / Aggregation Field button enforces XOR
776+
// in the UI: the inactive side's value is cleared so we never POST both.
777+
const toggleCustomExpression = useCallback(() => {
778+
const next = !formState.showCustomExpression;
779+
onUpdateFeature({
780+
meter: {
781+
...meter,
782+
aggregation: {
783+
...(meter?.aggregation ?? { type: METER_AGGREGATION_TYPE.SUM }),
784+
field: next ? '' : (meter?.aggregation?.field ?? ''),
785+
expression: next ? (meter?.aggregation?.expression ?? '') : '',
786+
},
787+
},
788+
});
789+
onUpdateFormState({ showCustomExpression: next });
790+
}, [onUpdateFeature, onUpdateFormState, meter, formState.showCustomExpression]);
791+
731792
const [multiplierInput, setMultiplierInput] = useState(meter?.aggregation?.multiplier?.toString() || '');
732793

733794
useEffect(() => {
@@ -790,8 +851,11 @@ const AggregationSection = ({
790851
[onUpdateFeature, meter],
791852
);
792853

793-
const showFieldInput = meter?.aggregation?.type !== METER_AGGREGATION_TYPE.COUNT;
794-
const showMultiplierInput = meter?.aggregation?.type === METER_AGGREGATION_TYPE.SUM_WITH_MULTIPLIER;
854+
const aggType = meter?.aggregation?.type;
855+
const supportsExpression = aggType ? EXPRESSION_SUPPORTED_TYPES.includes(aggType) : false;
856+
const showExpressionInput = supportsExpression && formState.showCustomExpression;
857+
const showFieldInput = aggType !== METER_AGGREGATION_TYPE.COUNT && !showExpressionInput;
858+
const showMultiplierInput = aggType === METER_AGGREGATION_TYPE.SUM_WITH_MULTIPLIER;
795859

796860
return (
797861
<>
@@ -819,6 +883,17 @@ const AggregationSection = ({
819883
/>
820884
)}
821885

886+
{showExpressionInput && (
887+
<Input
888+
value={meter?.aggregation?.expression || ''}
889+
onChange={handleAggregationExpressionChange}
890+
label={t('catalog:features.form.customExpression')}
891+
placeholder={t('catalog:features.form.customExpressionPh')}
892+
description={t('catalog:features.form.customExpressionHelp')}
893+
error={meterErrors.aggregation_expression}
894+
/>
895+
)}
896+
822897
{showMultiplierInput && (
823898
<Input
824899
value={multiplierInput}
@@ -841,6 +916,16 @@ const AggregationSection = ({
841916
{meter?.aggregation?.type === METER_AGGREGATION_TYPE.MAX && !formState.showGroupBy ? (
842917
<AddChargesButton label={t('catalog:features.form.groupByButton')} onClick={() => onUpdateFormState({ showGroupBy: true })} />
843918
) : null}
919+
{supportsExpression ? (
920+
<AddChargesButton
921+
label={t(
922+
formState.showCustomExpression
923+
? 'catalog:features.form.aggregationFieldButton'
924+
: 'catalog:features.form.customExpressionButton',
925+
)}
926+
onClick={toggleCustomExpression}
927+
/>
928+
) : null}
844929
</div>
845930
{formState.showBucketSize ? (
846931
<Select
@@ -998,24 +1083,22 @@ const AddFeaturePage = () => {
9981083

9991084
// If type is metered, validate meter data
10001085
if (data.type === FEATURE_TYPE.METERED) {
1001-
if (!validateMeter(data.meter)) {
1086+
if (!validateMeter(data.meter, formState)) {
10021087
return;
10031088
}
10041089
}
10051090

10061091
createFeature(data);
1007-
}, [data, validateFeature, validateMeter, createFeature]);
1092+
}, [data, formState, validateFeature, validateMeter, createFeature]);
10081093

10091094
const isCtaDisabled = useMemo(() => {
1010-
return (
1011-
!data.name ||
1012-
!data.type ||
1013-
isPending ||
1014-
(data.type === FEATURE_TYPE.METERED &&
1015-
(!data.meter?.event_name ||
1016-
!data.meter?.aggregation?.type ||
1017-
(data.meter.aggregation.type !== METER_AGGREGATION_TYPE.COUNT && !data.meter.aggregation?.field)))
1018-
);
1095+
const agg = data.meter?.aggregation;
1096+
const meteredButMissingValue =
1097+
data.type === FEATURE_TYPE.METERED &&
1098+
(!data.meter?.event_name ||
1099+
!agg?.type ||
1100+
(agg.type !== METER_AGGREGATION_TYPE.COUNT && !agg?.field?.trim() && !agg?.expression?.trim()));
1101+
return !data.name || !data.type || isPending || meteredButMissingValue;
10191102
}, [data.name, data.type, data.meter, isPending]);
10201103

10211104
const isMeteredType = data.type === FEATURE_TYPE.METERED;

src/types/dto/Meter.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export interface MeterFilter {
1212
export interface MeterAggregation {
1313
type: METER_AGGREGATION_TYPE;
1414
field?: string;
15+
// CEL expression evaluated per event to compute the quantity. Mutually
16+
// exclusive with `field`; only allowed for SUM, AVG, MAX, LATEST.
17+
expression?: string;
1518
multiplier?: number;
1619
bucket_size?: BUCKET_SIZE;
1720
group_by?: string;

0 commit comments

Comments
 (0)