Files
2024-02-15 17:01:50 +00:00

823 lines
24 KiB
TypeScript

import type {InjectedRouter} from 'react-router';
import {browserHistory} from 'react-router';
import {urlEncode} from '@sentry/utils';
import type {Location, Query} from 'history';
import * as Papa from 'papaparse';
import {openAddToDashboardModal} from 'sentry/actionCreators/modal';
import {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
import {URL_PARAM} from 'sentry/constants/pageFilters';
import {t} from 'sentry/locale';
import type {
NewQuery,
Organization,
OrganizationSummary,
Project,
SelectValue,
} from 'sentry/types';
import type {Event} from 'sentry/types/event';
import {getUtcDateString} from 'sentry/utils/dates';
import type {TableDataRow} from 'sentry/utils/discover/discoverQuery';
import type {EventData} from 'sentry/utils/discover/eventView';
import type EventView from 'sentry/utils/discover/eventView';
import type {
Aggregation,
Column,
ColumnType,
ColumnValueType,
Field,
} from 'sentry/utils/discover/fields';
import {
aggregateFunctionOutputType,
AGGREGATIONS,
explodeFieldString,
getAggregateAlias,
getAggregateArg,
getColumnsAndAggregates,
getEquation,
isAggregateEquation,
isEquation,
isMeasurement,
isSpanOperationBreakdownField,
measurementType,
PROFILING_FIELDS,
TRACING_FIELDS,
} from 'sentry/utils/discover/fields';
import type {DisplayModes} from 'sentry/utils/discover/types';
import {TOP_N} from 'sentry/utils/discover/types';
import {getTitle} from 'sentry/utils/events';
import {DISCOVER_FIELDS, FieldValueType, getFieldDefinition} from 'sentry/utils/fields';
import localStorage from 'sentry/utils/localStorage';
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
import type {WidgetQuery} from '../dashboards/types';
import {DashboardWidgetSource, DisplayType} from '../dashboards/types';
import {transactionSummaryRouteWithQuery} from '../performance/transactionSummary/utils';
import {displayModeToDisplayType} from './savedQuery/utils';
import type {FieldValue, TableColumn} from './table/types';
import {FieldValueKind} from './table/types';
import {ALL_VIEWS, TRANSACTION_VIEWS, WEB_VITALS_VIEWS} from './data';
export type QueryWithColumnState =
| Query
| {
field: string | string[] | null | undefined;
sort: string | string[] | null | undefined;
};
const TEMPLATE_TABLE_COLUMN: TableColumn<string> = {
key: '',
name: '',
type: 'never',
isSortable: false,
column: Object.freeze({kind: 'field', field: ''}),
width: COL_WIDTH_UNDEFINED,
};
// TODO(mark) these types are coupled to the gridEditable component types and
// I'd prefer the types to be more general purpose but that will require a second pass.
export function decodeColumnOrder(fields: Readonly<Field[]>): TableColumn<string>[] {
return fields.map((f: Field) => {
const column: TableColumn<string> = {...TEMPLATE_TABLE_COLUMN};
const col = explodeFieldString(f.field, f.alias);
const columnName = f.field;
if (isEquation(f.field)) {
column.key = f.field;
column.name = getEquation(columnName);
column.type = 'number';
} else {
column.key = columnName;
column.name = columnName;
}
column.width = f.width || COL_WIDTH_UNDEFINED;
if (col.kind === 'function') {
// Aggregations can have a strict outputType or they can inherit from their field.
// Otherwise use the FIELDS data to infer types.
const outputType = aggregateFunctionOutputType(col.function[0], col.function[1]);
if (outputType !== null) {
column.type = outputType;
}
const aggregate = AGGREGATIONS[col.function[0]];
column.isSortable = aggregate?.isSortable;
} else if (col.kind === 'field') {
if (getFieldDefinition(col.field) !== null) {
column.type = getFieldDefinition(col.field)?.valueType as ColumnValueType;
} else if (isMeasurement(col.field)) {
column.type = measurementType(col.field);
} else if (isSpanOperationBreakdownField(col.field)) {
column.type = 'duration';
}
}
column.column = col;
return column;
});
}
export function pushEventViewToLocation(props: {
location: Location;
nextEventView: EventView;
extraQuery?: Query;
}) {
const {location, nextEventView} = props;
const extraQuery = props.extraQuery || {};
const queryStringObject = nextEventView.generateQueryStringObject();
browserHistory.push({
...location,
query: {
...extraQuery,
...queryStringObject,
},
});
}
export function generateTitle({
eventView,
event,
organization,
}: {
eventView: EventView;
event?: Event;
organization?: Organization;
}) {
const titles = [t('Discover')];
const eventViewName = eventView.name;
if (typeof eventViewName === 'string' && String(eventViewName).trim().length > 0) {
titles.push(String(eventViewName).trim());
}
const eventTitle = event ? getTitle(event, organization?.features).title : undefined;
if (eventTitle) {
titles.push(eventTitle);
}
titles.reverse();
return titles.join(' — ');
}
export function getPrebuiltQueries(organization: Organization) {
const views = [...ALL_VIEWS];
if (organization.features.includes('performance-view')) {
// insert transactions queries at index 2
views.splice(2, 0, ...TRANSACTION_VIEWS);
views.push(...WEB_VITALS_VIEWS);
}
return views;
}
function disableMacros(value: string | null | boolean | number) {
const unsafeCharacterRegex = /^[\=\+\-\@]/;
if (typeof value === 'string' && `${value}`.match(unsafeCharacterRegex)) {
return `'${value}`;
}
return value;
}
export function downloadAsCsv(tableData, columnOrder, filename) {
const {data} = tableData;
const headings = columnOrder.map(column => column.name);
const keys = columnOrder.map(column => column.key);
const csvContent = Papa.unparse({
fields: headings,
data: data.map(row =>
keys.map(key => {
return disableMacros(row[key]);
})
),
});
// Need to also manually replace # since encodeURI skips them
const encodedDataUrl = `data:text/csv;charset=utf8,${encodeURIComponent(csvContent)}`;
// Create a download link then click it, this is so we can get a filename
const link = document.createElement('a');
const now = new Date();
link.setAttribute('href', encodedDataUrl);
link.setAttribute('download', `${filename} ${getUtcDateString(now)}.csv`);
link.click();
link.remove();
// Make testing easier
return encodedDataUrl;
}
const ALIASED_AGGREGATES_COLUMN = {
last_seen: 'timestamp',
failure_count: 'transaction.status',
};
/**
* Convert an aggregate into the resulting column from a drilldown action.
* The result is null if the drilldown results in the aggregate being removed.
*/
function drilldownAggregate(
func: Extract<Column, {kind: 'function'}>
): Extract<Column, {kind: 'field'}> | null {
const key = func.function[0];
const aggregation = AGGREGATIONS[key];
let column = func.function[1];
if (ALIASED_AGGREGATES_COLUMN.hasOwnProperty(key)) {
// Some aggregates are just shortcuts to other aggregates with
// predefined arguments so we can directly map them to the result.
column = ALIASED_AGGREGATES_COLUMN[key];
} else if (aggregation?.parameters?.[0]) {
const parameter = aggregation.parameters[0];
if (parameter.kind !== 'column') {
// The aggregation does not accept a column as a parameter,
// so we clear the column.
column = '';
} else if (!column && parameter.required === false) {
// The parameter was not given for a non-required parameter,
// so we fall back to the default.
column = parameter.defaultValue;
}
} else {
// The aggregation does not exist or does not have any parameters,
// so we clear the column.
column = '';
}
return column ? {kind: 'field', field: column} : null;
}
/**
* Convert an aggregated query into one that does not have aggregates.
* Will also apply additions conditions defined in `additionalConditions`
* and generate conditions based on the `dataRow` parameter and the current fields
* in the `eventView`.
*/
export function getExpandedResults(
eventView: EventView,
additionalConditions: Record<string, string>,
dataRow?: TableDataRow | Event
): EventView {
const fieldSet = new Set();
// Expand any functions in the resulting column, and dedupe the result.
// Mark any column as null to remove it.
const expandedColumns: (Column | null)[] = eventView.fields.map((field: Field) => {
const exploded = explodeFieldString(field.field, field.alias);
const column = exploded.kind === 'function' ? drilldownAggregate(exploded) : exploded;
if (
// if expanding the function failed
column === null ||
// the new column is already present
fieldSet.has(column.field) ||
// Skip aggregate equations, their functions will already be added so we just want to remove it
isAggregateEquation(field.field)
) {
return null;
}
fieldSet.add(column.field);
return column;
});
// id should be default column when expanded results in no columns; but only if
// the Discover query's columns is non-empty.
// This typically occurs in Discover drilldowns.
if (fieldSet.size === 0 && expandedColumns.length) {
expandedColumns[0] = {kind: 'field', field: 'id'};
}
// update the columns according the the expansion above
const nextView = expandedColumns.reduceRight(
(newView, column, index) =>
column === null
? newView.withDeletedColumn(index, undefined)
: newView.withUpdatedColumn(index, column, undefined),
eventView.clone()
);
nextView.query = generateExpandedConditions(nextView, additionalConditions, dataRow);
return nextView;
}
/**
* Create additional conditions based on the fields in an EventView
* and a datarow/event
*/
function generateAdditionalConditions(
eventView: EventView,
dataRow?: TableDataRow | Event
): Record<string, string | string[]> {
const specialKeys = Object.values(URL_PARAM);
const conditions: Record<string, string | string[]> = {};
if (!dataRow) {
return conditions;
}
eventView.fields.forEach((field: Field) => {
const column = explodeFieldString(field.field, field.alias);
// Skip aggregate fields
if (column.kind === 'function') {
return;
}
const dataKey = getAggregateAlias(field.field);
// Append the current field as a condition if it exists in the dataRow
// Or is a simple key in the event. More complex deeply nested fields are
// more challenging to get at as their location in the structure does not
// match their name.
if (dataRow.hasOwnProperty(dataKey)) {
let value = dataRow[dataKey];
if (Array.isArray(value)) {
if (value.length > 1) {
conditions[column.field] = value;
return;
}
// An array with only one value is equivalent to the value itself.
value = value[0];
}
// if the value will be quoted, then do not trim it as the whitespaces
// may be important to the query and should not be trimmed
const shouldQuote =
value === null || value === undefined
? false
: /[\s\(\)\\"]/g.test(String(value).trim());
const nextValue =
value === null || value === undefined
? ''
: shouldQuote
? String(value)
: String(value).trim();
if (isMeasurement(column.field) && !nextValue) {
// Do not add measurement conditions if nextValue is falsey.
// It's expected that nextValue is a numeric value.
return;
}
switch (column.field) {
case 'timestamp':
// normalize the "timestamp" field to ensure the payload works
conditions[column.field] = getUtcDateString(nextValue);
break;
default:
conditions[column.field] = nextValue;
}
}
// If we have an event, check tags as well.
if (dataRow.tags && Array.isArray(dataRow.tags)) {
const tagIndex = dataRow.tags.findIndex(item => item.key === dataKey);
if (tagIndex > -1) {
const key = specialKeys.includes(column.field)
? `tags[${column.field}]`
: column.field;
const tagValue = dataRow.tags[tagIndex].value;
conditions[key] = tagValue;
}
}
});
return conditions;
}
/**
* Discover queries can query either Errors, Transactions or a combination
* of the two datasets. This is a util to determine if the query will excusively
* hit the Transactions dataset.
*/
export function usesTransactionsDataset(eventView: EventView, yAxisValue: string[]) {
let usesTransactions: boolean = false;
const parsedQuery = new MutableSearch(eventView.query);
for (let index = 0; index < yAxisValue.length; index++) {
const yAxis = yAxisValue[index];
const aggregateArg = getAggregateArg(yAxis) ?? '';
if (isMeasurement(aggregateArg) || aggregateArg === 'transaction.duration') {
usesTransactions = true;
break;
}
const eventTypeFilter = parsedQuery.getFilterValues('event.type');
if (
eventTypeFilter.length > 0 &&
eventTypeFilter.every(filter => filter === 'transaction')
) {
usesTransactions = true;
break;
}
}
return usesTransactions;
}
function generateExpandedConditions(
eventView: EventView,
additionalConditions: Record<string, string>,
dataRow?: TableDataRow | Event
): string {
const parsedQuery = new MutableSearch(eventView.query);
// Remove any aggregates from the search conditions.
// otherwise, it'll lead to an invalid query result.
for (const key in parsedQuery.filters) {
const column = explodeFieldString(key);
if (column.kind === 'function') {
parsedQuery.removeFilter(key);
}
}
const conditions: Record<string, string | string[]> = Object.assign(
{},
additionalConditions,
generateAdditionalConditions(eventView, dataRow)
);
// Add additional conditions provided and generated.
for (const key in conditions) {
const value = conditions[key];
if (Array.isArray(value)) {
parsedQuery.setFilterValues(key, value);
continue;
}
if (key === 'project.id') {
eventView.project = [...eventView.project, parseInt(value, 10)];
continue;
}
if (key === 'environment') {
if (!eventView.environment.includes(value)) {
eventView.environment = [...eventView.environment, value];
}
continue;
}
const column = explodeFieldString(key);
// Skip aggregates as they will be invalid.
if (column.kind === 'function') {
continue;
}
parsedQuery.setFilterValues(key, [value]);
}
return parsedQuery.formatString();
}
type FieldGeneratorOpts = {
organization: OrganizationSummary;
aggregations?: Record<string, Aggregation>;
customMeasurements?: {functions: string[]; key: string}[] | null;
fieldKeys?: string[];
measurementKeys?: string[] | null;
spanOperationBreakdownKeys?: string[];
tagKeys?: string[] | null;
};
export function generateFieldOptions({
organization,
tagKeys,
measurementKeys,
spanOperationBreakdownKeys,
customMeasurements,
aggregations = AGGREGATIONS,
fieldKeys = DISCOVER_FIELDS,
}: FieldGeneratorOpts) {
let functions = Object.keys(aggregations);
// Strip tracing features if the org doesn't have access.
if (!organization.features.includes('performance-view')) {
fieldKeys = fieldKeys.filter(item => !TRACING_FIELDS.includes(item));
functions = functions.filter(item => !TRACING_FIELDS.includes(item));
}
// Strip profiling features if the org doesn't have access.
if (!organization.features.includes('profiling')) {
fieldKeys = fieldKeys.filter(item => !PROFILING_FIELDS.includes(item));
}
// Strip device.class if the org doesn't have access.
if (!organization.features.includes('device-classification')) {
fieldKeys = fieldKeys.filter(item => item !== 'device.class');
}
const fieldOptions: Record<string, SelectValue<FieldValue>> = {};
// Index items by prefixed keys as custom tags can overlap both fields and
// function names. Having a mapping makes finding the value objects easier
// later as well.
functions.forEach(func => {
const ellipsis = aggregations[func].parameters.length ? '\u2026' : '';
const parameters = aggregations[func].parameters.map(param => {
const overrides = AGGREGATIONS[func].getFieldOverrides;
if (typeof overrides === 'undefined') {
return param;
}
return {
...param,
...overrides({parameter: param}),
};
});
fieldOptions[`function:${func}`] = {
label: `${func}(${ellipsis})`,
value: {
kind: FieldValueKind.FUNCTION,
meta: {
name: func,
parameters,
},
},
};
});
fieldKeys.forEach(field => {
fieldOptions[`field:${field}`] = {
label: field,
value: {
kind: FieldValueKind.FIELD,
meta: {
name: field,
dataType: (getFieldDefinition(field)?.valueType ??
FieldValueType.STRING) as ColumnType,
},
},
};
});
if (measurementKeys !== undefined && measurementKeys !== null) {
measurementKeys.sort();
measurementKeys.forEach(measurement => {
fieldOptions[`measurement:${measurement}`] = {
label: measurement,
value: {
kind: FieldValueKind.MEASUREMENT,
meta: {name: measurement, dataType: measurementType(measurement)},
},
};
});
}
if (customMeasurements !== undefined && customMeasurements !== null) {
customMeasurements.sort(({key: currentKey}, {key: nextKey}) =>
currentKey > nextKey ? 1 : currentKey === nextKey ? 0 : -1
);
customMeasurements.forEach(({key, functions: supportedFunctions}) => {
fieldOptions[`measurement:${key}`] = {
label: key,
value: {
kind: FieldValueKind.CUSTOM_MEASUREMENT,
meta: {
name: key,
dataType: measurementType(key),
functions: supportedFunctions,
},
},
};
});
}
if (Array.isArray(spanOperationBreakdownKeys)) {
spanOperationBreakdownKeys.sort();
spanOperationBreakdownKeys.forEach(breakdownField => {
fieldOptions[`span_op_breakdown:${breakdownField}`] = {
label: breakdownField,
value: {
kind: FieldValueKind.BREAKDOWN,
meta: {name: breakdownField, dataType: 'duration'},
},
};
});
}
if (tagKeys !== undefined && tagKeys !== null) {
tagKeys.sort();
tagKeys.forEach(tag => {
const tagValue =
fieldKeys.includes(tag) || AGGREGATIONS.hasOwnProperty(tag)
? `tags[${tag}]`
: tag;
fieldOptions[`tag:${tag}`] = {
label: tag,
value: {
kind: FieldValueKind.TAG,
meta: {name: tagValue, dataType: 'string'},
},
};
});
}
return fieldOptions;
}
const RENDER_PREBUILT_KEY = 'discover-render-prebuilt';
export function shouldRenderPrebuilt(): boolean {
const shouldRender = localStorage.getItem(RENDER_PREBUILT_KEY);
return shouldRender === 'true' || shouldRender === null;
}
export function setRenderPrebuilt(value: boolean) {
localStorage.setItem(RENDER_PREBUILT_KEY, value ? 'true' : 'false');
}
export function eventViewToWidgetQuery({
eventView,
yAxis,
displayType,
}: {
displayType: DisplayType;
eventView: EventView;
yAxis?: string | string[];
}) {
const fields = eventView.fields.map(({field}) => field);
const {columns, aggregates} = getColumnsAndAggregates(fields);
const sort = eventView.sorts[0];
const queryYAxis = typeof yAxis === 'string' ? [yAxis] : yAxis ?? ['count()'];
let orderby = '';
// The orderby should only be set to sort.field if it is a Top N query
// since the query uses all of the fields, or if the ordering is used in the y-axis
if (sort) {
let orderbyFunction = '';
const aggregateFields = [...queryYAxis, ...aggregates];
for (let i = 0; i < aggregateFields.length; i++) {
if (sort.field === getAggregateAlias(aggregateFields[i])) {
orderbyFunction = aggregateFields[i];
break;
}
}
const bareOrderby = orderbyFunction === '' ? sort.field : orderbyFunction;
if (displayType === DisplayType.TOP_N || bareOrderby) {
orderby = `${sort.kind === 'desc' ? '-' : ''}${bareOrderby}`;
}
}
let newAggregates = aggregates;
if (displayType !== DisplayType.TABLE) {
newAggregates = queryYAxis;
}
const widgetQuery: WidgetQuery = {
name: '',
aggregates: newAggregates,
columns: [...(displayType === DisplayType.TOP_N ? columns : [])],
fields: [...(displayType === DisplayType.TOP_N ? fields : []), ...queryYAxis],
conditions: eventView.query,
orderby,
};
return widgetQuery;
}
export function handleAddQueryToDashboard({
eventView,
location,
query,
organization,
router,
yAxis,
}: {
eventView: EventView;
location: Location;
organization: Organization;
router: InjectedRouter;
query?: NewQuery;
yAxis?: string | string[];
}) {
const displayType = displayModeToDisplayType(eventView.display as DisplayModes);
const defaultWidgetQuery = eventViewToWidgetQuery({
eventView,
displayType,
yAxis,
});
const {query: widgetAsQueryParams} = constructAddQueryToDashboardLink({
eventView,
query,
organization,
yAxis,
location,
});
openAddToDashboardModal({
organization,
selection: {
projects: eventView.project,
environments: eventView.environment,
datetime: {
start: eventView.start,
end: eventView.end,
period: eventView.statsPeriod,
utc: eventView.utc,
},
},
widget: {
title: query?.name ?? eventView.name,
displayType: displayType === DisplayType.TOP_N ? DisplayType.AREA : displayType,
queries: [
{
...defaultWidgetQuery,
aggregates: [...(typeof yAxis === 'string' ? [yAxis] : yAxis ?? ['count()'])],
},
],
interval: eventView.interval,
limit:
displayType === DisplayType.TOP_N
? Number(eventView.topEvents) || TOP_N
: undefined,
},
router,
widgetAsQueryParams,
location,
});
return;
}
export function getTargetForTransactionSummaryLink(
dataRow: EventData,
organization: Organization,
projects?: Project[],
nextView?: EventView,
location?: Location
) {
let projectID: string | string[] | undefined;
const filterProjects = location?.query.project;
if (typeof filterProjects === 'string' && filterProjects !== '-1') {
// Project selector in discover has just one selected project
projectID = filterProjects;
} else {
const projectMatch = projects?.find(
project =>
project.slug && [dataRow['project.name'], dataRow.project].includes(project.slug)
);
projectID = projectMatch ? [projectMatch.id] : undefined;
}
const target = transactionSummaryRouteWithQuery({
orgSlug: organization.slug,
transaction: String(dataRow.transaction),
projectID,
query: nextView?.getPageFiltersQuery() || {},
});
// Pass on discover filter params when there are multiple
// projects associated with the transaction
if (!projectID && filterProjects) {
target.query.project = filterProjects;
}
return target;
}
export function constructAddQueryToDashboardLink({
eventView,
query,
organization,
yAxis,
location,
}: {
eventView: EventView;
organization: Organization;
location?: Location;
query?: NewQuery;
yAxis?: string | string[];
}) {
const displayType = displayModeToDisplayType(eventView.display as DisplayModes);
const defaultTableFields = eventView.fields.map(({field}) => field);
const defaultWidgetQuery = eventViewToWidgetQuery({
eventView,
displayType,
yAxis,
});
const defaultTitle =
query?.name ?? (eventView.name !== 'All Events' ? eventView.name : undefined);
return {
pathname: `/organizations/${organization.slug}/dashboards/new/widget/new/`,
query: {
...location?.query,
source: DashboardWidgetSource.DISCOVERV2,
start: eventView.start,
end: eventView.end,
statsPeriod: eventView.statsPeriod,
defaultWidgetQuery: urlEncode(defaultWidgetQuery),
defaultTableColumns: defaultTableFields,
defaultTitle,
displayType: displayType === DisplayType.TOP_N ? DisplayType.AREA : displayType,
limit:
displayType === DisplayType.TOP_N
? Number(eventView.topEvents) || TOP_N
: undefined,
},
};
}