Skip to content

Commit caf9660

Browse files
atheurerclaude
andcommitted
Structured breakout API, regex filter, aggregation, and deep dive fixes
Server (cdm.js): - Add parseBreakoutEntry() to accept both legacy string and structured object format: { name, values[], regex, aggregate } - Replace all 6 inline string-parsing locations with parseBreakoutEntry() - Fix remainingBreakouts filtering (was comparing field names against raw breakout strings, now compares against parsed names) - Add buildAggregateLabel() for compact aggregate labels: numeric ranges (0-11), comma lists, or "N values" fallback at 30-char cap - Insert synthetic label segments at correct position for aggregated breakouts so values-legend column order matches headers Client (CompareView.jsx): - Breakout dropdown sends structured objects instead of encoded strings - Add regex text input per breakout dimension with live chip auto-selection - Add "Sum" checkbox to aggregate selected values into one metric - Move Dive checkbox into Y-axis label for consistent placement - Handle object breakout entries in renderGroupedBreakouts and chip display Client (DeepDiveView.jsx): - Fix var closure bug: capture metricKey before setMetricData updater to prevent data being stored under wrong metric key - Handle object breakout entries in breakoutNames extraction - Add setTimeout yield between metrics for sequential rendering Server logging (server.js): - Fix breakout logging for object entries in /metric-data endpoint Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1fc9065 commit caf9660

5 files changed

Lines changed: 341 additions & 158 deletions

File tree

queries/cdmq/cdm.js

Lines changed: 123 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -2651,48 +2651,92 @@ getMetricGroupTermsFromAgg = function (agg, terms) {
26512651
};
26522652
exports.getMetricGroupTermsFromAgg = getMetricGroupTermsFromAgg;
26532653

2654+
// --------------------------------------------------------------------------------------------------------------
2655+
// Parse a breakout entry from either structured object or legacy string format.
2656+
// Returns { name, values (array|null), regex (string|null), aggregate (bool) }
2657+
function parseBreakoutEntry(entry) {
2658+
if (typeof entry === 'object' && entry !== null && entry.name) {
2659+
return {
2660+
name: entry.name,
2661+
values: entry.values || null,
2662+
regex: entry.regex || null,
2663+
aggregate: !!entry.aggregate
2664+
};
2665+
}
2666+
var parsed = { name: String(entry), values: null, regex: null, aggregate: false };
2667+
var eqMatch = /^([^=]+)=(.+)$/.exec(entry);
2668+
if (eqMatch) {
2669+
parsed.name = eqMatch[1];
2670+
var val = eqMatch[2];
2671+
var regexMatch = /^([rR])(.)(.+)\2$/.exec(val);
2672+
if (regexMatch) {
2673+
parsed.regex = regexMatch[3];
2674+
parsed.aggregate = regexMatch[1] === 'R';
2675+
} else {
2676+
parsed.values = val.split('+');
2677+
}
2678+
}
2679+
return parsed;
2680+
}
2681+
exports.parseBreakoutEntry = parseBreakoutEntry;
2682+
2683+
function buildAggregateLabel(bp, maxLen) {
2684+
maxLen = maxLen || 30;
2685+
if (bp.values && bp.values.length > 0) {
2686+
var vals = bp.values.slice().sort(function (a, b) {
2687+
var na = Number(a), nb = Number(b);
2688+
if (!isNaN(na) && !isNaN(nb)) return na - nb;
2689+
return a < b ? -1 : a > b ? 1 : 0;
2690+
});
2691+
var allNumeric = vals.every(function (v) { return !isNaN(Number(v)); });
2692+
if (allNumeric) {
2693+
var nums = vals.map(Number);
2694+
var ranges = [];
2695+
var start = nums[0], end = nums[0];
2696+
for (var i = 1; i < nums.length; i++) {
2697+
if (nums[i] === end + 1) {
2698+
end = nums[i];
2699+
} else {
2700+
ranges.push(start === end ? String(start) : start + '-' + end);
2701+
start = end = nums[i];
2702+
}
2703+
}
2704+
ranges.push(start === end ? String(start) : start + '-' + end);
2705+
var rangeStr = ranges.join(',');
2706+
if (rangeStr.length <= maxLen) return rangeStr;
2707+
}
2708+
var joined = vals.join(',');
2709+
if (joined.length <= maxLen) return joined;
2710+
}
2711+
if (bp.regex) {
2712+
if (bp.regex.length <= maxLen) return '/' + bp.regex + '/';
2713+
}
2714+
var count = bp.values ? bp.values.length : '?';
2715+
return count + ' values';
2716+
}
2717+
26542718
// --------------------------------------------------------------------------------------------------------------
26552719
getBreakoutAggregation = function (source, type, breakout) {
26562720
var agg_str = '{';
26572721
agg_str += '"metric_desc.source": { "terms": { "field": "metric_desc.source"}';
26582722
agg_str += ',"aggs": { "metric_desc.type": { "terms": { "field": "metric_desc.type"}';
2659-
// More nested aggregations are added, one per field found in the broeakout
2723+
// More nested aggregations are added, one per field found in the breakout
26602724
var field_count = 0;
2661-
var regExp = /([^\=]+)\=([^\=]+)/;
2662-
//var matches = regExp.exec("");
26632725

26642726
if (Array.isArray(breakout)) {
2665-
breakout.forEach((field) => {
2666-
//if (/([^\=]+)\=([^\=]+)/.exec(field)) {
2667-
var matches = regExp.exec(field);
2668-
var shouldAggregate = true; // default: include in aggregation
2669-
2670-
if (matches) {
2671-
//field = $1;
2672-
var fieldName = matches[1];
2673-
var value = matches[2];
2674-
2675-
// Check if this is an aggregated regex pattern (R/pattern/)
2676-
// If uppercase R, we should NOT add this field to the aggregation
2677-
// (all matches will be combined into a single metric)
2678-
if (/^R./.test(value)) {
2679-
shouldAggregate = false;
2680-
}
2681-
2682-
field = fieldName;
2683-
}
2727+
breakout.forEach((entry) => {
2728+
var bp = parseBreakoutEntry(entry);
26842729

2685-
// Only add to aggregation if shouldAggregate is true
2686-
if (shouldAggregate) {
2730+
if (!bp.aggregate) {
26872731
agg_str +=
26882732
',"aggs": { "metric_desc.names.' +
2689-
field +
2733+
bp.name +
26902734
'": { "terms": ' +
26912735
'{ "show_term_doc_count_error": true, "size": ' +
26922736
bigQuerySize +
26932737
',' +
26942738
'"field": "metric_desc.names.' +
2695-
field +
2739+
bp.name +
26962740
'" }';
26972741
field_count++;
26982742
}
@@ -2871,48 +2915,22 @@ getMetricGroupsFromBreakouts = async function (instance, sets, yearDotMonth) {
28712915
if (set.run != null) {
28722916
q.query.bool.filter.push(JSON.parse('{"term": {"run.run-uuid": "' + set.run + '"}}'));
28732917
}
2874-
// If the breakout contains a match requirement (something like "host=myhost"), then we must add a term filter for it.
2875-
// Multiple values can be specified with commas: "host=a,b,c" which will match any of those values.
2876-
// Regex patterns can be specified with r/pattern/ (separate metrics) or R/pattern/ (aggregated metric).
2877-
var regExp = /([^\=]+)\=([^\=]+)/;
2878-
set.breakout.forEach((field) => {
2879-
var matches = regExp.exec(field);
2880-
if (matches) {
2881-
field = matches[1];
2882-
value = matches[2];
2883-
2884-
// Check if it's a regex pattern: r/pattern/ or R/pattern/
2885-
// Group 1: r or R (lowercase = separate metrics, uppercase = aggregated)
2886-
// Group 2: delimiter character (usually /, but can be any char)
2887-
// Group 3: the actual regex pattern
2888-
// \2: backreference to ensure matching closing delimiter
2889-
var regexMatch = /^([rR])(.)(.+)\2$/.exec(value);
2890-
2891-
if (regexMatch) {
2892-
// It's a regex pattern
2893-
var isAggregated = regexMatch[1] === 'R';
2894-
var delimiter = regexMatch[2];
2895-
var pattern = regexMatch[3];
2896-
2897-
// Add regexp filter to OpenSearch query
2898-
// Both r/pattern/ and R/pattern/ use the same filter,
2899-
// the difference is in the aggregation (handled in getBreakoutAggregation)
2918+
// Add filters for breakout entries that specify values or regex patterns
2919+
set.breakout.forEach((entry) => {
2920+
var bp = parseBreakoutEntry(entry);
2921+
if (bp.regex) {
2922+
q.query.bool.filter.push(
2923+
JSON.parse('{"regexp": {"metric_desc.names.' + bp.name + '": ' + JSON.stringify(bp.regex) + '}}')
2924+
);
2925+
} else if (bp.values) {
2926+
if (bp.values.length > 1) {
29002927
q.query.bool.filter.push(
2901-
JSON.parse('{"regexp": {"metric_desc.names.' + field + '": ' + JSON.stringify(pattern) + '}}')
2928+
JSON.parse('{"terms": {"metric_desc.names.' + bp.name + '": ' + JSON.stringify(bp.values) + '}}')
29022929
);
29032930
} else {
2904-
// Not a regex pattern, handle as literal value(s)
2905-
// Multiple values are separated by '+': field=value1+value2
2906-
var values = value.split('+');
2907-
if (values.length > 1) {
2908-
// Multiple values: use "terms" query (note the plural)
2909-
q.query.bool.filter.push(
2910-
JSON.parse('{"terms": {"metric_desc.names.' + field + '": ' + JSON.stringify(values) + '}}')
2911-
);
2912-
} else {
2913-
// Single value: use "term" query (singular)
2914-
q.query.bool.filter.push(JSON.parse('{"term": {"metric_desc.names.' + field + '": "' + value + '"}}'));
2915-
}
2931+
q.query.bool.filter.push(
2932+
JSON.parse('{"term": {"metric_desc.names.' + bp.name + '": "' + bp.values[0] + '"}}')
2933+
);
29162934
}
29172935
}
29182936
});
@@ -2938,26 +2956,34 @@ getMetricGroupsFromBreakouts = async function (instance, sets, yearDotMonth) {
29382956
// Derive the label from each group and organize into a dict, key = label, value = the filter terms
29392957
var metricGroupTermsByLabel = getMetricGroupTermsByLabel(metricGroupTerms);
29402958

2941-
// Extract regexp filters that were excluded from aggregation (R/pattern/)
2942-
// These need to be preserved when querying for metric IDs
2959+
// For aggregated breakouts, insert a synthetic label segment at the correct position
29432960
var regexpFilters = [];
2944-
var regExp = /([^\=]+)\=([^\=]+)/;
2945-
sets[idx].breakout.forEach((field) => {
2946-
var matches = regExp.exec(field);
2947-
if (matches) {
2948-
var fieldName = matches[1];
2949-
var value = matches[2];
2950-
var regexMatch = /^([rR])(.)(.+)\2$/.exec(value);
2951-
if (regexMatch) {
2952-
var isAggregated = regexMatch[1] === 'R';
2953-
var pattern = regexMatch[3];
2954-
if (isAggregated) {
2955-
// This field was excluded from aggregation, need to preserve the regexp filter
2956-
regexpFilters.push({ field: fieldName, pattern: pattern });
2957-
}
2958-
}
2959-
}
2961+
var aggregatedPositions = [];
2962+
sets[idx].breakout.forEach((entry, bpIdx) => {
2963+
var bp = parseBreakoutEntry(entry);
2964+
if (!bp.aggregate) return;
2965+
if (bp.regex) {
2966+
regexpFilters.push({ field: bp.name, pattern: bp.regex });
2967+
}
2968+
aggregatedPositions.push({ position: bpIdx, segment: '<' + buildAggregateLabel(bp) + '>' });
29602969
});
2970+
if (aggregatedPositions.length > 0) {
2971+
var oldLabels = Object.keys(metricGroupTermsByLabel);
2972+
if (oldLabels.length === 0) {
2973+
var synLabel = aggregatedPositions.map(function (ap) { return ap.segment; }).join('-');
2974+
metricGroupTermsByLabel[synLabel] = '';
2975+
} else {
2976+
var updated = {};
2977+
oldLabels.forEach(function (oldLabel) {
2978+
var segments = oldLabel.match(/<[^>]*>/g) || [];
2979+
aggregatedPositions.forEach(function (ap) {
2980+
segments.splice(ap.position, 0, ap.segment);
2981+
});
2982+
updated[segments.join('-')] = metricGroupTermsByLabel[oldLabel];
2983+
});
2984+
metricGroupTermsByLabel = updated;
2985+
}
2986+
}
29612987

29622988
var thisLabelSet = {
29632989
run: sets[idx].run,
@@ -3519,14 +3545,7 @@ getMetricDataSets = async function (instance, sets, yearDotMonth) {
35193545
for (var i = 0; i < sets.length; i++) {
35203546
if (sets[i].breakout != 'undefined') {
35213547
for (var j = 0; j < sets[i].breakout.length; j++) {
3522-
var breakout = sets[i].breakout[j];
3523-
// The breakout requested might have a match included, for example, csid=1. We only
3524-
// want the string before the '='
3525-
var regExp = /([^\=]+)\=([^\=]+)/;
3526-
var matches = regExp.exec(breakout);
3527-
if (matches) {
3528-
breakout = matches[1];
3529-
}
3548+
var breakout = parseBreakoutEntry(sets[i].breakout[j]).name;
35303549
if (!setBreakouts[i].includes(breakout)) {
35313550
retMsg +=
35323551
'ERROR: the breakout [' +
@@ -3561,18 +3580,14 @@ getMetricDataSets = async function (instance, sets, yearDotMonth) {
35613580
// Check if any regex filters resulted in zero matches
35623581
for (var idx = 0; idx < metricGroupIdsByLabelSets.length; idx++) {
35633582
if (Object.keys(metricGroupIdsByLabelSets[idx]).length === 0) {
3564-
// This set has no metric groups - check if it was due to a regex filter
3583+
// This set has no metric groups - check if it was due to a regex or value filter
35653584
var regexFilters = [];
3566-
var regExp = /([^\=]+)\=([^\=]+)/;
3567-
sets[idx].breakout.forEach((field) => {
3568-
var matches = regExp.exec(field);
3569-
if (matches) {
3570-
var fieldName = matches[1];
3571-
var value = matches[2];
3572-
// Check if it's a regex pattern
3573-
if (/^[rR]./.test(value)) {
3574-
regexFilters.push({ field: fieldName, pattern: value });
3575-
}
3585+
sets[idx].breakout.forEach((entry) => {
3586+
var bp = parseBreakoutEntry(entry);
3587+
if (bp.regex) {
3588+
regexFilters.push({ field: bp.name, pattern: bp.regex });
3589+
} else if (bp.values) {
3590+
regexFilters.push({ field: bp.name, pattern: bp.values.join('+') });
35763591
}
35773592
});
35783593

@@ -3622,19 +3637,14 @@ getMetricDataSets = async function (instance, sets, yearDotMonth) {
36223637
// Build the label-decoder and the remaining breakouts
36233638
dataSets[i].usedBreakouts = sets[i].breakout;
36243639
dataSets[i].valueSeriesLabelDecoder = '';
3625-
var regExp = /([^\=]+)\=([^\=]+)/;
3626-
dataSets[i].usedBreakouts.forEach((field) => {
3627-
var matches = regExp.exec(field);
3628-
if (matches) {
3629-
field = matches[1];
3630-
value = matches[2];
3631-
}
3632-
dataSets[i].valueSeriesLabelDecoder += '-' + '<' + field + '>';
3633-
//TODO: validate if user's breakouts are available by checking against data.breakouts
3640+
var usedNames = [];
3641+
dataSets[i].usedBreakouts.forEach((entry) => {
3642+
var bp = parseBreakoutEntry(entry);
3643+
usedNames.push(bp.name);
3644+
dataSets[i].valueSeriesLabelDecoder += '-' + '<' + bp.name + '>';
36343645
});
36353646
dataSets[i].valueSeriesLabelDecoder = dataSets[i].valueSeriesLabelDecoder.replace('-', '');
3636-
// Breakouts already used should not show up in the list of avauilable breakouts
3637-
dataSets[i].remainingBreakouts = setBreakouts[i].filter((n) => !dataSets[i].usedBreakouts.includes(n));
3647+
dataSets[i].remainingBreakouts = setBreakouts[i].filter((n) => !usedNames.includes(n));
36383648
}
36393649

36403650
for (var i = 0; i < sets.length; i++) {

queries/cdmq/server.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1559,7 +1559,7 @@ app.post('/api/v1/metric-data', async (req, res) => {
15591559
var { run, period, begin, end, source, type, resolution, breakout, filter, instances: reqInstances } = req.body;
15601560

15611561
var reqStart = Date.now();
1562-
var breakoutStr = Array.isArray(breakout) ? breakout.join(',') : (breakout || 'none');
1562+
var breakoutStr = Array.isArray(breakout) ? breakout.map(function (b) { return typeof b === 'object' && b.name ? b.name : b; }).join(',') : (breakout || 'none');
15631563
serverLog('POST /api/v1/metric-data: ' + source + '::' + type + ' resolution=' + resolution + ' breakout=[' + breakoutStr + ']' + (filter ? ' filter=' + filter : '') + ' run=' + (run || 'none').toString().substring(0, 8) + '... period=' + (period || 'none').toString().substring(0, 8) + '...', req.reqId);
15641564
//serverLog(' curl: curl -s -X POST http://localhost:3000/api/v1/metric-data -H "Content-Type: application/json" -d \'' + JSON.stringify({ run: run, period: period, begin: begin, end: end, source: source, type: type, resolution: resolution, breakout: breakout, filter: filter }) + '\'', req.reqId);
15651565

0 commit comments

Comments
 (0)