Np migration tsvb route validation (#51850)

This commit is contained in:
Christiane (Tina) Heiligers 2020-01-14 01:32:56 -07:00 committed by Joe Reuter
parent 9a871d2a7a
commit 9380b6408b
13 changed files with 447 additions and 13 deletions

View file

@ -379,6 +379,7 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut
this.expression = await buildPipeline(this.vis, {
searchSource: this.savedVisualization.searchSource,
timeRange: this.timeRange,
savedObjectId: this.savedVisualization.id,
});
this.vis.filters = { timeRange: this.timeRange };

View file

@ -32,6 +32,20 @@ const metricsPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPlu
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
hacks: [resolve(__dirname, 'public/legacy')],
injectDefaultVars: server => ({}),
mappings: {
'tsvb-validation-telemetry': {
properties: {
failedRequests: {
type: 'long',
},
},
},
},
savedObjectSchemas: {
'tsvb-validation-telemetry': {
isNamespaceAgnostic: true,
},
},
},
init: (server: Legacy.Server) => {
const visTypeTimeSeriesPlugin = server.newPlatform.setup.plugins

View file

@ -31,6 +31,7 @@ type Context = KibanaContext | null;
interface Arguments {
params: string;
uiState: string;
savedObjectId: string | null;
}
type VisParams = Required<Arguments>;
@ -64,10 +65,16 @@ export const createMetricsFn = (): ExpressionFunction<typeof name, Context, Argu
default: '"{}"',
help: '',
},
savedObjectId: {
types: ['null', 'string'],
default: null,
help: '',
},
},
async fn(context: Context, args: Arguments) {
const params = JSON.parse(args.params);
const uiStateParams = JSON.parse(args.uiState);
const savedObjectId = args.savedObjectId;
const uiState = new PersistedState(uiStateParams);
const response = await metricsRequestHandler({
@ -76,6 +83,7 @@ export const createMetricsFn = (): ExpressionFunction<typeof name, Context, Argu
filters: get(context, 'filters', null),
visParams: params,
uiState,
savedObjectId,
});
response.visType = 'metrics';

View file

@ -23,7 +23,14 @@ import { timefilter } from 'ui/timefilter';
import { kfetch } from 'ui/kfetch';
import { getUISettings } from './services';
export const metricsRequestHandler = async ({ uiState, timeRange, filters, query, visParams }) => {
export const metricsRequestHandler = async ({
uiState,
timeRange,
filters,
query,
visParams,
savedObjectId,
}) => {
const config = getUISettings();
const timezone = timezoneProvider(config)();
const uiStateObj = uiState.get(visParams.type, {});
@ -49,6 +56,7 @@ export const metricsRequestHandler = async ({ uiState, timeRange, filters, query
filters,
panels: [visParams],
state: uiStateObj,
savedObjectId: savedObjectId || 'unsaved',
}),
});

View file

@ -26,12 +26,17 @@ import { SearchStrategiesRegister } from './lib/search_strategies/search_strateg
// @ts-ignore
import { getVisData } from './lib/get_vis_data';
import { Framework } from '../../../../plugins/vis_type_timeseries/server';
import { ValidationTelemetryServiceSetup } from '../../../../plugins/vis_type_timeseries/server';
export const init = async (framework: Framework, __LEGACY: any) => {
export const init = async (
framework: Framework,
__LEGACY: any,
validationTelemetry: ValidationTelemetryServiceSetup
) => {
const { core } = framework;
const router = core.http.createRouter();
visDataRoutes(router, framework);
visDataRoutes(router, framework, validationTelemetry);
// [LEGACY_TODO]
fieldsRoutes(__LEGACY.server);

View file

@ -0,0 +1,247 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Joi from 'joi';
const stringOptionalNullable = Joi.string()
.allow('', null)
.optional();
const stringRequired = Joi.string()
.allow('')
.required();
const arrayNullable = Joi.array().allow(null);
const numberIntegerOptional = Joi.number()
.integer()
.optional();
const numberIntegerRequired = Joi.number()
.integer()
.required();
const numberOptional = Joi.number().optional();
const numberRequired = Joi.number().required();
const queryObject = Joi.object({
language: Joi.string().allow(''),
query: Joi.string().allow(''),
});
const annotationsItems = Joi.object({
color: stringOptionalNullable,
fields: stringOptionalNullable,
hidden: Joi.boolean().optional(),
icon: stringOptionalNullable,
id: stringOptionalNullable,
ignore_global_filters: numberIntegerOptional,
ignore_panel_filters: numberIntegerOptional,
index_pattern: stringOptionalNullable,
query_string: queryObject.optional(),
template: stringOptionalNullable,
time_field: stringOptionalNullable,
});
const backgroundColorRulesItems = Joi.object({
value: Joi.number()
.allow(null)
.optional(),
id: stringOptionalNullable,
background_color: stringOptionalNullable,
color: stringOptionalNullable,
});
const gaugeColorRulesItems = Joi.object({
gauge: stringOptionalNullable,
id: stringOptionalNullable,
operator: stringOptionalNullable,
value: Joi.number(),
});
const metricsItems = Joi.object({
field: stringOptionalNullable,
id: stringRequired,
metric_agg: stringOptionalNullable,
numerator: stringOptionalNullable,
denominator: stringOptionalNullable,
sigma: stringOptionalNullable,
function: stringOptionalNullable,
script: stringOptionalNullable,
variables: Joi.array()
.items(
Joi.object({
field: stringOptionalNullable,
id: stringRequired,
name: stringOptionalNullable,
})
)
.optional(),
type: stringRequired,
value: stringOptionalNullable,
values: Joi.array()
.items(Joi.string().allow('', null))
.allow(null)
.optional(),
});
const splitFiltersItems = Joi.object({
id: stringOptionalNullable,
color: stringOptionalNullable,
filter: Joi.object({
language: Joi.string().allow(''),
query: Joi.string().allow(''),
}).optional(),
label: stringOptionalNullable,
});
const seriesItems = Joi.object({
aggregate_by: stringOptionalNullable,
aggregate_function: stringOptionalNullable,
axis_position: stringRequired,
axis_max: stringOptionalNullable,
axis_min: stringOptionalNullable,
chart_type: stringRequired,
color: stringRequired,
color_rules: Joi.array()
.items(
Joi.object({
value: numberOptional,
id: stringRequired,
text: stringOptionalNullable,
operator: stringOptionalNullable,
})
)
.optional(),
fill: numberOptional,
filter: Joi.object({
query: stringRequired,
language: stringOptionalNullable,
}).optional(),
formatter: stringRequired,
hide_in_legend: numberIntegerOptional,
hidden: Joi.boolean().optional(),
id: stringRequired,
label: stringOptionalNullable,
line_width: numberOptional,
metrics: Joi.array().items(metricsItems),
offset_time: stringOptionalNullable,
override_index_pattern: numberOptional,
point_size: numberRequired,
separate_axis: numberIntegerOptional,
seperate_axis: numberIntegerOptional,
series_index_pattern: stringOptionalNullable,
series_time_field: stringOptionalNullable,
series_interval: stringOptionalNullable,
series_drop_last_bucket: numberIntegerOptional,
split_color_mode: stringOptionalNullable,
split_filters: Joi.array()
.items(splitFiltersItems)
.optional(),
split_mode: stringRequired,
stacked: stringRequired,
steps: numberIntegerOptional,
terms_field: stringOptionalNullable,
terms_order_by: stringOptionalNullable,
terms_size: stringOptionalNullable,
terms_direction: stringOptionalNullable,
terms_include: stringOptionalNullable,
terms_exclude: stringOptionalNullable,
time_range_mode: stringOptionalNullable,
trend_arrows: numberOptional,
type: stringOptionalNullable,
value_template: stringOptionalNullable,
var_name: stringOptionalNullable,
});
export const visPayloadSchema = Joi.object({
filters: arrayNullable,
panels: Joi.array().items(
Joi.object({
annotations: Joi.array()
.items(annotationsItems)
.optional(),
axis_formatter: stringRequired,
axis_position: stringRequired,
axis_scale: stringRequired,
axis_min: stringOptionalNullable,
axis_max: stringOptionalNullable,
bar_color_rules: arrayNullable.optional(),
background_color: stringOptionalNullable,
background_color_rules: Joi.array()
.items(backgroundColorRulesItems)
.optional(),
default_index_pattern: stringOptionalNullable,
default_timefield: stringOptionalNullable,
drilldown_url: stringOptionalNullable,
drop_last_bucket: numberIntegerOptional,
filter: Joi.alternatives(
stringOptionalNullable,
Joi.object({
language: stringOptionalNullable,
query: stringOptionalNullable,
})
),
gauge_color_rules: Joi.array()
.items(gaugeColorRulesItems)
.optional(),
gauge_width: [stringOptionalNullable, numberOptional],
gauge_inner_color: stringOptionalNullable,
gauge_inner_width: Joi.alternatives(stringOptionalNullable, numberIntegerOptional),
gauge_style: stringOptionalNullable,
gauge_max: stringOptionalNullable,
id: stringRequired,
ignore_global_filters: numberOptional,
ignore_global_filter: numberOptional,
index_pattern: stringRequired,
interval: stringRequired,
isModelInvalid: Joi.boolean().optional(),
legend_position: stringOptionalNullable,
markdown: stringOptionalNullable,
markdown_scrollbars: numberIntegerOptional,
markdown_openLinksInNewTab: numberIntegerOptional,
markdown_vertical_align: stringOptionalNullable,
markdown_less: stringOptionalNullable,
markdown_css: stringOptionalNullable,
pivot_id: stringOptionalNullable,
pivot_label: stringOptionalNullable,
pivot_type: stringOptionalNullable,
pivot_rows: stringOptionalNullable,
series: Joi.array()
.items(seriesItems)
.required(),
show_grid: numberIntegerRequired,
show_legend: numberIntegerRequired,
time_field: stringOptionalNullable,
time_range_mode: stringOptionalNullable,
type: stringRequired,
})
),
// general
query: Joi.array()
.items(queryObject)
.allow(null)
.required(),
state: Joi.object({
sort: Joi.object({
column: stringRequired,
order: Joi.string()
.valid(['asc', 'desc'])
.required(),
}).optional(),
}).required(),
savedObjectId: Joi.string().optional(),
timerange: Joi.object({
timezone: stringRequired,
min: stringRequired,
max: stringRequired,
}).required(),
});

View file

@ -17,12 +17,22 @@
* under the License.
*/
import { IRouter } from 'kibana/server';
import { schema } from '@kbn/config-schema';
import { getVisData } from '../lib/get_vis_data';
import { visPayloadSchema } from './post_vis_schema';
import {
Framework,
ValidationTelemetryServiceSetup,
} from '../../../../../plugins/vis_type_timeseries/server';
const escapeHatch = schema.object({}, { allowUnknowns: true });
export const visDataRoutes = (router, framework) => {
export const visDataRoutes = (
router: IRouter,
framework: Framework,
{ logFailedValidation }: ValidationTelemetryServiceSetup
) => {
router.post(
{
path: '/api/metrics/vis/data',
@ -31,6 +41,16 @@ export const visDataRoutes = (router, framework) => {
},
},
async (requestContext, request, response) => {
const { error: validationError } = visPayloadSchema.validate(request.body);
if (validationError) {
logFailedValidation();
const savedObjectId =
(typeof request.body === 'object' && (request.body as any).savedObjectId) ||
'unavailable';
framework.logger.warn(
`Request validation error: ${validationError.message} (saved object id: ${savedObjectId}). This most likely means your TSVB visualization contains outdated configuration. You can report this problem under https://github.com/elastic/kibana/issues/new?template=Bug_report.md`
);
}
try {
const results = await getVisData(requestContext, request.body, framework);
return response.ok({ body: results });

View file

@ -59,7 +59,12 @@ export interface Schemas {
[key: string]: any[] | undefined;
}
type buildVisFunction = (visState: VisState, schemas: Schemas, uiState: any) => string;
type buildVisFunction = (
visState: VisState,
schemas: Schemas,
uiState: any,
meta?: { savedObjectId?: string }
) => string;
type buildVisConfigFunction = (schemas: Schemas, visParams?: VisParams) => VisParams;
interface BuildPipelineVisFunction {
@ -248,11 +253,13 @@ export const buildPipelineVisFunction: BuildPipelineVisFunction = {
input_control_vis: visState => {
return `input_control_vis ${prepareJson('visConfig', visState.params)}`;
},
metrics: (visState, schemas, uiState = {}) => {
metrics: (visState, schemas, uiState = {}, meta) => {
const paramsJson = prepareJson('params', visState.params);
const uiStateJson = prepareJson('uiState', uiState);
const savedObjectIdParam = prepareString('savedObjectId', meta?.savedObjectId);
return `tsvb ${paramsJson} ${uiStateJson}`;
const params = [paramsJson, uiStateJson, savedObjectIdParam].filter(param => Boolean(param));
return `tsvb ${params.join(' ')}`;
},
timelion: visState => {
const expression = prepareString('expression', visState.params.expression);
@ -488,6 +495,7 @@ export const buildPipeline = async (
params: {
searchSource: ISearchSource;
timeRange?: any;
savedObjectId?: string;
}
) => {
const { searchSource } = params;
@ -521,7 +529,9 @@ export const buildPipeline = async (
const schemas = getSchemas(vis, params.timeRange);
if (buildPipelineVisFunction[vis.type.name]) {
pipeline += buildPipelineVisFunction[vis.type.name](visState, schemas, uiState);
pipeline += buildPipelineVisFunction[vis.type.name](visState, schemas, uiState, {
savedObjectId: params.savedObjectId,
});
} else if (vislibCharts.includes(vis.type.name)) {
const visConfig = visState.params;
visConfig.dimensions = await buildVislibDimensions(vis, params);

View file

@ -2,5 +2,6 @@
"id": "metrics",
"version": "8.0.0",
"kibanaVersion": "kibana",
"server": true
}
"server": true,
"optionalPlugins": ["usageCollection"]
}

View file

@ -30,6 +30,8 @@ export const config = {
export type VisTypeTimeseriesConfig = TypeOf<typeof config.schema>;
export { ValidationTelemetryServiceSetup } from './validation_telemetry';
export function plugin(initializerContext: PluginInitializerContext) {
return new VisTypeTimeseriesPlugin(initializerContext);
}

View file

@ -35,11 +35,17 @@ import {
GetVisData,
GetVisDataOptions,
} from '../../../legacy/core_plugins/vis_type_timeseries/server';
import { ValidationTelemetryService } from './validation_telemetry/validation_telemetry_service';
import { UsageCollectionSetup } from '../../usage_collection/server';
export interface LegacySetup {
server: Server;
}
interface VisTypeTimeseriesPluginSetupDependencies {
usageCollection?: UsageCollectionSetup;
}
export interface VisTypeTimeseriesSetup {
/** @deprecated */
__legacy: {
@ -61,11 +67,14 @@ export interface Framework {
}
export class VisTypeTimeseriesPlugin implements Plugin<VisTypeTimeseriesSetup> {
private validationTelementryService: ValidationTelemetryService;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.initializerContext = initializerContext;
this.validationTelementryService = new ValidationTelemetryService();
}
public setup(core: CoreSetup, plugins: any) {
public setup(core: CoreSetup, plugins: VisTypeTimeseriesPluginSetupDependencies) {
const logger = this.initializerContext.logger.get('visTypeTimeseries');
const config$ = this.initializerContext.config.create<VisTypeTimeseriesConfig>();
// Global config contains things like the ES shard timeout
@ -82,8 +91,13 @@ export class VisTypeTimeseriesPlugin implements Plugin<VisTypeTimeseriesSetup> {
return {
__legacy: {
config$,
registerLegacyAPI: once((__LEGACY: LegacySetup) => {
init(framework, __LEGACY);
registerLegacyAPI: once(async (__LEGACY: LegacySetup) => {
const validationTelemetrySetup = await this.validationTelementryService.setup(core, {
...plugins,
globalConfig$,
});
await init(framework, __LEGACY, validationTelemetrySetup);
}),
},
getVisData: async (requestContext: RequestHandlerContext, options: GetVisDataOptions) => {

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export * from './validation_telemetry_service';

View file

@ -0,0 +1,84 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { APICaller, CoreSetup, Plugin, PluginInitializerContext } from 'kibana/server';
import { UsageCollectionSetup } from '../../../usage_collection/server';
export interface ValidationTelemetryServiceSetup {
logFailedValidation: () => void;
}
export class ValidationTelemetryService implements Plugin<ValidationTelemetryServiceSetup> {
private kibanaIndex: string = '';
async setup(
core: CoreSetup,
{
usageCollection,
globalConfig$,
}: {
usageCollection?: UsageCollectionSetup;
globalConfig$: PluginInitializerContext['config']['legacy']['globalConfig$'];
}
) {
globalConfig$.subscribe(config => {
this.kibanaIndex = config.kibana.index;
});
if (usageCollection) {
usageCollection.registerCollector(
usageCollection.makeUsageCollector({
type: 'tsvb-validation',
isReady: () => this.kibanaIndex !== '',
fetch: async (callCluster: APICaller) => {
try {
const response = await callCluster('get', {
index: this.kibanaIndex,
id: 'tsvb-validation-telemetry:tsvb-validation-telemetry',
ignore: [404],
});
return {
failed_validations:
response?._source?.['tsvb-validation-telemetry']?.failedRequests || 0,
};
} catch (err) {
return {
failed_validations: 0,
};
}
},
})
);
}
const internalRepository = core.savedObjects.createInternalRepository();
return {
logFailedValidation: async () => {
try {
await internalRepository.incrementCounter(
'tsvb-validation-telemetry',
'tsvb-validation-telemetry',
'failedRequests'
);
} catch (e) {
// swallow error, validation telemetry shouldn't fail anything else
}
},
};
}
start() {}
}