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

877 lines
27 KiB
TypeScript

import {browserHistory} from 'react-router';
import {EventFixture} from 'sentry-fixture/event';
import {LocationFixture} from 'sentry-fixture/locationFixture';
import {initializeOrg} from 'sentry-test/initializeOrg';
import {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
import type {EventViewOptions} from 'sentry/utils/discover/eventView';
import EventView from 'sentry/utils/discover/eventView';
import {DisplayType} from 'sentry/views/dashboards/types';
import {
decodeColumnOrder,
downloadAsCsv,
eventViewToWidgetQuery,
generateFieldOptions,
getExpandedResults,
pushEventViewToLocation,
} from 'sentry/views/discover/utils';
const baseView: EventViewOptions = {
display: undefined,
start: undefined,
end: undefined,
id: '0',
name: undefined,
fields: [],
createdBy: undefined,
environment: [],
project: [],
query: '',
sorts: [],
statsPeriod: undefined,
team: [],
topEvents: undefined,
};
describe('decodeColumnOrder', function () {
it('can decode 0 elements', function () {
const results = decodeColumnOrder([]);
expect(Array.isArray(results)).toBeTruthy();
expect(results).toHaveLength(0);
});
it('can decode fields', function () {
const results = decodeColumnOrder([{field: 'title', width: 123}]);
expect(Array.isArray(results)).toBeTruthy();
expect(results[0]).toEqual({
key: 'title',
name: 'title',
column: {
kind: 'field',
field: 'title',
},
width: 123,
isSortable: false,
type: 'string',
});
});
it('can decode measurement fields', function () {
const results = decodeColumnOrder([{field: 'measurements.foo', width: 123}]);
expect(Array.isArray(results)).toBeTruthy();
expect(results[0]).toEqual({
key: 'measurements.foo',
name: 'measurements.foo',
column: {
kind: 'field',
field: 'measurements.foo',
},
width: 123,
isSortable: false,
type: 'number',
});
});
it('can decode span op breakdown fields', function () {
const results = decodeColumnOrder([{field: 'spans.foo', width: 123}]);
expect(Array.isArray(results)).toBeTruthy();
expect(results[0]).toEqual({
key: 'spans.foo',
name: 'spans.foo',
column: {
kind: 'field',
field: 'spans.foo',
},
width: 123,
isSortable: false,
type: 'duration',
});
});
it('can decode aggregate functions with no arguments', function () {
let results = decodeColumnOrder([{field: 'count()', width: 123}]);
expect(Array.isArray(results)).toBeTruthy();
expect(results[0]).toEqual({
key: 'count()',
name: 'count()',
column: {
kind: 'function',
function: ['count', '', undefined, undefined],
},
width: 123,
isSortable: true,
type: 'number',
});
results = decodeColumnOrder([{field: 'p75()', width: 123}]);
expect(results[0].type).toEqual('duration');
results = decodeColumnOrder([{field: 'p99()', width: 123}]);
expect(results[0].type).toEqual('duration');
});
it('can decode elements with aggregate functions with arguments', function () {
const results = decodeColumnOrder([{field: 'avg(transaction.duration)'}]);
expect(Array.isArray(results)).toBeTruthy();
expect(results[0]).toEqual({
key: 'avg(transaction.duration)',
name: 'avg(transaction.duration)',
column: {
kind: 'function',
function: ['avg', 'transaction.duration', undefined, undefined],
},
width: COL_WIDTH_UNDEFINED,
isSortable: true,
type: 'duration',
});
});
it('can decode elements with aggregate functions with multiple arguments', function () {
const results = decodeColumnOrder([
{field: 'percentile(transaction.duration, 0.65)'},
]);
expect(Array.isArray(results)).toBeTruthy();
expect(results[0]).toEqual({
key: 'percentile(transaction.duration, 0.65)',
name: 'percentile(transaction.duration, 0.65)',
column: {
kind: 'function',
function: ['percentile', 'transaction.duration', '0.65', undefined],
},
width: COL_WIDTH_UNDEFINED,
isSortable: true,
type: 'duration',
});
});
it('can decode elements with aggregate functions using measurements', function () {
const results = decodeColumnOrder([{field: 'avg(measurements.foo)'}]);
expect(Array.isArray(results)).toBeTruthy();
expect(results[0]).toEqual({
key: 'avg(measurements.foo)',
name: 'avg(measurements.foo)',
column: {
kind: 'function',
function: ['avg', 'measurements.foo', undefined, undefined],
},
width: COL_WIDTH_UNDEFINED,
isSortable: true,
type: 'number',
});
});
it('can decode elements with aggregate functions with multiple arguments using measurements', function () {
const results = decodeColumnOrder([{field: 'percentile(measurements.lcp, 0.65)'}]);
expect(Array.isArray(results)).toBeTruthy();
expect(results[0]).toEqual({
key: 'percentile(measurements.lcp, 0.65)',
name: 'percentile(measurements.lcp, 0.65)',
column: {
kind: 'function',
function: ['percentile', 'measurements.lcp', '0.65', undefined],
},
width: COL_WIDTH_UNDEFINED,
isSortable: true,
type: 'duration',
});
});
it('can decode elements with aggregate functions using span op breakdowns', function () {
const results = decodeColumnOrder([{field: 'avg(spans.foo)'}]);
expect(Array.isArray(results)).toBeTruthy();
expect(results[0]).toEqual({
key: 'avg(spans.foo)',
name: 'avg(spans.foo)',
column: {
kind: 'function',
function: ['avg', 'spans.foo', undefined, undefined],
},
width: COL_WIDTH_UNDEFINED,
isSortable: true,
type: 'duration',
});
});
it('can decode elements with aggregate functions with multiple arguments using span op breakdowns', function () {
const results = decodeColumnOrder([{field: 'percentile(spans.lcp, 0.65)'}]);
expect(Array.isArray(results)).toBeTruthy();
expect(results[0]).toEqual({
key: 'percentile(spans.lcp, 0.65)',
name: 'percentile(spans.lcp, 0.65)',
column: {
kind: 'function',
function: ['percentile', 'spans.lcp', '0.65', undefined],
},
width: COL_WIDTH_UNDEFINED,
isSortable: true,
type: 'duration',
});
});
});
describe('pushEventViewToLocation', function () {
const state: EventViewOptions = {
...baseView,
id: '1234',
name: 'best query',
fields: [{field: 'count()', width: 420}, {field: 'project.id'}],
sorts: [{field: 'count', kind: 'desc'}],
query: 'event.type:error',
project: [42],
start: '2019-10-01T00:00:00',
end: '2019-10-02T00:00:00',
statsPeriod: '14d',
environment: ['staging'],
};
const location = LocationFixture({
query: {
bestCountry: 'canada',
},
});
it('correct query string object pushed to history', function () {
const eventView = new EventView({...baseView, ...state});
pushEventViewToLocation({
location,
nextEventView: eventView,
});
expect(browserHistory.push).toHaveBeenCalledWith(
expect.objectContaining({
query: expect.objectContaining({
id: '1234',
name: 'best query',
field: ['count()', 'project.id'],
widths: [420],
sort: ['-count'],
query: 'event.type:error',
project: [42],
start: '2019-10-01T00:00:00',
end: '2019-10-02T00:00:00',
statsPeriod: '14d',
environment: ['staging'],
yAxis: 'count()',
}),
})
);
});
it('extra query params', function () {
const eventView = new EventView({...baseView, ...state});
pushEventViewToLocation({
location,
nextEventView: eventView,
extraQuery: {
cursor: 'some cursor',
},
});
expect(browserHistory.push).toHaveBeenCalledWith(
expect.objectContaining({
query: expect.objectContaining({
id: '1234',
name: 'best query',
field: ['count()', 'project.id'],
widths: [420],
sort: ['-count'],
query: 'event.type:error',
project: [42],
start: '2019-10-01T00:00:00',
end: '2019-10-02T00:00:00',
statsPeriod: '14d',
environment: ['staging'],
cursor: 'some cursor',
yAxis: 'count()',
}),
})
);
});
});
describe('getExpandedResults()', function () {
const state: EventViewOptions = {
...baseView,
id: '1234',
name: 'best query',
fields: [
{field: 'count()'},
{field: 'last_seen()'},
{field: 'title'},
{field: 'custom_tag'},
],
sorts: [{field: 'count', kind: 'desc'}],
query: 'event.type:error',
project: [42],
start: '2019-10-01T00:00:00',
end: '2019-10-02T00:00:00',
statsPeriod: '14d',
environment: ['staging'],
};
it('id should be default column when drilldown results in no columns', () => {
const view = new EventView({
...baseView,
...state,
fields: [{field: 'count()'}, {field: 'epm()'}, {field: 'eps()'}],
});
const result = getExpandedResults(view, {}, EventFixture());
expect(result.fields).toEqual([{field: 'id', width: -1}]);
});
it('preserves aggregated fields', () => {
let view = new EventView(state);
let result = getExpandedResults(view, {}, EventFixture());
// id should be omitted as it is an implicit property on unaggregated results.
expect(result.fields).toEqual([
{field: 'timestamp', width: -1},
{field: 'title'},
{field: 'custom_tag'},
]);
expect(result.query).toEqual('event.type:error title:ApiException');
// de-duplicate transformed columns
view = new EventView({
...baseView,
...state,
fields: [
{field: 'count()'},
{field: 'last_seen()'},
{field: 'title'},
{field: 'custom_tag'},
{field: 'count(id)'},
],
});
result = getExpandedResults(view, {}, EventFixture());
// id should be omitted as it is an implicit property on unaggregated results.
expect(result.fields).toEqual([
{field: 'timestamp', width: -1},
{field: 'title'},
{field: 'custom_tag'},
]);
// transform aliased fields, & de-duplicate any transforms
view = new EventView({
...baseView,
...state,
fields: [
{field: 'last_seen()'}, // expect this to be transformed to timestamp
{field: 'title'},
{field: 'avg(transaction.duration)'}, // expect this to be dropped
{field: 'p50()'},
{field: 'p75()'},
{field: 'p95()'},
{field: 'p99()'},
{field: 'p100()'},
{field: 'p9001()'}, // it's over 9000
{field: 'foobar()'}, // unknown function with no parameter
{field: 'custom_tag'},
{field: 'transaction.duration'}, // should be dropped
{field: 'title'}, // should be dropped
{field: 'unique_count(id)'},
{field: 'apdex(300)'}, // should be dropped
{field: 'user_misery(300)'}, // should be dropped
{field: 'failure_count()'}, // expect this to be transformed to transaction.status
],
});
result = getExpandedResults(view, {}, EventFixture());
expect(result.fields).toEqual([
{field: 'timestamp', width: -1},
{field: 'title'},
{field: 'transaction.duration', width: -1},
{field: 'custom_tag'},
{field: 'transaction.status', width: -1},
]);
// transforms pXX functions with optional arguments properly
view = new EventView({
...baseView,
...state,
fields: [
{field: 'p50(transaction.duration)'},
{field: 'p75(measurements.foo)'},
{field: 'p95(measurements.bar)'},
{field: 'p99(measurements.fcp)'},
{field: 'p100(measurements.lcp)'},
],
});
result = getExpandedResults(view, {}, EventFixture());
expect(result.fields).toEqual([
{field: 'transaction.duration', width: -1},
{field: 'measurements.foo', width: -1},
{field: 'measurements.bar', width: -1},
{field: 'measurements.fcp', width: -1},
{field: 'measurements.lcp', width: -1},
]);
});
it('applies provided additional conditions', () => {
const view = new EventView({
...baseView,
...state,
fields: [...state.fields, {field: 'measurements.lcp'}, {field: 'measurements.fcp'}],
});
let result = getExpandedResults(view, {extra: 'condition'}, EventFixture());
expect(result.query).toEqual('event.type:error extra:condition title:ApiException');
// handles user tag values.
result = getExpandedResults(view, {user: 'id:12735'}, EventFixture());
expect(result.query).toEqual('event.type:error user:id:12735 title:ApiException');
result = getExpandedResults(view, {user: 'name:uhoh'}, EventFixture());
expect(result.query).toEqual('event.type:error user:name:uhoh title:ApiException');
// quotes value
result = getExpandedResults(view, {extra: 'has space'}, EventFixture());
expect(result.query).toEqual('event.type:error extra:"has space" title:ApiException');
// appends to existing conditions
result = getExpandedResults(view, {'event.type': 'csp'}, EventFixture());
expect(result.query).toEqual('event.type:csp title:ApiException');
// Includes empty strings
result = getExpandedResults(view, {}, EventFixture({id: '0', custom_tag: ''}));
expect(result.query).toEqual('event.type:error title:ApiException custom_tag:""');
// Includes 0
result = getExpandedResults(view, {}, EventFixture({id: '0', custom_tag: 0}));
expect(result.query).toEqual('event.type:error title:ApiException custom_tag:0');
// Includes null
result = getExpandedResults(view, {}, EventFixture({id: '0', custom_tag: null}));
expect(result.query).toEqual('event.type:error title:ApiException custom_tag:""');
// Handles measurements while ignoring null values
result = getExpandedResults(
view,
{},
// The type on this is wrong, the actual type is ReactText which is just string|number
// however we seem to have tests that test for null values as well, hence the expect error
// @ts-expect-error
{'measurements.lcp': 2, 'measurements.fcp': null}
);
expect(result.query).toEqual('event.type:error measurements.lcp:2');
});
it('removes any aggregates in either search conditions or extra conditions', () => {
const view = new EventView({...state, query: 'event.type:error count(id):<10'});
const result = getExpandedResults(view, {'count(id)': '>2'}, EventFixture());
expect(result.query).toEqual('event.type:error title:ApiException');
});
it('applies conditions from dataRow map structure based on fields', () => {
const view = new EventView(state);
const result = getExpandedResults(
view,
{extra: 'condition'},
EventFixture({title: 'Event title'})
);
expect(result.query).toEqual('event.type:error extra:condition title:"Event title"');
});
it('applies tag key conditions from event data', () => {
const view = new EventView(state);
const event = EventFixture({
type: 'error',
tags: [
{key: 'nope', value: 'nope'},
{key: 'custom_tag', value: 'tag_value'},
],
});
const result = getExpandedResults(view, {}, event);
expect(result.query).toEqual(
'event.type:error title:ApiException custom_tag:tag_value'
);
});
it('generate eventview from an empty eventview', () => {
const view = EventView.fromLocation(LocationFixture());
const result = getExpandedResults(view, {some_tag: 'value'}, EventFixture());
expect(result.fields).toEqual([]);
expect(result.query).toEqual('some_tag:value');
});
it('removes equations on aggregates', () => {
const view = new EventView({
...baseView,
...state,
fields: [
{field: 'count()'},
{field: 'equation|count() / 2'},
{field: 'equation|(count() - count()) + 5'},
],
});
const result = getExpandedResults(view, {});
expect(result.fields).toEqual([
{
field: 'id',
width: -1,
},
]);
});
it('keeps equations without aggregates', () => {
const view = new EventView({
...baseView,
...state,
fields: [{field: 'count()'}, {field: 'equation|transaction.duration / 2'}],
});
const result = getExpandedResults(view, {});
expect(result.fields).toEqual([
{
field: 'equation|transaction.duration / 2',
width: -1,
},
]);
});
it('applies array value conditions from event data', () => {
const view = new EventView({
...baseView,
...state,
fields: [...state.fields, {field: 'error.type'}],
});
const event = EventFixture({
type: 'error',
tags: [
{key: 'nope', value: 'nope'},
{key: 'custom_tag', value: 'tag_value'},
],
'error.type': ['DeadSystem Exception', 'RuntimeException', 'RuntimeException'],
});
const result = getExpandedResults(view, {}, event);
expect(result.query).toEqual(
'event.type:error title:ApiException custom_tag:tag_value error.type:"DeadSystem Exception" error.type:RuntimeException error.type:RuntimeException'
);
});
it('applies project condition to project property', () => {
const view = new EventView(state);
const result = getExpandedResults(view, {'project.id': '1'});
expect(result.query.includes('event.type:error')).toBeTruthy();
expect(result.project).toEqual([42, 1]);
});
it('applies environment condition to environment property', () => {
const view = new EventView(state);
const result = getExpandedResults(view, {environment: 'dev'});
expect(result.query).toEqual('event.type:error');
expect(result.environment).toEqual(['staging', 'dev']);
});
it('applies tags that overlap PageFilters state', () => {
const view = new EventView({
...baseView,
...state,
fields: [{field: 'project'}, {field: 'environment'}, {field: 'title'}],
});
const event = EventFixture({
title: 'something bad',
timestamp: '2020-02-13T17:05:46+00:00',
tags: [
{key: 'project', value: '12345'},
{key: 'environment', value: 'earth'},
],
});
const result = getExpandedResults(view, {}, event);
expect(result.query).toEqual(
'event.type:error tags[project]:12345 tags[environment]:earth title:"something bad"'
);
expect(result.project).toEqual([42]);
expect(result.environment).toEqual(['staging']);
});
it('applies the normalized user tag', function () {
const view = new EventView({
...baseView,
...state,
fields: [{field: 'user'}, {field: 'title'}],
});
let event = EventFixture({
title: 'something bad',
// user context should be ignored.
user: {
id: 1234,
username: 'uhoh',
},
tags: [{key: 'user', value: 'id:1234'}],
});
let result = getExpandedResults(view, {}, event);
expect(result.query).toEqual('event.type:error user:id:1234 title:"something bad"');
event = EventFixture({
title: 'something bad',
tags: [{key: 'user', value: '1234'}],
});
result = getExpandedResults(view, {}, event);
expect(result.query).toEqual('event.type:error user:1234 title:"something bad"');
});
it('applies the user field in a table row', function () {
const view = new EventView({
...state,
fields: [{field: 'user'}, {field: 'title'}],
});
const event = EventFixture({
title: 'something bad',
user: 'id:1234',
});
const result = getExpandedResults(view, {}, event);
expect(result.query).toEqual('event.type:error user:id:1234 title:"something bad"');
});
it('normalizes the timestamp field', () => {
const view = new EventView({
...state,
fields: [{field: 'timestamp'}],
sorts: [{field: 'timestamp', kind: 'desc'}],
});
const event = EventFixture({
type: 'error',
timestamp: '2020-02-13T17:05:46+00:00',
});
const result = getExpandedResults(view, {}, event);
expect(result.query).toEqual('event.type:error timestamp:2020-02-13T17:05:46');
});
it('does not duplicate conditions', () => {
const view = new EventView({
...baseView,
...state,
query: 'event.type:error title:bogus',
});
const event = EventFixture({
title: 'bogus',
});
const result = getExpandedResults(view, {trace: 'abc123'}, event);
expect(result.query).toEqual('event.type:error trace:abc123 title:bogus');
});
it('applies project as condition if present', () => {
const view = new EventView({
...baseView,
...state,
query: '',
fields: [{field: 'project'}],
});
const event = EventFixture({project: 'whoosh'});
const result = getExpandedResults(view, {}, event);
expect(result.query).toEqual('project:whoosh');
});
it('applies project name as condition if present', () => {
const view = new EventView({
...baseView,
...state,
query: '',
fields: [{field: 'project.name'}],
});
const event = EventFixture({'project.name': 'whoosh'});
const result = getExpandedResults(view, {}, event);
expect(result.query).toEqual('project.name:whoosh');
});
it('should not trim values that need to be quoted', () => {
const view = new EventView({
...baseView,
...state,
query: '',
fields: [{field: 'title'}],
});
// needs to be quoted because of whitespace in middle
const event = EventFixture({title: 'hello there '});
const result = getExpandedResults(view, {}, event);
expect(result.query).toEqual('title:"hello there "');
});
it('should add environment from the data row', () => {
const view = new EventView({
...baseView,
...state,
environment: [],
query: '',
fields: [{field: 'environment'}],
});
expect(view.environment).toEqual([]);
const event = EventFixture({environment: 'staging'});
const result = getExpandedResults(view, {}, event);
expect(result.environment).toEqual(['staging']);
});
it('should not add duplicate environment', () => {
const view = new EventView({
...baseView,
...state,
query: '',
fields: [{field: 'environment'}],
});
expect(view.environment).toEqual(['staging']);
const event = EventFixture({environment: 'staging'});
const result = getExpandedResults(view, {}, event);
expect(result.environment).toEqual(['staging']);
});
});
describe('downloadAsCsv', function () {
const messageColumn = {key: 'message', name: 'message'};
const environmentColumn = {key: 'environment', name: 'environment'};
const countColumn = {key: 'count', name: 'count'};
const userColumn = {key: 'user', name: 'user'};
const equationColumn = {key: 'equation| count() + count()', name: 'count() + count()'};
it('handles raw data', function () {
const result = {
data: [
{message: 'test 1', environment: 'prod'},
{message: 'test 2', environment: 'test'},
],
};
expect(
downloadAsCsv(result, [messageColumn, environmentColumn], 'filename.csv')
).toContain(encodeURIComponent('message,environment\r\ntest 1,prod\r\ntest 2,test'));
});
it('handles aggregations', function () {
const result = {
data: [{count: 3}],
};
expect(downloadAsCsv(result, [countColumn], 'filename.csv')).toContain(
encodeURI('count\r\n3')
);
});
it('quotes unsafe strings', function () {
const result = {
data: [{message: '=HYPERLINK(http://some-bad-website#)'}],
};
expect(downloadAsCsv(result, [messageColumn], 'filename.csv')).toContain(
encodeURIComponent("message\r\n'=HYPERLINK(http://some-bad-website#)")
);
});
it('handles the user column', function () {
const result = {
data: [
{message: 'test 0', user: 'name:baz'},
{message: 'test 1', user: 'id:123'},
{message: 'test 2', user: 'email:[email protected]'},
{message: 'test 3', user: 'ip:127.0.0.1'},
],
};
expect(downloadAsCsv(result, [messageColumn, userColumn], 'filename.csv')).toContain(
encodeURIComponent(
'message,user\r\ntest 0,name:baz\r\ntest 1,id:123\r\ntest 2,email:[email protected]\r\ntest 3,ip:127.0.0.1'
)
);
});
it('handles equations', function () {
const result = {
data: [{'equation| count() + count()': 3}],
};
expect(downloadAsCsv(result, [equationColumn], 'filename.csv')).toContain(
encodeURIComponent('count() + count()\r\n3')
);
});
});
describe('eventViewToWidgetQuery', function () {
const state: EventViewOptions = {
...baseView,
id: '1234',
name: 'best query',
fields: [{field: 'count()', width: 420}, {field: 'project.id'}],
sorts: [{field: 'count', kind: 'desc'}],
query: 'event.type:error',
project: [42],
start: '2019-10-01T00:00:00',
end: '2019-10-02T00:00:00',
statsPeriod: '14d',
environment: ['staging'],
};
it('updates orderby to function format for top N query', function () {
const view = new EventView({...baseView, ...state});
const widgetQuery = eventViewToWidgetQuery({
eventView: view,
displayType: DisplayType.TOP_N,
yAxis: ['count()'],
});
expect(widgetQuery.orderby).toEqual('-count()');
});
it('updates orderby to function format for complex function', function () {
const view = new EventView({
...baseView,
...state,
fields: [{field: 'count_unique(device.locale)', width: 420}, {field: 'project.id'}],
sorts: [{field: 'count_unique_device_locale', kind: 'desc'}],
});
const widgetQuery = eventViewToWidgetQuery({
eventView: view,
displayType: DisplayType.TABLE,
});
expect(widgetQuery.orderby).toEqual('-count_unique(device.locale)');
});
it('updates orderby to field', function () {
const view = new EventView({
...baseView,
...state,
sorts: [{field: 'project.id', kind: 'desc'}],
});
const widgetQuery = eventViewToWidgetQuery({
eventView: view,
displayType: DisplayType.TABLE,
});
expect(widgetQuery.orderby).toEqual('-project.id');
});
});
describe('generateFieldOptions', function () {
it('generates custom measurement field options', function () {
expect(
generateFieldOptions({
organization: initializeOrg().organization,
customMeasurements: [
{functions: ['p99'], key: 'measurements.custom.measurement'},
],
})['measurement:measurements.custom.measurement']
).toEqual({
label: 'measurements.custom.measurement',
value: {
kind: 'custom_measurement',
meta: {
dataType: 'number',
functions: ['p99'],
name: 'measurements.custom.measurement',
},
},
});
});
});