diff --git a/glam/api/views.py b/glam/api/views.py index a0bf53178..a078135e0 100644 --- a/glam/api/views.py +++ b/glam/api/views.py @@ -73,6 +73,7 @@ def validate_request_legacy(**kwargs): validated_data["os"] = kwargs.get("os", "*") validated_data["num_versions"] = kwargs.get("versions", 3) validated_data["process"] = kwargs.get("process") + validated_data["metric_key"] = kwargs.get("metric_key") if validated_data["channel"] not in ["beta", "nightly", "release"]: raise ValidationError("Invalid channel: {}".format(validated_data["channel"])) @@ -116,6 +117,7 @@ def validate_request_glean(**kwargs): validated_data["os"] = kwargs.get("os", "*") validated_data["num_versions"] = kwargs.get("versions", 3) validated_data["ping_type"] = kwargs.get("ping_type") + validated_data["metric_key"] = kwargs.get("metric_key") if validated_data["channel"] not in ["beta", "nightly", "release"]: raise ValidationError("Invalid channel: {}".format(validated_data["channel"])) @@ -146,6 +148,36 @@ def validate_request_glean(**kwargs): return validated_data +def validate_metric_keys_request(**kwargs): + REQUIRED_QUERY_PARAMETERS = [ + "probe", + "channel", + "product", + ] + if any([k not in kwargs.keys() for k in REQUIRED_QUERY_PARAMETERS]): + missing = set(REQUIRED_QUERY_PARAMETERS) - set(kwargs.keys()) + raise ValidationError( + "Missing required query parameters: {}".format(", ".join(sorted(missing))) + ) + + validated_data = {} + validated_data["probe"] = kwargs.get("probe")[0] + validated_data["channel"] = kwargs.get("channel")[0] + validated_data["product"] = kwargs.get("product")[0] + validated_data["os"] = kwargs.get("os")[0] + + if validated_data["channel"] not in ["beta", "nightly", "release"]: + raise ValidationError("Invalid channel: {}".format(validated_data["channel"])) + + if validated_data["product"] not in ["fog", "fenix"]: + raise ValidationError("Invalid product: {}".format(validated_data["product"])) + + if validated_data["os"] not in ["Windows", "Darwin", "Mac", "Linux", "*"]: + raise ValidationError("Invalid os: {}".format(validated_data["os"])) + + return validated_data + + def get_firefox_aggregations(source, request, **kwargs): if source == "BigQuery": bqClient = get_bq_client() @@ -218,6 +250,8 @@ def get_firefox_aggregations_from_pg(request, **kwargs): if "process" in kwargs: dimensions.append(Q(process=kwargs["process"])) + if "metric_key" in kwargs and kwargs["metric_key"]: + dimensions.append(Q(metric_key=kwargs["metric_key"])) result = model.objects.filter(*dimensions) response = [] @@ -291,6 +325,15 @@ def get_firefox_aggregations_from_bq(bqClient, request, req_data): ) process_filter = "AND process = @process" + metric_key_filter = "" + if "metric_key" in req_data and req_data["metric_key"]: + query_parameters.append( + bigquery.ScalarQueryParameter( + "metric_key", "STRING", req_data["metric_key"] + ) + ) + metric_key_filter = "AND metric_key = @metric_key" + table = f"glam_desktop_{channel}_aggregates" query = f""" WITH versions AS ( @@ -314,6 +357,7 @@ def get_firefox_aggregations_from_bq(bqClient, request, req_data): metric = @metric AND os = @os {process_filter} + {metric_key_filter} {build_id_filter} AND version IN UNNEST(versions.selected_versions) """ @@ -461,6 +505,11 @@ def get_glean_aggregations_from_pg(request, **kwargs): Q(os=os), ] + # Add metric_key filtering if provided + metric_key = kwargs.get("metric_key") + if metric_key: + dimensions.append(Q(metric_key=metric_key)) + aggregation_level = kwargs["aggregationLevel"] # Whether to pull aggregations by version or build_id. if aggregation_level == "version": @@ -519,6 +568,7 @@ def get_glean_aggregations_from_bq(bqClient, request, req_data): ping_type = req_data["ping_type"] os = req_data["os"] aggregation_level = req_data["aggregation_level"] + metric_key = req_data["metric_key"] or "" table_id = f"glam_{product}_{channel}_aggregates" shas = {} @@ -528,6 +578,10 @@ def get_glean_aggregations_from_bq(bqClient, request, req_data): if product == "fog": shas = _get_firefox_shas(channel, hourly=True) build_id_filter = 'AND build_id != "*"' + if metric_key: + metric_key_filter = "AND metric_key = @metric_key" + else: + metric_key_filter = "" # Build the SQL query with parameters query = f""" WITH versions AS ( @@ -549,18 +603,24 @@ def get_glean_aggregations_from_bq(bqClient, request, req_data): versions WHERE metric = @metric + {metric_key_filter} AND ping_type = @ping_type AND os = @os {build_id_filter} AND version IN UNNEST(versions.selected_versions) """ + query_parameters = [ + bigquery.ScalarQueryParameter("metric", "STRING", probe), + bigquery.ScalarQueryParameter("ping_type", "STRING", ping_type), + bigquery.ScalarQueryParameter("os", "STRING", os), + bigquery.ScalarQueryParameter("num_versions", "INT64", num_versions), + ] + if metric_key: + query_parameters.append( + bigquery.ScalarQueryParameter("metric_key", "STRING", metric_key) + ) job_config = bigquery.QueryJobConfig( - query_parameters=[ - bigquery.ScalarQueryParameter("metric", "STRING", probe), - bigquery.ScalarQueryParameter("ping_type", "STRING", ping_type), - bigquery.ScalarQueryParameter("os", "STRING", os), - bigquery.ScalarQueryParameter("num_versions", "INT64", num_versions), - ] + query_parameters=query_parameters, ) with bqClient as client: query_job = client.query(query, job_config=job_config) @@ -666,6 +726,34 @@ def _get_fog_counts(app_id, versions, ping_type, os, by_build): return data +@api_view(["GET"]) +def metric_keys(request): + """ + Fetches metric keys from BigQuery for a given probe. + """ + req_data = validate_metric_keys_request(**request.GET) + product = req_data["product"] + channel = req_data["channel"] + probe = req_data["probe"] + os = req_data["os"] + + query = f""" + SELECT DISTINCT metric_key + FROM `{GLAM_BQ_PROD_PROJECT}.glam_etl.glam_{product}_{channel}_aggregates` + WHERE metric = @probe + AND os = @os + """ + job_config = bigquery.QueryJobConfig( + query_parameters=[ + bigquery.ScalarQueryParameter("probe", "STRING", probe), + bigquery.ScalarQueryParameter("os", "STRING", os), + ] + ) + with get_bq_client() as client: + query_job = client.query(query, job_config=job_config) + return Response({"metric_keys": [row.metric_key for row in query_job]}) + + @api_view(["POST"]) def aggregations(request): """ @@ -676,7 +764,8 @@ def aggregations(request): { "query": { "channel": "nightly", - "probe": "gc_ms", + "probe": "", + "metric_key": "", "process": "content" "versions": 5, # Defaults to 3 versions. "aggregationLevel": "version" # OR "build_id" diff --git a/glam/urls.py b/glam/urls.py index a3ac82219..373a6f08b 100644 --- a/glam/urls.py +++ b/glam/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ path("api/v1/data/", api_views.aggregations, name="v1-data"), + path("api/v1/metric-keys/", api_views.metric_keys, name="v1-metric-keys"), path("api/v1/probes/", api_views.probes, name="v1-probes"), path("api/v1/probes/random", api_views.random_probes, name="v1-random-probes"), path("api/v1/updates/", api_views.updates, name="v1-updates"), diff --git a/public/static/global.css b/public/static/global.css index 61ddf6ca4..71fb49aa2 100644 --- a/public/static/global.css +++ b/public/static/global.css @@ -579,6 +579,7 @@ main .details { padding: var(--space-1h); padding-left: var(--space-2x); padding-right: var(--space-2x); + z-index: -1; } /* probe labels */ @@ -660,6 +661,16 @@ main .details { background-color: var(--memory_distribution-bg); } +.label--labeled_custom_distribution { + color: var(--memory_distribution-color); + background-color: var(--memory_distribution-bg); +} + +.label--labeled_timing_distribution { + color: var(--memory_distribution-color); + background-color: var(--memory_distribution-bg); +} + .label--uuid { color: var(--uuid-color); background-color: var(--uuid-bg); diff --git a/src/components/controls/ProbeKeySelector.svelte b/src/components/controls/ProbeKeySelector.svelte index 5c7928413..6850b2510 100644 --- a/src/components/controls/ProbeKeySelector.svelte +++ b/src/components/controls/ProbeKeySelector.svelte @@ -2,13 +2,18 @@ import { CaretDown } from '@graph-paper/icons'; import { FloatingMenu, MenuList, MenuListItem } from '@graph-paper/menu'; import { tooltip as tooltipAction } from '@graph-paper/core/actions'; + import { createEventDispatcher } from 'svelte'; import { store } from '../../state/store'; + const dispatch = createEventDispatcher(); + export let options; export let currentKey; export let active; export let tooltipText = 'this probe has multiple keys associated with it'; + export let fieldName = 'metricKey'; + export let disableStoreUpdate = false; let button; let width; @@ -20,14 +25,16 @@ } function setValue(event) { - currentKey = event.detail.key; - store.setField('aggKey', currentKey); - active = false; - } + const selectedKey = event.detail.key; + currentKey = selectedKey; + + // Dispatch selection event to parent + dispatch('selection', { key: selectedKey }); + if (!disableStoreUpdate) { + store.setField(fieldName, selectedKey); + } - // initialize aggregation key if none has been selected yet - if (!$store.aggKey && options.length) { - store.setField('aggKey', options[0]); + active = false; } @@ -41,12 +48,19 @@ text-align: left; /* min-width: var(--space-16x); */ background-color: white; + border: 1px solid var(--cool-gray-300); display: grid; grid-auto-flow: column; width: max-content; grid-column-gap: var(--space-base); color: var(--subhead-gray-02); border-radius: var(--space-1h); + cursor: pointer; + } + + .activating-button:hover { + border-color: var(--cool-gray-400); + background-color: var(--cool-gray-50); } .menu-list-item__title { font-size: var(--text-01); @@ -58,7 +72,11 @@ } -
- {#each probeKeys as key, i (key)} - {#each aggregationTypes as aggType, i (aggType + timeHorizon + probeType + metricType)} - {#if key === currentKey && (aggregationTypes.length === 1 || aggType === currentAggregation)} -
- - Object.values( - d[ - metricType === 'proportions' - ? getProportionName( - $store.productDimensions.normalizationType - ) - : getCountName( - $store.productDimensions.normalizationType - ) - ] - ) + {#each aggregationTypes as aggType, i (aggType + timeHorizon + probeType + metricType)} + {#if aggregationTypes.length === 1 || aggType === currentAggregation} +
+ + Object.values( + d[ + metricType === 'proportions' + ? getProportionName( + $store.productDimensions.normalizationType + ) + : getCountName( + $store.productDimensions.normalizationType + ) + ] ) - .flat() - ), - ]} - /> -
- {/if} - {/each} + ) + .flat() + ), + ]} + /> +
+ {/if} {/each}
diff --git a/src/components/explore/QuantileExplorerView.svelte b/src/components/explore/QuantileExplorerView.svelte index 72ac0f708..e3f88e378 100644 --- a/src/components/explore/QuantileExplorerView.svelte +++ b/src/components/explore/QuantileExplorerView.svelte @@ -20,12 +20,10 @@ } from '../../config/shared'; import { - gatherProbeKeys, gatherAggregationTypes, - gatherDualLabeledProbeKeyMap, - transformDualLabeledData, + getDualLabeledSubKeys, } from '../../utils/probe-utils'; - import { store } from '../../state/store'; + import { store, processedMetricKeys, metricKeys } from '../../state/store'; const dispatch = createEventDispatcher(); @@ -35,18 +33,59 @@ export let timeHorizon = 'MONTH'; export let percentiles = [95, 75, 50, 25, 5]; - let isDualLabeled = $store.probe.type === 'dual_labeled_counter'; - let transformedData = isDualLabeled ? transformDualLabeledData(data) : data; - let totalAggs = Object.keys(Object.values(transformedData)[0]).length; + let totalAggs = Object.keys(Object.values(data)[0]).length; + let aggregationTypes = gatherAggregationTypes(data); - let aggregationTypes = gatherAggregationTypes(transformedData); - const dualLabeledKeys = gatherDualLabeledProbeKeyMap(transformedData); - let probeKeys = gatherProbeKeys(transformedData); + $: currentKey = $store.metricKey; + $: isDualLabeled = $store.probe.type === 'dual_labeled_counter'; + $: isLabeledMetric = + $store.probe && $store.probe.type && $store.probe.type.includes('labeled'); - let currentKey = $store.aggKey || probeKeys[0]; - $: currentSubKey = dualLabeledKeys[currentKey] - ? dualLabeledKeys[currentKey][0] - : null; + // Local, non-committing selections for label/sub-label to avoid + // triggering expensive data refetches on every change. + // These should sync with store values (from URL) but allow local changes + $: localMetricKey = $store.metricKey || ''; + $: localSubMetricKey = $store.subMetricKey || ''; + + // Compute local sub-keys based on local metric key selection + $: localSubMetricKeys = + isDualLabeled && localMetricKey && $metricKeys + ? getDualLabeledSubKeys($metricKeys, localMetricKey) + : []; + + // When main key changes, clear invalid sub key + $: { + if (isDualLabeled) { + const validSubs = localSubMetricKeys || []; + if (localSubMetricKey && !validSubs.includes(localSubMetricKey)) { + localSubMetricKey = ''; + } + } else { + localSubMetricKey = ''; + } + } + + // Apply button enablement + $: canGo = !!(localMetricKey && (!isDualLabeled || localSubMetricKey)); + + function applyKeySelections() { + if (!canGo) return; + store.setField('metricKey', localMetricKey); + if (isDualLabeled) { + store.setField('subMetricKey', localSubMetricKey); + } else { + store.setField('subMetricKey', ''); + } + } + + // Use full key assembled from the store (applied selection) + $: currentFullKey = isDualLabeled + ? `${$store.metricKey}[${$store.subMetricKey}]` + : $store.metricKey; + + // Local state for dropdown active states + let mainKeyActive = false; + let subKeyActive = false; let currentAggregation = aggregationTypes.includes('summed_histogram') ? 'summed_histogram' @@ -64,20 +103,16 @@ }; } - function filterQuantileData(d, agg, key, subKey) { + function filterQuantileData(d, agg, key) { return d.filter( - (di) => - di.client_agg_type === agg && - di.metric_key === key && - (!subKey || di.nested_metric_key === subKey) + (di) => di.client_agg_type === agg && di.metric_key === key ); } $: selectedData = filterQuantileData( - transformedData, + data, currentAggregation, - currentKey, - currentSubKey + currentFullKey ); $: densityMetricType = getHistogramName( @@ -90,12 +125,7 @@ : getPercentileName($store.productDimensions.normalizationType); const getYDomain = (source, normType) => { - let range = filterQuantileData( - source, - currentAggregation, - currentKey, - currentSubKey - ); + let range = filterQuantileData(source, currentAggregation, currentFullKey); let histogramRange = range[range.length - 1][ getHistogramName(normType) ].map((d) => d.bin); @@ -110,10 +140,7 @@ ]; return probeType === 'log' ? histogramRange : percentileRange; }; - $: yDomain = getYDomain( - transformedData, - $store.productDimensions.normalizationType - ); + $: yDomain = getYDomain(data, $store.productDimensions.normalizationType);
@@ -155,8 +223,7 @@ + on:selection={makeSelection('timeHorizon')} /> {/if}
@@ -164,8 +231,7 @@ + on:selection={makeSelection('percentiles')} /> @@ -177,81 +243,105 @@ + {aggregationTypes} /> {/if} - {#if probeKeys && probeKeys.length > 1} + + {#if isLabeledMetric}
- - + {#if isDualLabeled} + + {:else} + + {/if} + { + localMetricKey = e.detail.key; + }} />
{/if} - {#if isDualLabeled} + + {#if isDualLabeled && localMetricKey}
- + + options={localSubMetricKeys || []} + currentKey={localSubMetricKey} + bind:active={subKeyActive} + tooltipText="Select a category for this dual labeled counter" + fieldName="subMetricKey" + disableStoreUpdate={true} + on:selection={(e) => { + localSubMetricKey = e.detail.key; + }} />
{/if} + + {#if isLabeledMetric} +
+ +
+ {/if} +
- {#each probeKeys as key, i (key)} - {#each aggregationTypes as aggType, i (aggType + timeHorizon + key)} - {#if key === currentKey && aggType === currentAggregation} - {#key interpolate} -
- `${perc}%`} - yScaleType={probeType === 'log' ? 'scalePoint' : 'linear'} - {yDomain} - {interpolate} - > -
- -

- Interpolated -

- - -
-
-
- {/key} - {/if} - {/each} + {#each aggregationTypes as aggType, i (aggType + timeHorizon + currentKey)} + {#if aggType === currentAggregation} + {#key interpolate} +
+ `${perc}%`} + yScaleType={probeType === 'log' ? 'scalePoint' : 'linear'} + {yDomain} + {interpolate}> +
+ +

+ Interpolated +

+ + +
+
+
+ {/key} + {/if} {/each}
diff --git a/src/components/regions/MainSelectors.svelte b/src/components/regions/MainSelectors.svelte index 6eb35cb93..497772c6a 100644 --- a/src/components/regions/MainSelectors.svelte +++ b/src/components/regions/MainSelectors.svelte @@ -3,6 +3,7 @@ import { CaretDown } from '@graph-paper/icons'; import { MenuList, MenuListItem } from '@graph-paper/menu'; import DimensionMenu from '../controls/DimensionMenu.svelte'; + import productConfig from '../../config/products'; import { store, productConfigDimensions } from '../../state/store'; diff --git a/src/components/table/ProbeTableView.svelte b/src/components/table/ProbeTableView.svelte index c3e127fe9..130af7a9f 100644 --- a/src/components/table/ProbeTableView.svelte +++ b/src/components/table/ProbeTableView.svelte @@ -4,26 +4,22 @@ import AggregationTypeSelector from '../controls/AggregationTypeSelector.svelte'; import ProbeKeySelector from '../controls/ProbeKeySelector.svelte'; import { formatCount, formatPercentDecimal } from '../../utils/formatters'; - import { - gatherProbeKeys, - gatherAggregationTypes, - } from '../../utils/probe-utils'; + import { gatherAggregationTypes } from '../../utils/probe-utils'; import { PERCENTILES } from '../../utils/constants'; import { getProportionName, getPercentileName } from '../../config/shared'; - import { store } from '../../state/store'; + import { store, metricKeys } from '../../state/store'; export let data; export let probeType = 'categorical'; export let aggregationLevel = 'build_id'; export let aggregationTypes = gatherAggregationTypes(data); - export let probeKeys = gatherProbeKeys(data); export let colorMap; export let visibleBuckets; export let bucketOptions; export let densityMetricType; - let currentKey = probeKeys[0]; + let currentKey = $store.metricKey; let currentAggregation = aggregationTypes[0]; function filterResponseData(d, agg, key) { @@ -44,7 +40,7 @@
- {#if (aggregationTypes && aggregationTypes.length > 2) || (probeKeys && probeKeys.length > 1)} + {#if (aggregationTypes && aggregationTypes.length > 2) || ($metricKeys && $metricKeys.length > 1)}
- - -
- {/if}
{/if} - +
- {#if probeType === 'categorical'} + {#if needsLabelSelection || needsSubLabelSelection} + + + + {:else if probeType === 'categorical'} {:else} - probeType: {probeType} + Error: Unknown probe type ({probeType})
diff --git a/src/routing/wrappers/Probe.svelte b/src/routing/wrappers/Probe.svelte index af8545b0b..59b1674dd 100644 --- a/src/routing/wrappers/Probe.svelte +++ b/src/routing/wrappers/Probe.svelte @@ -7,6 +7,9 @@ import { isSelectedProcessValid } from '../../utils/probe-utils'; + + {#if $store.probe.loaded} {#await $dataset}
@@ -31,6 +34,10 @@ />
+ {:else if data.level === 'INFO' && data.key === 'SELECT_LABEL'} + + {:else if data.level === 'INFO' && data.key === 'SELECT_SUB_LABEL'} + {:else} {/if} diff --git a/src/state/api.js b/src/state/api.js index 02e7d8323..c2e61f055 100644 --- a/src/state/api.js +++ b/src/state/api.js @@ -1,5 +1,6 @@ const dataURL = '__BASE_DOMAIN__/api/v1/data/'; const randomProbeURL = '__BASE_DOMAIN__/api/v1/probes/random?'; +const metricKeysURL = '__BASE_DOMAIN__/api/v1/metric-keys/'; // We could eventually make a constants.js, this is low priority. const FETCH_ERROR_MESSAGES = { @@ -51,6 +52,34 @@ export async function getProbeData(params) { return data; } +export async function getMetricKeys(params) { + const queryParams = new URLSearchParams({ + product: params.product, + channel: params.channel, + probe: params.probe, + os: params.os, + }); + + const response = await fetch(`${metricKeysURL}?${queryParams}`); + + if (response.status >= 400 && response.status < 600) { + const errorCode = `code${response.status}`; + let msg = FETCH_ERROR_MESSAGES[errorCode] || ''; + if (!msg) { + msg = + response.status < 500 + ? FETCH_ERROR_MESSAGES.code4xx + : FETCH_ERROR_MESSAGES.code5xx; + } + const error = new Error(msg); + error.statusCode = response.status; + throw error; + } + + const data = await response.json(); + return data.metric_keys; +} + function getProbeSearchURL(productId, queryString, resultsLimit) { const URLResult = new URL( `__GLEAN_DICTIONARY_DOMAIN__/api/v1/metrics_search_${productId}` diff --git a/src/state/store.js b/src/state/store.js index 0e7e66222..7c6b3e0ba 100644 --- a/src/state/store.js +++ b/src/state/store.js @@ -4,6 +4,12 @@ import { createStore } from '../utils/create-store'; import sharedConfig from '../config/shared'; import productConfig from '../config/products'; +import { getMetricKeys } from './api'; +import { + getDualLabeledMainKeys, + getDualLabeledSubKeys, + reconstructDualLabeledKey, +} from '../utils/probe-utils'; export function getFromQueryString(fieldKey, isMulti = false) { const params = new URLSearchParams(window.location.search); @@ -43,7 +49,8 @@ function getDefaultState( state.route = {}; state.searchProduct = searchProducts[state.product]; - state.aggKey = getFromQueryString('aggKey') || ''; + state.metricKey = getFromQueryString('metricKey') || ''; + state.subMetricKey = getFromQueryString('subMetricKey') || ''; state.aggType = getFromQueryString('aggType') || 'avg'; state.currentPage = getFromQueryString('currentPage'); state.countView = getFromQueryString('countView') || 'clients'; @@ -182,55 +189,250 @@ function paramsAreValid(params) { export const datasetResponse = (level, key, data) => ({ level, key, data }); const cache = {}; +const metricKeysCache = {}; let previousQuery; -export const dataset = derived([store], ([$store], set) => { +export const metricKeys = derived([store], ([$store], set) => { if ( $store.probeName === '' || $store.probeName === undefined || - !$store.product + !$store.product || + !$store.probe || + !$store.probe.loaded ) { return; } + const isLabeledMetric = + $store.probe.type && $store.probe.type.includes('labeled'); + + if (!isLabeledMetric) { + // For non-labeled metrics, set empty array to indicate no keys needed + set([]); + return; + } + const activeProductConfig = getActiveProductConfig(); const params = activeProductConfig.getParamsForDataAPI($store); - const qs = toQueryString(params); - // invalid parameters, probe selected. - if (!paramsAreValid(params) && probeSelected($store.probeName)) { - const message = datasetResponse('ERROR', 'INVALID_PARAMETERS'); - // eslint-disable-next-line consistent-return - return message; + // Only fetch metric keys if we have a valid probe and parameters + if (!paramsAreValid(params) || !probeSelected($store.probeName)) { + return; + } + + const keyParams = { + product: $store.product, + channel: + $store.productDimensions.app_id || $store.productDimensions.channel, + probe: $store.probeName, + os: $store.productDimensions.os, + }; + + const keyCacheKey = `${keyParams.product}-${keyParams.channel}-${keyParams.probe}`; + + if (!(keyCacheKey in metricKeysCache)) { + metricKeysCache[keyCacheKey] = getMetricKeys(keyParams); } - // no probe selected. - if (!probeSelected($store.probeName)) { - const message = datasetResponse('INFO', 'DEFAULT_VIEW'); - // eslint-disable-next-line consistent-return - return message; + // Resolve the promise and set the result + metricKeysCache[keyCacheKey] + .then((keys) => { + set(keys); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error('Failed to fetch metric keys:', error); + set([]); + }); +}); + +// Processed metric keys for different metric types +export const processedMetricKeys = derived( + [store, metricKeys], + ([$store, $metricKeys]) => { + if (!$metricKeys || $metricKeys.length === 0) { + return []; + } + + const isDualLabeledCounter = + $store.probe && $store.probe.type === 'dual_labeled_counter'; + + if (isDualLabeledCounter) { + // For dual labeled counters, return only the main keys + return getDualLabeledMainKeys($metricKeys); + } + // For regular labeled metrics, return keys as-is + return $metricKeys; } +); + +// Sub-keys for dual labeled counters +export const subMetricKeys = derived( + [store, metricKeys], + ([$store, $metricKeys]) => { + if (!$metricKeys || $metricKeys.length === 0 || !$store.metricKey) { + return []; + } + + const isDualLabeledCounter = + $store.probe && $store.probe.type === 'dual_labeled_counter'; - if (!(qs in cache)) { - cache[qs] = activeProductConfig.fetchData(params, store); + if (isDualLabeledCounter) { + return getDualLabeledSubKeys($metricKeys, $store.metricKey); + } + return []; } +); + +export const dataset = derived( + [store, metricKeys], + ([$store, $metricKeys], set) => { + // Must wait for info from Dictionary before doing anything + if ( + $store.probeName === '' || + $store.probeName === undefined || + !$store.product || + !$store.probe || + !$store.probe.loaded + ) { + // Return a never-resolving promise to keep Probe.svelte in loading state + set(new Promise(() => {})); + return; + } + + // Handle labeled metrics for Glean products + const isLabeledMetric = + $store.probe && + $store.probe.loaded && + $store.probe.type && + $store.probe.type.includes('labeled'); + const isDualLabeledCounter = + $store.probe && $store.probe.type === 'dual_labeled_counter'; + + const activeProductConfig = getActiveProductConfig(); + let params = activeProductConfig.getParamsForDataAPI($store); + + // For dual labeled counters, reconstruct the full metric key + if (isDualLabeledCounter && $store.metricKey && $store.subMetricKey) { + params = { + ...params, + metric_key: reconstructDualLabeledKey( + $store.metricKey, + $store.subMetricKey + ), + }; + } - // compare the previousQuery to the current one. - // if the actual query params have changed, let's update the - // data set. - if (previousQuery !== qs) { - previousQuery = qs; - set( - cache[qs].then(({ data, probeType }) => - activeProductConfig.updateStoreAfterDataIsReceived( - data, - probeType, - store + const qs = toQueryString(params); + + // invalid parameters, probe selected. + if (!paramsAreValid(params) && probeSelected($store.probeName)) { + const message = datasetResponse('ERROR', 'INVALID_PARAMETERS'); + // eslint-disable-next-line consistent-return + set(Promise.resolve(message)); + return; + } + + // no probe selected. + if (!probeSelected($store.probeName)) { + const message = datasetResponse('INFO', 'DEFAULT_VIEW'); + // eslint-disable-next-line consistent-return + set(Promise.resolve(message)); + return; + } + + if (isLabeledMetric) { + // Check if this is a static labeled counter (has predefined labels) + const isStaticLabeledCounter = + $store.probe.labels !== undefined && + $store.probe.labels !== null && + $store.probe.labels.length > 0; + + // For static labeled counters, skip key selection and go straight to data + if (!isStaticLabeledCounter) { + // For dynamic labeled counters, require key selection + // If we don't have metric keys yet, don't proceed with data fetching + if ($metricKeys === undefined) { + return; // Still loading keys, don't fetch data yet + } + + // If metricKeys is empty array, it means this probe has no keys to fetch + if ($metricKeys && $metricKeys.length === 0) { + // This shouldn't happen for labeled metrics, but handle gracefully + return; + } + + // Get processed keys for display + const displayKeys = isDualLabeledCounter + ? getDualLabeledMainKeys($metricKeys) + : $metricKeys; + + // If we have keys but no selection, show message to select + if (displayKeys && displayKeys.length > 0 && !$store.metricKey) { + const message = datasetResponse('INFO', 'SELECT_LABEL', { + keys: displayKeys, + isDualLabeled: isDualLabeledCounter, + }); + // eslint-disable-next-line consistent-return + set(Promise.resolve(message)); + return; + } + + // If we have a selection but it's not valid, show message to select again + if ( + displayKeys && + displayKeys.length > 0 && + $store.metricKey && + !displayKeys.includes($store.metricKey) + ) { + const message = datasetResponse('INFO', 'SELECT_LABEL', { + keys: displayKeys, + isDualLabeled: isDualLabeledCounter, + }); + // eslint-disable-next-line consistent-return + set(Promise.resolve(message)); + return; + } + + // For dual labeled counters, we also need a sub-key selection + if (isDualLabeledCounter && $store.metricKey && !$store.subMetricKey) { + const message = datasetResponse('INFO', 'SELECT_SUB_LABEL', { + mainKey: $store.metricKey, + }); + // eslint-disable-next-line consistent-return + set(Promise.resolve(message)); + return; + } + + // If we reach here for dynamic labeled metrics, we must have a valid metricKey + // Only proceed with data fetching if we have the required key selection + if (!$store.metricKey) { + return; // Don't fetch data without a selected key + } + } + } + + if (!(qs in cache)) { + cache[qs] = activeProductConfig.fetchData(params, store); + } + + // compare the previousQuery to the current one. + // if the actual query params have changed, let's update the + // data set. + if (previousQuery !== qs) { + previousQuery = qs; + set( + cache[qs].then(({ data, probeType }) => + activeProductConfig.updateStoreAfterDataIsReceived( + data, + probeType, + store + ) ) - ) - ); + ); + } } -}); +); export const currentQuery = derived(store, ($store) => { const activeProductConfig = getActiveProductConfig(); diff --git a/src/utils/probe-utils.js b/src/utils/probe-utils.js index 92ccf77ac..65a7ce7c8 100644 --- a/src/utils/probe-utils.js +++ b/src/utils/probe-utils.js @@ -74,3 +74,43 @@ export function convertValueToProportions(obj) { }); return newObj; } + +// Parse metric keys for dual labeled counters +export function parseDualLabeledMetricKeys(metricKeys) { + // Filter to only keys that contain '[' and ']' + const validKeys = metricKeys.filter( + (key) => key.includes('[') && key.includes(']') + ); + + const keyMap = {}; + validKeys.forEach((key) => { + const [mainKey, subKeyWithBracket] = key.split('['); + const subKey = subKeyWithBracket.split(']')[0]; + + if (!keyMap[mainKey]) { + keyMap[mainKey] = []; + } + if (!keyMap[mainKey].includes(subKey)) { + keyMap[mainKey].push(subKey); + } + }); + + return keyMap; +} + +// Get main keys from dual labeled metric keys +export function getDualLabeledMainKeys(metricKeys) { + const keyMap = parseDualLabeledMetricKeys(metricKeys); + return Object.keys(keyMap); +} + +// Get sub keys for a specific main key +export function getDualLabeledSubKeys(metricKeys, mainKey) { + const keyMap = parseDualLabeledMetricKeys(metricKeys); + return keyMap[mainKey] || []; +} + +// Reconstruct the full metric key for API calls +export function reconstructDualLabeledKey(mainKey, subKey) { + return `${mainKey}[${subKey}]`; +}