diff --git a/src/style/style.js b/src/style/style.js index 68cc0d5419a..cd79b910804 100644 --- a/src/style/style.js +++ b/src/style/style.js @@ -1,6 +1,7 @@ // @flow import assert from 'assert'; +import murmur3 from 'murmurhash-js'; import {Event, ErrorEvent, Evented} from '../util/evented.js'; import StyleLayer from './style_layer.js'; @@ -15,6 +16,7 @@ import Fog from './fog.js'; import {pick, clone, extend, deepEqual, filterObject, cartesianPositionToSpherical, warnOnce} from '../util/util.js'; import {getJSON, getReferrer, makeRequest, ResourceType} from '../util/ajax.js'; import {isMapboxURL} from '../util/mapbox_url.js'; +import {stripQueryParameters} from '../util/url.js'; import browser from '../util/browser.js'; import Dispatcher from '../util/dispatcher.js'; import Lights from '../../3d-style/style/lights.js'; @@ -198,6 +200,9 @@ class Style extends Evented { transition: TransitionSpecification; projection: ProjectionSpecification; + // Serializable identifier of style, which we use for telemetry + globalId: string | null; + scope: string; fragments: Array; importDepth: number; @@ -261,6 +266,8 @@ class Style extends Evented { // Empty string indicates the root Style scope. this.scope = options.scope || ''; + this.globalId = null; + this.fragments = []; this.importDepth = options.importDepth || 0; this.importsCache = options.importsCache || new Map(); @@ -375,6 +382,72 @@ class Style extends Evented { }); } + load(style: StyleSpecification | string | null): Style { + if (!style) { + return this; + } + + if (typeof style === 'string') { + this.loadURL(style); + } else { + this.loadJSON(style); + } + + return this; + } + + _getGlobalId(loadedStyle?: StyleSpecification | string | null): string | null { + if (!loadedStyle) { + return null; + } + + if (typeof loadedStyle === 'string') { + if (isMapboxURL(loadedStyle)) { + return loadedStyle; + } + + const url = stripQueryParameters(loadedStyle); + + if (!url.startsWith('http')) { + try { + return new URL(url, location.href).toString(); + } catch (_e) { + return url; + } + } + + return url; + } + + return `json://${murmur3(JSON.stringify(loadedStyle))}`; + } + + _diffStyle(style: StyleSpecification | string, onStarted: (err: Error | null, isUpdateNeeded: boolean) => void, onFinished?: () => void) { + this.globalId = this._getGlobalId(style); + + const handleStyle = (json: StyleSpecification, callback: (err: Error | null, isUpdateNeeded: boolean) => void) => { + try { + callback(null, this.setState(json, onFinished)); + } catch (e) { + callback(e, false); + } + }; + + if (typeof style === 'string') { + const url = this.map._requestManager.normalizeStyleURL(style); + const request = this.map._requestManager.transformRequest(url, ResourceType.Style); + getJSON(request, (error: ?Error, json: ?Object) => { + if (error) { + this.fire(new ErrorEvent(error)); + } else if (json) { + handleStyle(json, onStarted); + } + }); + } else if (typeof style === 'object') { + handleStyle(style, onStarted); + } + } + loadURL(url: string, options: { validate?: boolean, accessToken?: string @@ -384,6 +457,7 @@ class Style extends Evented { const validate = typeof options.validate === 'boolean' ? options.validate : !isMapboxURL(url); + this.globalId = this._getGlobalId(url); url = this.map._requestManager.normalizeStyleURL(url, options.accessToken); this.resolvedImports.add(url); @@ -404,6 +478,8 @@ class Style extends Evented { loadJSON(json: StyleSpecification, options: StyleSetterOptions = {}): void { this.fire(new Event('dataloading', {dataType: 'style'})); + + this.globalId = this._getGlobalId(json); this._request = browser.frame(() => { this._request = null; this._load(json, options.validate !== false); @@ -445,6 +521,13 @@ class Style extends Evented { const json = importSpec.data || this.importsCache.get(importSpec.url); if (json) { style.loadJSON(json, {validate}); + + // Don't expose global ID for internal style to ensure + // that we don't send in telemetry Standard style as import + // because we already use it directly + if (this._isInternalStyle(json)) { + style.globalId = null; + } } else if (importSpec.url) { style.loadURL(importSpec.url, {validate}); } else { @@ -476,6 +559,17 @@ class Style extends Evented { return Promise.allSettled(waitForStyles); } + getImportGlobalIds(style: Style = this, ids: Set = new Set()): string[] { + for (const fragment of style.fragments) { + if (fragment.style.globalId) { + ids.add(fragment.style.globalId); + } + this.getImportGlobalIds(fragment.style, ids); + } + + return [...ids.values()]; + } + _createFragmentStyle(importSpec: ImportSpecification): Style { const scope = this.scope ? makeFQID(importSpec.id, this.scope) : importSpec.id; @@ -514,9 +608,11 @@ class Style extends Evented { options: this.options }); - const isRootStyle = this.isRootStyle(); - this._shouldPrecompile = isRootStyle; - this.fire(new Event(isRootStyle ? 'style.load' : 'style.import.load')); + this._shouldPrecompile = this.isRootStyle(); + } + + _isInternalStyle(json: StyleSpecification): boolean { + return this.isRootStyle() && (json.fragment || (!!json.schema && json.fragment !== false)); } _load(json: StyleSpecification, validate: boolean) { @@ -524,7 +620,7 @@ class Style extends Evented { // This style was loaded as a root style, but it is marked as a fragment and/or has a schema. We instead load // it as an import with the well-known ID "basemap" to make sure that we don't expose the internals. - if (this.isRootStyle() && (json.fragment || (schema && json.fragment !== false))) { + if (this._isInternalStyle(json)) { const basemap = {id: 'basemap', data: json, url: ''}; const style = extend({}, empty, {imports: [basemap]}); this._load(style, validate); @@ -618,10 +714,16 @@ class Style extends Evented { this.fire(new Event('data', {dataType: 'style'})); + const isRootStyle = this.isRootStyle(); + if (json.imports) { - this._loadImports(json.imports, validate).then(() => this._reloadImports()); + this._loadImports(json.imports, validate).then(() => { + this._reloadImports(); + this.fire(new Event(isRootStyle ? 'style.load' : 'style.import.load')); + }); } else { this._reloadImports(); + this.fire(new Event(isRootStyle ? 'style.load' : 'style.import.load')); } } @@ -1266,7 +1368,7 @@ class Style extends Evented { * @returns {boolean} true if any changes were made; false otherwise * @private */ - setState(nextState: StyleSpecification): boolean { + setState(nextState: StyleSpecification, onFinish?: () => void): boolean { this._checkLoaded(); if (emitValidationErrors(this, validateStyle(nextState))) return false; @@ -1286,10 +1388,16 @@ class Style extends Evented { throw new Error(`Unimplemented: ${unimplementedOps.map(op => op.command).join(', ')}.`); } + const changesPromises = []; + changes.forEach((op) => { - (this: any)[op.command].apply(this, op.args); + changesPromises.push((this: any)[op.command].apply(this, op.args)); }); + if (onFinish) { + Promise.all(changesPromises).then(onFinish); + } + this.stylesheet = nextState; this.mergeAll(); @@ -2822,26 +2930,26 @@ class Style extends Evented { // Fragments and merging - addImport(importSpec: ImportSpecification, beforeId: ?string): Style { + addImport(importSpec: ImportSpecification, beforeId: ?string): Promise | void { this._checkLoaded(); const imports = this.stylesheet.imports = this.stylesheet.imports || []; const index = imports.findIndex(({id}) => id === importSpec.id); if (index !== -1) { - return this.fire(new ErrorEvent(new Error(`Import with id '${importSpec.id}' already exists in the map's style.`))); + this.fire(new ErrorEvent(new Error(`Import with id '${importSpec.id}' already exists in the map's style.`))); + return; } if (!beforeId) { imports.push(importSpec); - this._loadImports([importSpec], true); - return this; + return this._loadImports([importSpec], true); } const beforeIndex = imports.findIndex(({id}) => id === beforeId); if (beforeIndex === -1) { - return this.fire(new ErrorEvent(new Error(`Import with id "${beforeId}" does not exist on this map.`))); + this.fire(new ErrorEvent(new Error(`Import with id "${beforeId}" does not exist on this map.`))); } this.stylesheet.imports = imports @@ -2849,8 +2957,7 @@ class Style extends Evented { .concat(importSpec) .concat(imports.slice(beforeIndex)); - this._loadImports([importSpec], true, beforeId); - return this; + return this._loadImports([importSpec], true, beforeId); } updateImport(importId: string, importSpecification: ImportSpecification | string): Style { @@ -2977,12 +3084,12 @@ class Style extends Evented { return this; } - removeImport(importId: string): Style { + removeImport(importId: string): void { this._checkLoaded(); const imports = this.stylesheet.imports || []; const index = this.getImportIndex(importId); - if (index === -1) return this; + if (index === -1) return; imports.splice(index, 1); @@ -2992,7 +3099,6 @@ class Style extends Evented { this.fragments.splice(index, 1); this._reloadImports(); - return this; } getImportIndex(importId: string): number { diff --git a/src/ui/map.js b/src/ui/map.js index e37a0009e15..8f623fb5e65 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -4,7 +4,7 @@ import {version} from '../../package.json'; import {asyncAll, extend, bindAll, warnOnce, uniqueId, isSafariWithAntialiasingBug} from '../util/util.js'; import browser from '../util/browser.js'; import * as DOM from '../util/dom.js'; -import {getImage, getJSON, ResourceType} from '../util/ajax.js'; +import {getImage, ResourceType} from '../util/ajax.js'; import { RequestManager, mapSessionAPI, @@ -12,6 +12,7 @@ import { getMapSessionAPI, postPerformanceEvent, postMapLoadEvent, + postStyleLoadEvent, AUTH_ERR_MSG, storeAuthState, removeAuthState @@ -697,6 +698,7 @@ export class Map extends Camera { if (this.transform.unmodified) { this.jumpTo((this.style.stylesheet: any)); } + this._postStyleLoadEvent(); }); this.on('data', (event: MapDataEvent) => { this._update(event.dataType === 'style'); @@ -1983,7 +1985,21 @@ export class Map extends Camera { if ((options.diff !== false && options.localIdeographFontFamily === this._localIdeographFontFamily && options.localFontFamily === this._localFontFamily) && this.style && style) { - this._diffStyle(style, options); + this.style._diffStyle( + style, + (e: any, isUpdateNeeded) => { + if (e) { + warnOnce( + `Unable to perform style diff: ${e.message || e.error || e}. Rebuilding the style from scratch.` + ); + this._updateStyle(style, options); + } else if (isUpdateNeeded) { + this._update(true); + } + }, + () => { + this._postStyleLoadEvent(); + }); return this; } else { this._localIdeographFontFamily = options.localIdeographFontFamily; @@ -2009,15 +2025,11 @@ export class Map extends Camera { } if (style) { - this.style = new Style(this, options || {}); - this.style.setEventedParent(this, {style: this.style}); - - if (typeof style === 'string') { - this.style.loadURL(style); - } else { - this.style.loadJSON(style); - } + this.style = new Style(this, options) + .setEventedParent(this, {style: this.style}) + .load(style); } + this._updateTerrain(); return this; } @@ -2030,35 +2042,6 @@ export class Map extends Camera { } } - _diffStyle(style: StyleSpecification | string, options?: {diff?: boolean} & StyleOptions) { - if (typeof style === 'string') { - const url = this._requestManager.normalizeStyleURL(style); - const request = this._requestManager.transformRequest(url, ResourceType.Style); - getJSON(request, (error: ?Error, json: ?Object) => { - if (error) { - this.fire(new ErrorEvent(error)); - } else if (json) { - this._updateDiff(json, options); - } - }); - } else if (typeof style === 'object') { - this._updateDiff(style, options); - } - } - - _updateDiff(style: StyleSpecification, options?: {diff?: boolean} & StyleOptions) { - try { - if (this.style.setState(style)) { - this._update(true); - } - } catch (e) { - warnOnce( - `Unable to perform style diff: ${e.message || e.error || e}. Rebuilding the style from scratch.` - ); - this._updateStyle(style, options); - } - } - /** * Returns the map's Mapbox [style](https://docs.mapbox.com/help/glossary/style/) object, a JSON object which can be used to recreate the map's style. * @@ -4054,6 +4037,19 @@ export class Map extends Camera { /***** END WARNING - REMOVAL OR MODIFICATION OF THE PRECEDING CODE VIOLATES THE MAPBOX TERMS OF SERVICE ******/ + _postStyleLoadEvent() { + if (!this.style.globalId) { + return; + } + + postStyleLoadEvent(this._requestManager._customAccessToken, { + map: this, + skuToken: this._requestManager._skuToken, + style: this.style.globalId, + importedStyles: this.style.getImportGlobalIds() + }); + } + _updateTerrain() { // Recalculate if enabled/disabled and calculate elevation cover. As camera is using elevation tiles before // render (and deferred update after zoom recalculation), this needs to be called when removing terrain source. diff --git a/src/util/mapbox.js b/src/util/mapbox.js index d0a6cc50ed5..dff1d645cd9 100644 --- a/src/util/mapbox.js +++ b/src/util/mapbox.js @@ -14,7 +14,6 @@ ******************************************************************************/ import assert from 'assert'; - import config from './config.js'; import webpSupported from './webp_supported.js'; import {isMapboxHTTPURL, isMapboxURL} from './mapbox_url.js'; @@ -23,10 +22,12 @@ import {version as sdkVersion} from '../../package.json'; import {uuid, validateUuid, storageAvailable, b64DecodeUnicode, b64EncodeUnicode, warnOnce, extend} from './util.js'; import {postData, ResourceType, getData} from './ajax.js'; import {getLivePerformanceMetrics} from '../util/live_performance.js'; + import type {LivePerformanceData} from '../util/live_performance.js'; import type {RequestParameters} from './ajax.js'; import type {Cancelable} from '../types/cancelable.js'; import type {TileJSON} from '../types/tilejson.js'; +import type {Map as MapboxMap} from "../ui/map"; type ResourceTypeEnum = $Keys; export type RequestTransformFunction = (url: string, resourceType?: ResourceTypeEnum) => RequestParameters; @@ -287,7 +288,7 @@ function parseAccessToken(accessToken: ?string) { } } -type TelemetryEventType = 'appUserTurnstile' | 'map.load' | 'map.auth' | 'gljs.performance'; +type TelemetryEventType = 'appUserTurnstile' | 'map.load' | 'map.auth' | 'gljs.performance' | 'style.load'; class TelemetryEvent { eventData: any; @@ -497,6 +498,85 @@ export class MapLoadEvent extends TelemetryEvent { } } +type StyleLoadEventInput = { + map: MapboxMap; + style: string; + importedStyles: string[]; +} + +type StyleLoadEventPayload = { + mapInstanceId: string; + eventId: number; + style: string; + importedStyles?: string[]; +} + +export class StyleLoadEvent extends TelemetryEvent { + eventIdPerMapInstanceMap: Map; + mapInstanceIdMap: WeakMap; + + constructor() { + super('style.load'); + this.eventIdPerMapInstanceMap = new Map(); + this.mapInstanceIdMap = new WeakMap(); + } + + getMapInstanceId(map: MapboxMap): string { + let instanceId = this.mapInstanceIdMap.get(map); + + if (!instanceId) { + instanceId = uuid(); + this.mapInstanceIdMap.set(map, instanceId); + } + + return instanceId; + } + + getEventId(mapInstanceId: string): number { + const eventId = this.eventIdPerMapInstanceMap.get(mapInstanceId) || 0; + this.eventIdPerMapInstanceMap.set(mapInstanceId, eventId + 1); + return eventId; + } + + postStyleLoadEvent(customAccessToken: ?string, input: StyleLoadEventInput) { + const { + map, + style, + importedStyles, + } = input; + + if (!config.EVENTS_URL || !(customAccessToken || config.ACCESS_TOKEN)) { + return; + } + + const mapInstanceId = this.getMapInstanceId(map); + const payload: StyleLoadEventPayload = { + mapInstanceId, + eventId: this.getEventId(mapInstanceId), + style, + }; + + if (importedStyles.length) { + payload.importedStyles = importedStyles; + } + + this.queueRequest({ + timestamp: Date.now(), + payload + }, customAccessToken); + } + + processRequests(customAccessToken?: ?string) { + if (this.pendingRequest || this.queue.length === 0) { + return; + } + + const {timestamp, payload} = this.queue.shift(); + + this.postEvent(timestamp, payload, () => {}, customAccessToken); + } +} + export class MapSessionAPI extends TelemetryEvent { +success: {[_: number]: boolean}; skuToken: string; @@ -642,6 +722,10 @@ export const mapLoadEvent: MapLoadEvent = new MapLoadEvent(); // $FlowFixMe[method-unbinding] export const postMapLoadEvent: (number, string, ?string, EventCallback) => void = mapLoadEvent.postMapLoadEvent.bind(mapLoadEvent); +export const styleLoadEvent: StyleLoadEvent = new StyleLoadEvent(); +// $FlowFixMe[method-unbinding] +export const postStyleLoadEvent: (?string, StyleLoadEventInput) => void = styleLoadEvent.postStyleLoadEvent.bind(styleLoadEvent); + export const performanceEvent_: PerformanceEvent = new PerformanceEvent(); // $FlowFixMe[method-unbinding] export const postPerformanceEvent: (?string, LivePerformanceData) => void = performanceEvent_.postPerformanceEvent.bind(performanceEvent_); diff --git a/src/util/tile_request_cache.js b/src/util/tile_request_cache.js index ce3b22bd53d..76ae7f4330a 100644 --- a/src/util/tile_request_cache.js +++ b/src/util/tile_request_cache.js @@ -1,6 +1,7 @@ // @flow import {warnOnce, parseCacheControl} from './util.js'; +import {stripQueryParameters, setQueryParameters} from './url.js'; import type Dispatcher from './dispatcher.js'; @@ -88,7 +89,8 @@ export function cachePut(request: Request, response: Response, requestTime: numb const timeUntilExpiry = new Date(expires).getTime() - requestTime; if (timeUntilExpiry < MIN_TIME_UNTIL_EXPIRY) return; - let strippedURL = stripQueryParameters(request.url); + // preserve `language` and `worldview` params if any + let strippedURL = stripQueryParameters(request.url, {persistentParams: ['language', 'worldview']}); // Handle partial responses by keeping the range header in the query string if (response.status === 206) { @@ -111,42 +113,14 @@ export function cachePut(request: Request, response: Response, requestTime: numb }); } -function stripQueryParameters(url: string): string { - const paramStart = url.indexOf('?'); - if (paramStart < 0) return url; - - // preserve `language` and `worldview` params if any - const persistentParams = ['language', 'worldview']; - - const nextParams = new URLSearchParams(); - const searchParams = new URLSearchParams(url.slice(paramStart)); - for (const param of persistentParams) { - const value = searchParams.get(param); - if (value) nextParams.set(param, value); - } - - return `${url.slice(0, paramStart)}?${nextParams.toString()}`; -} - -function setQueryParameters(url: string, params: {[string]: string}): string { - const paramStart = url.indexOf('?'); - if (paramStart < 0) return `${url}?${new URLSearchParams(params).toString()}`; - - const searchParams = new URLSearchParams(url.slice(paramStart)); - for (const key in params) { - searchParams.set(key, params[key]); - } - - return `${url.slice(0, paramStart)}?${searchParams.toString()}`; -} - export function cacheGet(request: Request, callback: (error: ?any, response: ?Response, fresh: ?boolean) => void): void { cacheOpen(); if (!sharedCache) return callback(null); sharedCache .then(cache => { - let strippedURL = stripQueryParameters(request.url); + // preserve `language` and `worldview` params if any + let strippedURL = stripQueryParameters(request.url, {persistentParams: ['language', 'worldview']}); const range = request.headers.get('Range'); if (range) strippedURL = setQueryParameters(strippedURL, {range}); diff --git a/src/util/url.js b/src/util/url.js new file mode 100644 index 00000000000..63fa63fc40a --- /dev/null +++ b/src/util/url.js @@ -0,0 +1,33 @@ +// @flow + +export function setQueryParameters(url: string, params: {[string]: string}): string { + const paramStart = url.indexOf('?'); + if (paramStart < 0) return `${url}?${new URLSearchParams(params).toString()}`; + + const searchParams = new URLSearchParams(url.slice(paramStart)); + for (const key in params) { + searchParams.set(key, params[key]); + } + + return `${url.slice(0, paramStart)}?${searchParams.toString()}`; +} + +type StripQueryParameters = { + persistentParams: string[] +} + +export function stripQueryParameters(url: string, params?: StripQueryParameters = {persistentParams: []}): string { + const paramStart = url.indexOf('?'); + if (paramStart < 0) return url; + + const nextParams = new URLSearchParams(); + const searchParams = new URLSearchParams(url.slice(paramStart)); + for (const param of params.persistentParams) { + const value = searchParams.get(param); + if (value) nextParams.set(param, value); + } + + const nextParamsString = nextParams.toString(); + + return `${url.slice(0, paramStart)}${nextParamsString.length > 0 ? `?${nextParamsString}` : ''}`; +} diff --git a/test/unit/style/style_imports.test.js b/test/unit/style/style_imports.test.js index 113d732d83d..b59e1d05422 100644 --- a/test/unit/style/style_imports.test.js +++ b/test/unit/style/style_imports.test.js @@ -798,6 +798,91 @@ describe('Style#updateImport', () => { }); }); +describe('Style#getImportGlobalIds', () => { + test('should return all imports', async () => { + const style = new Style(new StubMap()); + + networkWorker.use( + http.get('/standard.json', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.get('/standard-2.json', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.get('/supplement.json', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.get('/roads.json', () => { + return HttpResponse.json(createStyleJSON()); + }), + ); + + style.loadJSON({ + version: 8, + imports: [ + { + id: 'supplement', + url: '/supplement.json', + data: { + version: 8, + layers: [], + sources: {}, + imports: [ + { + id: 'inner', + url: '/inner.json', + data: { + version: 8, + layers: [], + sources: {}, + imports: [ + { + id: 'basemap-2', + url: '/standard-2.json' + } + ] + } + } + ] + } + }, + { + id: 'roads', + url: '/roads.json' + }, + { + id: 'wrapper', + url: '/non-standard.json', + data: { + version: 8, + layers: [], + sources: {}, + imports: [ + { + id: 'basemap', + url: '/standard.json' + } + ] + } + } + ], + layers: [], + sources: {} + }); + + await waitFor(style, "style.load"); + + expect(style.getImportGlobalIds()).toEqual([ + "json://2572277275", + "json://978922503", + new URL("/standard-2.json", location.href).toString(), + new URL("/roads.json", location.href).toString(), + "json://3288768429", + new URL("/standard.json", location.href).toString(), + ]); + }); +}); + describe('Style#addSource', () => { test('same id in different scopes', async () => { const style = new Style(new StubMap()); diff --git a/test/unit/ui/map.test.js b/test/unit/ui/map.test.js index e16078ddf9e..7e48f9454e9 100755 --- a/test/unit/ui/map.test.js +++ b/test/unit/ui/map.test.js @@ -1,8 +1,6 @@ -import assert from 'assert'; - import {describe, test, beforeEach, afterEach, expect, waitFor, vi, createMap} from '../../util/vitest.js'; import {createStyle, createStyleSource} from './map/util.js'; -import {getPNGResponse, getRequestBody} from '../../util/network.js'; +import {getPNGResponse} from '../../util/network.js'; import {extend} from '../../../src/util/util.js'; import {Map} from '../../../src/ui/map.js'; import Actor from '../../../src/util/actor.js'; @@ -12,7 +10,6 @@ import {OverscaledTileID} from '../../../src/source/tile_id.js'; import {ErrorEvent} from '../../../src/util/evented.js'; import simulate, {constructTouch} from '../../util/simulate_interaction.js'; import {fixedNum} from '../../util/fixed.js'; -import {performanceEvent_} from '../../../src/util/mapbox.js'; import {makeFQID} from '../../../src/util/fqid.js'; // Mock implementation of elevation @@ -71,60 +68,6 @@ describe('Map', () => { expect(stub.mock.calls[0][0]).toEqual('mapbox://styles/mapbox/standard'); }); - test('disablePerformanceMetricsCollection', async () => { - const fetchSpy = vi.spyOn(window, 'fetch'); - const map = createMap({performanceMetricsCollection: false}); - await waitFor(map, "idle"); - map.triggerRepaint(); - await waitFor(map, "idle"); - expect(map._fullyLoaded).toBeTruthy(); - expect(map._loaded).toBeTruthy(); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - test('default performance metrics collection', async () => { - const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation(async () => { - return new window.Response('{}'); - }); - const map = createMap({performanceMetricsCollection: true}); - map._requestManager._customAccessToken = 'access-token'; - await waitFor(map, "idle"); - map.triggerRepaint(); - await waitFor(map, "idle"); - expect(map._fullyLoaded).toBeTruthy(); - expect(map._loaded).toBeTruthy(); - const reqBody = await getRequestBody(fetchSpy.mock.calls[0][0]); - const performanceEvent = JSON.parse(reqBody.slice(1, reqBody.length - 1)); - expect(performanceEvent.event).toEqual('gljs.performance'); - performanceEvent_.pendingRequest = null; - }); - - test('performance metrics event stores explicit projection', async () => { - const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation(async () => { - return new window.Response('{}'); - }); - const map = createMap({performanceMetricsCollection: true, projection: 'globe', zoom: 20}); - map._requestManager._customAccessToken = 'access-token'; - await waitFor(map, "idle"); - map.triggerRepaint(); - await waitFor(map, "idle"); - expect(map._fullyLoaded).toBeTruthy(); - expect(map._loaded).toBeTruthy(); - const reqBody = await getRequestBody(fetchSpy.mock.calls[0][0]); - const performanceEvent = JSON.parse(reqBody.slice(1, reqBody.length - 1)); - const checkMetric = (data, metricName, metricValue) => { - for (const metric of data) { - if (metric.name === metricName) { - expect(metric.value).toEqual(metricValue); - return; - } - } - assert(false); - }; - checkMetric(performanceEvent.attributes, 'projection', 'globe'); - performanceEvent_.pendingRequest = null; - }); - test('warns when map container is not empty', () => { const container = window.document.createElement('div'); container.textContent = 'Hello World'; diff --git a/test/unit/ui/map/metrics.test.js b/test/unit/ui/map/metrics.test.js new file mode 100644 index 00000000000..6e83c9c6c0a --- /dev/null +++ b/test/unit/ui/map/metrics.test.js @@ -0,0 +1,658 @@ +import assert from 'assert'; + +import {describe, test, expect, waitFor, vi, createMap, beforeAll, afterEach, afterAll, doneAsync} from '../../../util/vitest.js'; +import {getRequestBody, getNetworkWorker, http, HttpResponse} from '../../../util/network.js'; +import {extend} from '../../../../src/util/util.js'; +import {performanceEvent_} from '../../../../src/util/mapbox.js'; + +let networkWorker; + +beforeAll(async () => { + networkWorker = await getNetworkWorker(window); +}); + +afterEach(() => { + networkWorker.resetHandlers(); +}); + +afterAll(() => { + networkWorker.stop(); +}); + +function createStyleJSON(properties) { + return extend({ + "version": 8, + "sources": {}, + "layers": [] + }, properties); +} + +describe('Map', () => { + describe('Metrics', () => { + test('disable performance metrics collection', async () => { + const fetchSpy = vi.spyOn(window, 'fetch'); + const map = createMap({performanceMetricsCollection: false}); + await waitFor(map, "idle"); + map.triggerRepaint(); + await waitFor(map, "idle"); + expect(map._fullyLoaded).toBeTruthy(); + expect(map._loaded).toBeTruthy(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test('default performance metrics collection', async () => { + const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation(async () => { + return new window.Response('{}'); + }); + const map = createMap({ + performanceMetricsCollection: true, + accessToken: 'access-token' + }); + await waitFor(map, "idle"); + map.triggerRepaint(); + await waitFor(map, "idle"); + expect(map._fullyLoaded).toBeTruthy(); + expect(map._loaded).toBeTruthy(); + + async function getEventNames() { + const events = await Promise.all(fetchSpy.mock.calls.map(async ([arg]) => { + const requestBody = await getRequestBody(arg); + return JSON.parse(requestBody.slice(1, requestBody.length - 1)); + })); + + return events.map(e => e.event); + } + expect(await getEventNames()).toEqual([ + 'style.load', + 'gljs.performance' + ]); + performanceEvent_.pendingRequest = null; + }); + + test('performance metrics event stores explicit projection', async () => { + const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation(async () => { + return new window.Response('{}'); + }); + const map = createMap({ + performanceMetricsCollection: true, + projection: 'globe', + zoom: 20, + accessToken: 'access-token' + }); + + await waitFor(map, "idle"); + map.triggerRepaint(); + await waitFor(map, "idle"); + expect(map._fullyLoaded).toBeTruthy(); + expect(map._loaded).toBeTruthy(); + const reqBody = await getRequestBody(fetchSpy.mock.calls[1][0]); + const performanceEvent = JSON.parse(reqBody.slice(1, reqBody.length - 1)); + const checkMetric = (data, metricName, metricValue) => { + for (const metric of data) { + if (metric.name === metricName) { + expect(metric.value).toEqual(metricValue); + return; + } + } + assert(false); + }; + checkMetric(performanceEvent.attributes, 'projection', 'globe'); + performanceEvent_.pendingRequest = null; + }); + + describe('Style loading event', () => { + function getStyleLoadEventChecker(payload) { + return async ({request}, doneRef) => { + const reqBody = await getRequestBody(request); + const performanceEvent = JSON.parse(reqBody.slice(1, reqBody.length - 1)); + + if (performanceEvent.event !== 'style.load') { + return HttpResponse.json({}); + } + + expect(performanceEvent).toEqual(payload); + doneRef.resolve(); + + return HttpResponse.json({}); + }; + } + + test('should not add imported styles for standard-like style', () => { + const {wait, withAsync} = doneAsync(); + + networkWorker.use( + http.get('/style.json', () => { + return HttpResponse.json(createStyleJSON({ + schema: { + showPlaceLabels: { + default: true, + type: "boolean" + }, + } + })); + }), + http.post( + 'https://events.mapbox.com/events/v2', + withAsync(getStyleLoadEventChecker({ + event: 'style.load', + created: expect.any(String), + mapInstanceId: expect.any(String), + eventId: 0, + style: new URL('/style.json', location.href).toString() + })) + ), + ); + + createMap({ + performanceMetricsCollection: true, + style: '/style.json', + accessToken: 'access-token' + }); + + return wait; + }); + + test('should strip query parameters from URLs', () => { + const {wait, withAsync} = doneAsync(); + + networkWorker.use( + http.get('/standard.json', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.get('https://api.mapbox.com/styles/v1/mapbox/standard', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.get('/style.json', () => { + return HttpResponse.json(createStyleJSON({ + imports: [ + { + id: 'other', + url: '/standard.json?sensitive=true&security=true' + }, + { + id: 'basemap', + url: 'mapbox://styles/mapbox/standard?some_param=42' + } + ] + })); + }), + http.post( + 'https://events.mapbox.com/events/v2', + withAsync(getStyleLoadEventChecker({ + event: 'style.load', + created: expect.any(String), + mapInstanceId: expect.any(String), + eventId: 0, + style: new URL('/style.json', location.href).toString(), + importedStyles: [ + new URL('/standard.json', location.href).toString(), + 'mapbox://styles/mapbox/standard?some_param=42' + ] + })) + ), + ); + + createMap({ + performanceMetricsCollection: true, + style: '/style.json?secret=true', + accessToken: 'access-token' + }); + + return wait; + }); + + test('should send style load event with style', async () => { + const {wait, withAsync} = doneAsync(); + + networkWorker.use( + http.get('https://localhost:8080/style.json', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.post( + 'https://events.mapbox.com/events/v2', + withAsync(getStyleLoadEventChecker({ + event: 'style.load', + created: expect.any(String), + mapInstanceId: expect.any(String), + eventId: 0, + style: 'https://localhost:8080/style.json' + })) + ), + ); + + createMap({ + performanceMetricsCollection: true, + style: 'https://localhost:8080/style.json', + accessToken: 'access-token' + }); + + return wait; + }); + + test('should send style load event with imported style', async () => { + const {wait, withAsync} = doneAsync(); + + networkWorker.use( + http.get('/standard.json', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.post( + 'https://events.mapbox.com/events/v2', + withAsync(getStyleLoadEventChecker({ + event: 'style.load', + created: expect.any(String), + mapInstanceId: expect.any(String), + eventId: 0, + style: 'json://1187918353', + importedStyles: [ + new URL('/standard.json', location.href).toString() + ] + })) + ), + ); + + createMap({ + performanceMetricsCollection: true, + style: { + version: 8, + imports: [ + { + id: 'basemap', + url: '/standard.json' + } + ], + layers: [], + sources: {} + }, + accessToken: 'access-token' + }); + + return wait; + }); + + test('should send style load event with nested imported style', async () => { + const {wait, withAsync} = doneAsync(); + + networkWorker.use( + http.get('/standard.json', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.get('/standard-2.json', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.get('/supplement.json', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.get('/roads.json', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.post( + 'https://events.mapbox.com/events/v2', + withAsync(getStyleLoadEventChecker({ + event: 'style.load', + created: expect.any(String), + mapInstanceId: expect.any(String), + eventId: 0, + style: 'json://4028586463', + importedStyles: [ + 'json://2572277275', + 'json://978922503', + new URL('/standard-2.json', location.href).toString(), + new URL('/roads.json', location.href).toString(), + 'json://3288768429', + new URL('/standard.json', location.href).toString(), + ] + })) + ), + ); + + createMap({ + performanceMetricsCollection: true, + style: { + version: 8, + imports: [ + { + id: 'supplement', + url: '/supplement.json', + data: { + version: 8, + layers: [], + sources: {}, + imports: [ + { + id: 'inner', + url: '/inner.json', + data: { + version: 8, + layers: [], + sources: {}, + imports: [ + { + id: 'basemap-2', + url: '/standard-2.json' + } + ] + } + } + ] + } + }, + { + id: 'roads', + url: '/roads.json' + }, + { + id: 'wrapper', + url: '/non-standard.json', + data: { + version: 8, + layers: [], + sources: {}, + imports: [ + { + id: 'basemap', + url: '/standard.json' + } + ] + } + } + ], + layers: [], + sources: {} + }, + accessToken: 'access-token' + }); + + return wait; + }); + + test('should send style load events in sequence after style URL switch', async () => { + const {wait, withAsync} = doneAsync(); + + let styleLoadEventCounter = 0; + let mapInstanceId = null; + + const expected = [ + { + event: 'style.load', + created: expect.any(String), + mapInstanceId: expect.any(String), + eventId: 0, + style: 'mapbox://styles/mapbox/standard', + }, + { + event: 'style.load', + created: expect.any(String), + mapInstanceId: expect.any(String), + eventId: 1, + style: new URL('/another.json', location.href).toString(), + } + ]; + + networkWorker.use( + http.get('https://api.mapbox.com/styles/v1/mapbox/standard', () => { + return HttpResponse.json(createStyleJSON({ + layers: [ + { + id: 'background', + type: 'background', + paint: { + 'background-color': '#000' + } + } + ] + })); + }), + http.get('/another.json', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.post( + 'https://events.mapbox.com/events/v2', + withAsync(async ({request}, doneRef) => { + const reqBody = await getRequestBody(request); + const performanceEvent = JSON.parse(reqBody.slice(1, reqBody.length - 1)); + + if (performanceEvent.event !== 'style.load') { + return HttpResponse.json({}); + } + + mapInstanceId = reqBody.mapInstanceId; + + const index = styleLoadEventCounter++; + + expect(performanceEvent).toEqual({ + ...expected[index], + mapInstanceId: mapInstanceId || expected[index].mapInstanceId + }); + + assert(styleLoadEventCounter <= expected.length, 'More then expected "style.load" events'); + + if (styleLoadEventCounter === expected.length) { + doneRef.resolve(); + } + + return HttpResponse.json({}); + }) + ) + ); + + const map = createMap({ + performanceMetricsCollection: true, + style: 'mapbox://styles/mapbox/standard', + accessToken: 'access-token' + }); + + await waitFor(map, 'load'); + + map.setStyle('/another.json'); + + return wait; + }); + + test('should send style load events in sequence after style JSON switch', async () => { + const {wait, withAsync} = doneAsync(); + + let styleLoadEventCounter = 0; + let mapInstanceId = null; + + const expected = [ + { + event: 'style.load', + created: expect.any(String), + mapInstanceId: expect.any(String), + eventId: 0, + style: 'mapbox://styles/mapbox/standard', + }, + { + event: 'style.load', + created: expect.any(String), + mapInstanceId: expect.any(String), + eventId: 1, + style: 'json://684132956', + importedStyles: [ + new URL('/standard-2.json', location.href).toString(), + new URL('/standard-3.json', location.href).toString() + ] + } + ]; + + networkWorker.use( + http.get('https://api.mapbox.com/styles/v1/mapbox/standard', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.get('/standard-2.json', () => { + return HttpResponse.json(createStyleJSON({ + imports: [ + { + id: 'inner', + url: '/standard-3.json' + } + ] + })); + }), + http.get('/standard-3.json', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.post( + 'https://events.mapbox.com/events/v2', + withAsync(async ({request}, doneRef) => { + const reqBody = await getRequestBody(request); + const performanceEvent = JSON.parse(reqBody.slice(1, reqBody.length - 1)); + + if (performanceEvent.event !== 'style.load') { + return HttpResponse.json({}); + } + + mapInstanceId = reqBody.mapInstanceId; + + const index = styleLoadEventCounter++; + + expect(performanceEvent).toEqual({ + ...expected[index], + mapInstanceId: mapInstanceId || expected[index].mapInstanceId + }); + + assert(styleLoadEventCounter <= expected.length, 'More then expected "style.load" events'); + + if (styleLoadEventCounter === expected.length) { + doneRef.resolve(); + } + + return HttpResponse.json({}); + }) + ) + ); + + const map = createMap({ + performanceMetricsCollection: true, + style: 'mapbox://styles/mapbox/standard', + accessToken: 'access-token' + }); + + await waitFor(map, 'load'); + + map.setStyle({ + version: 8, + imports: [ + { + id: 'basemap', + url: '/standard-2.json' + } + ], + layers: [], + sources: {} + }); + + return wait; + }); + + test('should send second event after switch from standard-like style to import', async () => { + const {wait, withAsync} = doneAsync(); + + let styleLoadEventCounter = 0; + let mapInstanceId = null; + + const expected = [ + { + event: 'style.load', + created: expect.any(String), + mapInstanceId: expect.any(String), + eventId: 0, + style: 'mapbox://styles/mapbox/standard', + }, + { + event: 'style.load', + created: expect.any(String), + mapInstanceId: expect.any(String), + eventId: 1, + style: new URL('/another.json', location.href).toString(), + importedStyles: [ + new URL('/second.json', location.href).toString(), + new URL('/inner.json', location.href).toString() + ] + } + ]; + + networkWorker.use( + http.get('https://api.mapbox.com/styles/v1/mapbox/standard', () => { + return HttpResponse.json(createStyleJSON({ + schema: { + showPlaceLabels: { + default: true, + type: "boolean" + }, + } + })); + }), + http.get('/inner.json', () => { + return HttpResponse.json(createStyleJSON()); + }), + http.get('/second.json', () => { + return HttpResponse.json(createStyleJSON({ + imports: [ + { + id: 'inner', + url: '/inner.json' + }, + ] + })); + }), + http.get('/another.json', () => { + return HttpResponse.json(createStyleJSON({ + imports: [ + { + id: 'second', + url: '/second.json' + } + ] + })); + }), + http.post( + 'https://events.mapbox.com/events/v2', + withAsync(async ({request}, doneRef) => { + const reqBody = await getRequestBody(request); + const performanceEvent = JSON.parse(reqBody.slice(1, reqBody.length - 1)); + + if (performanceEvent.event !== 'style.load') { + return HttpResponse.json({}); + } + + mapInstanceId = reqBody.mapInstanceId; + + const index = styleLoadEventCounter++; + + expect(performanceEvent).toEqual({ + ...expected[index], + mapInstanceId: mapInstanceId || expected[index].mapInstanceId + }); + + assert(styleLoadEventCounter <= expected.length, 'More then expected "style.load" events'); + + if (styleLoadEventCounter === expected.length) { + doneRef.resolve(); + } + + return HttpResponse.json({}); + }) + ) + ); + + const map = createMap({ + performanceMetricsCollection: true, + style: 'mapbox://styles/mapbox/standard', + accessToken: 'access-token' + }); + + map.on('style.load', () => { + console.log('style.load'); + }); + + await waitFor(map, 'load'); + + map.setStyle('/another.json'); + + return wait; + }); + }); + }); +});