[Maps] fix vector tile load errors not displayed in legend (#130395)

* [Maps] fix vector tile load errors not displayed in legend

* revert unneeded change

* update API docs

* add error integration test

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* eslint and fix jest test

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* cleanup

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2022-04-18 16:20:55 -06:00 committed by GitHub
parent 6a4eb48119
commit fa89c459ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 258 additions and 34 deletions

View file

@ -34,7 +34,7 @@ kibanaResponseFactory: {
message: string | Error;
attributes?: ResponseErrorAttributes | undefined;
}>;
customError: (options: CustomHttpResponseOptions<ResponseError>) => KibanaResponse<string | Error | {
customError: (options: CustomHttpResponseOptions<ResponseError | Buffer | Stream>) => KibanaResponse<string | Error | Buffer | Stream | {
message: string | Error;
attributes?: ResponseErrorAttributes | undefined;
}>;

View file

@ -186,7 +186,7 @@ const errorResponseFactory = {
* Creates an error response with defined status code and payload.
* @param options - {@link CustomHttpResponseOptions} configures HTTP response headers, error message and other error details to pass to the client
*/
customError: (options: CustomHttpResponseOptions<ResponseError>) => {
customError: (options: CustomHttpResponseOptions<ResponseError | Buffer | Stream>) => {
if (!options || !options.statusCode) {
throw new Error(
`options.statusCode is expected to be set. given options: ${options && options.statusCode}`

View file

@ -1464,7 +1464,7 @@ export const kibanaResponseFactory: {
message: string | Error;
attributes?: ResponseErrorAttributes | undefined;
}>;
customError: (options: CustomHttpResponseOptions<ResponseError>) => KibanaResponse<string | Error | {
customError: (options: CustomHttpResponseOptions<ResponseError | Buffer | Stream>) => KibanaResponse<string | Error | Buffer | Stream | {
message: string | Error;
attributes?: ResponseErrorAttributes | undefined;
}>;

View file

@ -220,7 +220,7 @@ export function syncDataForLayerId(layerId: string | null, isForceRefresh: boole
};
}
function setLayerDataLoadErrorStatus(layerId: string, errorMessage: string | null) {
export function setLayerDataLoadErrorStatus(layerId: string, errorMessage: string | null) {
return {
type: SET_LAYER_ERROR_STATUS,
isInErrorState: errorMessage !== null,

View file

@ -15,6 +15,7 @@ export {
cancelAllInFlightRequests,
fitToLayerExtent,
fitToDataBounds,
setLayerDataLoadErrorStatus,
} from './data_request_actions';
export {
closeOnClickTooltip,

View file

@ -5,7 +5,12 @@
* 2.0.
*/
import { parseTileKey, getTileBoundingBox, expandToTileBoundaries } from './geo_tile_utils';
import {
getTileKey,
parseTileKey,
getTileBoundingBox,
expandToTileBoundaries,
} from './geo_tile_utils';
it('Should parse tile key', () => {
expect(parseTileKey('15/23423/1867')).toEqual({
@ -16,6 +21,10 @@ it('Should parse tile key', () => {
});
});
it('Should get tile key', () => {
expect(getTileKey(45, 120, 10)).toEqual('10/853/368');
});
it('Should convert tile key to geojson Polygon', () => {
const geometry = getTileBoundingBox('15/23423/1867');
expect(geometry).toEqual({

View file

@ -60,6 +60,14 @@ export function parseTileKey(tileKey: string): {
return { x, y, zoom, tileCount };
}
export function getTileKey(lat: number, lon: number, zoom: number): string {
const tileCount = getTileCount(zoom);
const x = longitudeToTile(lon, tileCount);
const y = latitudeToTile(lat, tileCount);
return `${zoom}/${x}/${y}`;
}
function sinh(x: number): number {
return (Math.exp(x) - Math.exp(-x)) / 2;
}

View file

@ -16,6 +16,7 @@ import {
mapExtentChanged,
mapReady,
setAreTilesLoaded,
setLayerDataLoadErrorStatus,
setMapInitError,
setMouseCoordinates,
updateMetaFromTiles,
@ -86,6 +87,12 @@ function mapDispatchToProps(dispatch: ThunkDispatch<MapStoreState, void, AnyActi
updateMetaFromTiles(layerId: string, features: TileMetaFeature[]) {
dispatch(updateMetaFromTiles(layerId, features));
},
clearTileLoadError(layerId: string) {
dispatch(setLayerDataLoadErrorStatus(layerId, null));
},
setTileLoadError(layerId: string, errorMessage: string) {
dispatch(setLayerDataLoadErrorStatus(layerId, errorMessage));
},
};
}

View file

@ -76,6 +76,8 @@ export interface Props {
updateMetaFromTiles: (layerId: string, features: TileMetaFeature[]) => void;
featureModeActive: boolean;
filterModeActive: boolean;
setTileLoadError(layerId: string, errorMessage: string): void;
clearTileLoadError(layerId: string): void;
}
interface State {
@ -205,8 +207,15 @@ export class MbMap extends Component<Props, State> {
this._tileStatusTracker = new TileStatusTracker({
mbMap,
getCurrentLayerList: () => this.props.layerList,
updateTileStatus: (layer: ILayer, areTilesLoaded: boolean) => {
updateTileStatus: (layer: ILayer, areTilesLoaded: boolean, errorMessage?: string) => {
this.props.setAreTilesLoaded(layer.getId(), areTilesLoaded);
if (errorMessage) {
this.props.setTileLoadError(layer.getId(), errorMessage);
} else {
this.props.clearTileLoadError(layer.getId());
}
this._queryForMeta(layer);
},
});

View file

@ -61,6 +61,11 @@ function createMockMbDataEvent(mbSourceId: string, tileKey: string): unknown {
dataType: 'source',
tile: {
tileID: {
canonical: {
x: 80,
y: 10,
z: 5,
},
key: tileKey,
},
},
@ -133,7 +138,7 @@ describe('TileStatusTracker', () => {
},
});
expect(mockMbMap.listeners.length).toBe(3);
expect(mockMbMap.listeners.length).toBe(4);
tileStatusTracker.destroy();
expect(mockMbMap.listeners.length).toBe(0);
});

View file

@ -7,14 +7,21 @@
import type { Map as MapboxMap, MapSourceDataEvent } from '@kbn/mapbox-gl';
import _ from 'lodash';
import { i18n } from '@kbn/i18n';
import { ILayer } from '../../classes/layers/layer';
import { SPATIAL_FILTERS_LAYER_ID } from '../../../common/constants';
import { getTileKey } from '../../classes/util/geo_tile_utils';
interface MbTile {
// references internal object from mapbox
aborted?: boolean;
}
type TileError = Error & {
status: number;
tileZXYKey: string; // format zoom/x/y
};
interface Tile {
mbKey: string;
mbSourceId: string;
@ -23,9 +30,16 @@ interface Tile {
export class TileStatusTracker {
private _tileCache: Tile[];
private _tileErrorCache: Record<string, TileError[]>;
private _prevCenterTileKey?: string;
private readonly _mbMap: MapboxMap;
private readonly _updateTileStatus: (layer: ILayer, areTilesLoaded: boolean) => void;
private readonly _updateTileStatus: (
layer: ILayer,
areTilesLoaded: boolean,
errorMessage?: string
) => void;
private readonly _getCurrentLayerList: () => ILayer[];
private readonly _onSourceDataLoading = (e: MapSourceDataEvent) => {
if (
e.sourceId &&
@ -51,16 +65,29 @@ export class TileStatusTracker {
}
};
private readonly _onError = (e: MapSourceDataEvent) => {
private readonly _onError = (e: MapSourceDataEvent & { error: Error & { status: number } }) => {
if (
e.sourceId &&
e.sourceId !== SPATIAL_FILTERS_LAYER_ID &&
e.tile &&
(e.source.type === 'vector' || e.source.type === 'raster')
) {
const targetLayer = this._getCurrentLayerList().find((layer) => {
return layer.ownsMbSourceId(e.sourceId);
});
const layerId = targetLayer ? targetLayer.getId() : undefined;
if (layerId) {
const layerErrors = this._tileErrorCache[layerId] ? this._tileErrorCache[layerId] : [];
layerErrors.push({
...e.error,
tileZXYKey: `${e.tile.tileID.canonical.z}/${e.tile.tileID.canonical.x}/${e.tile.tileID.canonical.y}`,
} as TileError);
this._tileErrorCache[layerId] = layerErrors;
}
this._removeTileFromCache(e.sourceId, e.tile.tileID.key as unknown as string);
}
};
private readonly _onSourceData = (e: MapSourceDataEvent) => {
if (
e.sourceId &&
@ -73,16 +100,35 @@ export class TileStatusTracker {
}
};
/*
* Clear errors when center tile changes.
* Tracking center tile provides the cleanest way to know when a new data fetching cycle is beginning
*/
private readonly _onMove = () => {
const center = this._mbMap.getCenter();
// Maplibre rounds zoom when 'source.roundZoom' is true and floors zoom when 'source.roundZoom' is false
// 'source.roundZoom' is true for raster and video layers
// 'source.roundZoom' is false for vector layers
// Always floor zoom to keep logic as simple as possible and not have to track center tile by source.
// We are mainly concerned with showing errors from Elasticsearch vector tile requests (which are vector sources)
const centerTileKey = getTileKey(center.lat, center.lng, Math.floor(this._mbMap.getZoom()));
if (this._prevCenterTileKey !== centerTileKey) {
this._prevCenterTileKey = centerTileKey;
this._tileErrorCache = {};
}
};
constructor({
mbMap,
updateTileStatus,
getCurrentLayerList,
}: {
mbMap: MapboxMap;
updateTileStatus: (layer: ILayer, areTilesLoaded: boolean) => void;
updateTileStatus: (layer: ILayer, areTilesLoaded: boolean, errorMessage?: string) => void;
getCurrentLayerList: () => ILayer[];
}) {
this._tileCache = [];
this._tileErrorCache = {};
this._updateTileStatus = updateTileStatus;
this._getCurrentLayerList = getCurrentLayerList;
@ -90,6 +136,7 @@ export class TileStatusTracker {
this._mbMap.on('sourcedataloading', this._onSourceDataLoading);
this._mbMap.on('error', this._onError);
this._mbMap.on('sourcedata', this._onSourceData);
this._mbMap.on('move', this._onMove);
}
_updateTileStatusForAllLayers = _.debounce(() => {
@ -107,7 +154,31 @@ export class TileStatusTracker {
break;
}
}
this._updateTileStatus(layer, !atLeastOnePendingTile);
const tileErrorMessages = this._tileErrorCache[layer.getId()]
? this._tileErrorCache[layer.getId()].map((tileError) => {
return i18n.translate('xpack.maps.tileStatusTracker.tileErrorMsg', {
defaultMessage: `tile '{tileZXYKey}' failed to load: '{status} {message}'`,
values: {
tileZXYKey: tileError.tileZXYKey,
status: tileError.status,
message: tileError.message,
},
});
})
: [];
this._updateTileStatus(
layer,
!atLeastOnePendingTile,
tileErrorMessages.length
? i18n.translate('xpack.maps.tileStatusTracker.layerErrorMsg', {
defaultMessage: `Unable to load {count} tiles: {tileErrors}`,
values: {
count: tileErrorMessages.length,
tileErrors: tileErrorMessages.join(', '),
},
})
: undefined
);
}
}, 100);
@ -126,6 +197,7 @@ export class TileStatusTracker {
this._mbMap.off('error', this._onError);
this._mbMap.off('sourcedata', this._onSourceData);
this._mbMap.off('sourcedataloading', this._onSourceDataLoading);
this._mbMap.off('move', this._onMove);
this._tileCache.length = 0;
}
}

View file

@ -41,7 +41,7 @@ export async function getEsGridTile({
renderAs: RENDER_AS;
gridPrecision: number;
abortController: AbortController;
}): Promise<{ stream: Stream | null; headers?: IncomingHttpHeaders }> {
}): Promise<{ stream: Stream | null; headers: IncomingHttpHeaders; statusCode: number }> {
try {
const path = `/${encodeURIComponent(index)}/_mvt/${geometryFieldName}/${z}/${x}/${y}`;
const body = {
@ -81,13 +81,15 @@ export async function getEsGridTile({
}
);
return { stream: tile.body as Stream, headers: tile.headers };
return { stream: tile.body as Stream, headers: tile.headers, statusCode: tile.statusCode };
} catch (e) {
if (!isAbortError(e)) {
// These are often circuit breaking exceptions
// Should return a tile with some error message
logger.warn(`Cannot generate ES-grid-tile for ${z}/${x}/${y}: ${e.message}`);
if (isAbortError(e)) {
return { stream: null, headers: {}, statusCode: 200 };
}
return { stream: null };
// These are often circuit breaking exceptions
// Should return a tile with some error message
logger.warn(`Cannot generate ES-grid-tile for ${z}/${x}/${y}: ${e.message}`);
return { stream: null, headers: {}, statusCode: 500 };
}
}

View file

@ -37,7 +37,7 @@ export async function getEsTile({
logger: Logger;
requestBody: any;
abortController: AbortController;
}): Promise<{ stream: Stream | null; headers?: IncomingHttpHeaders }> {
}): Promise<{ stream: Stream | null; headers: IncomingHttpHeaders; statusCode: number }> {
try {
const path = `/${encodeURIComponent(index)}/_mvt/${geometryFieldName}/${z}/${x}/${y}`;
@ -81,13 +81,15 @@ export async function getEsTile({
}
);
return { stream: tile.body as Stream, headers: tile.headers };
return { stream: tile.body as Stream, headers: tile.headers, statusCode: tile.statusCode };
} catch (e) {
if (!isAbortError(e)) {
// These are often circuit breaking exceptions
// Should return a tile with some error message
logger.warn(`Cannot generate ES-grid-tile for ${z}/${x}/${y}: ${e.message}`);
if (isAbortError(e)) {
return { stream: null, headers: {}, statusCode: 200 };
}
return { stream: null };
// These are often circuit breaking exceptions
// Should return a tile with some error message
logger.warn(`Cannot generate ES-grid-tile for ${z}/${x}/${y}: ${e.message}`);
return { stream: null, headers: {}, statusCode: 500 };
}
}

View file

@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Readable } from 'stream';
import sinon from 'sinon';
import { KibanaResponseFactory } from '@kbn/core/server';
import { sendResponse } from './mvt_routes';
const mockStream = Readable.from(['{}']);
test('should send error response when status code is above 400', () => {
const responseMock = {
customError: sinon.spy(),
ok: sinon.spy(),
};
sendResponse(responseMock as unknown as KibanaResponseFactory, mockStream, {}, 400);
expect(responseMock.ok.notCalled);
expect(responseMock.customError.calledOnce);
const firstCallArgs = responseMock.customError.getCall(0).args[0];
expect(firstCallArgs.statusCode).toBe(400);
});
test('should forward content-length and content-encoding elasticsearch headers', () => {
const responseMock = {
customError: sinon.spy(),
ok: sinon.spy(),
};
sendResponse(
responseMock as unknown as KibanaResponseFactory,
mockStream,
{ 'content-encoding': 'gzip', 'content-length': '19326' },
200
);
expect(responseMock.ok.calledOnce);
expect(responseMock.customError.notCalled);
const firstCallArgs = responseMock.ok.getCall(0).args[0];
const headers = { ...firstCallArgs.headers };
// remove lastModified from comparision check since its a timestamp that changes every run
expect(headers).toHaveProperty('Last-Modified');
delete headers['Last-Modified'];
expect(headers).toEqual({
'Cache-Control': 'public, max-age=3600',
'Content-Type': 'application/x-protobuf',
'content-disposition': 'inline',
'content-encoding': 'gzip',
'content-length': '19326',
});
});
test('should not set content-encoding when elasticsearch does not provide value', () => {
const responseMock = {
customError: sinon.spy(),
ok: sinon.spy(),
};
sendResponse(
responseMock as unknown as KibanaResponseFactory,
mockStream,
{ 'content-length': '19326' },
200
);
expect(responseMock.ok.calledOnce);
expect(responseMock.customError.notCalled);
const firstCallArgs = responseMock.ok.getCall(0).args[0];
const headers = { ...firstCallArgs.headers };
// remove lastModified from comparision check since its a timestamp that changes every run
expect(headers).toHaveProperty('Last-Modified');
delete headers['Last-Modified'];
expect(headers).toEqual({
'Cache-Control': 'public, max-age=3600',
'Content-Type': 'application/x-protobuf',
'content-disposition': 'inline',
'content-length': '19326',
});
});

View file

@ -58,7 +58,7 @@ export function initMVTRoutes({
const abortController = makeAbortController(request);
const { stream, headers } = await getEsTile({
const { stream, headers, statusCode } = await getEsTile({
url: `${API_ROOT_PATH}/${MVT_GETTILE_API_PATH}/{z}/{x}/{y}.pbf`,
core,
logger,
@ -72,7 +72,7 @@ export function initMVTRoutes({
abortController,
});
return sendResponse(response, stream, headers);
return sendResponse(response, stream, headers, statusCode);
}
);
@ -104,7 +104,7 @@ export function initMVTRoutes({
const abortController = makeAbortController(request);
const { stream, headers } = await getEsGridTile({
const { stream, headers, statusCode } = await getEsGridTile({
url: `${API_ROOT_PATH}/${MVT_GETGRIDTILE_API_PATH}/{z}/{x}/{y}.pbf`,
core,
logger,
@ -120,23 +120,31 @@ export function initMVTRoutes({
abortController,
});
return sendResponse(response, stream, headers);
return sendResponse(response, stream, headers, statusCode);
}
);
}
function sendResponse(
export function sendResponse(
response: KibanaResponseFactory,
gzipTileStream: Stream | null,
headers?: IncomingHttpHeaders
tileStream: Stream | null,
headers: IncomingHttpHeaders,
statusCode: number
) {
if (statusCode >= 400) {
return response.customError({
statusCode,
body: tileStream ? tileStream : statusCode.toString(),
});
}
const cacheControl = `public, max-age=${CACHE_TIMEOUT_SECONDS}`;
const lastModified = `${new Date().toUTCString()}`;
if (gzipTileStream && headers) {
if (tileStream) {
// use the content-encoding and content-length headers from elasticsearch if they exist
const { 'content-length': contentLength, 'content-encoding': contentEncoding } = headers;
return response.ok({
body: gzipTileStream,
body: tileStream,
headers: {
'content-disposition': 'inline',
...(contentLength && { 'content-length': contentLength }),

View file

@ -192,5 +192,13 @@ export default function ({ getService }) {
],
]);
});
it('should return error when index does not exist', async () => {
await supertest
.get(URL.replace('index=logstash-*', 'index=notRealIndex') + '&renderAs=point')
.set('kbn-xsrf', 'kibana')
.responseType('blob')
.expect(404);
});
});
}

View file

@ -84,5 +84,18 @@ export default function ({ getService }) {
],
]);
});
it('should return error when index does not exist', async () => {
await supertest
.get(
`/api/maps/mvt/getTile/2/1/1.pbf\
?geometryFieldName=geo.coordinates\
&index=notRealIndex\
&requestBody=(_source:!f,docvalue_fields:!(bytes,geo.coordinates,machine.os.raw,(field:'@timestamp',format:epoch_millis)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(bytes,geo.coordinates,machine.os.raw,'@timestamp'))`
)
.set('kbn-xsrf', 'kibana')
.responseType('blob')
.expect(404);
});
});
}