tileserver-gl 5.6.0 → 5.7.0-pre.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/package.json +23 -20
- package/public/resources/maplibre-gl.css +1 -1
- package/public/resources/maplibre-gl.js +4 -4
- package/public/resources/maplibre-gl.js.map +1 -1
- package/src/main.js +9 -0
- package/src/mbtiles_wrapper.js +9 -0
- package/src/metrics.js +70 -0
- package/src/pmtiles_adapter.js +51 -3
- package/src/serve_data.js +47 -4
- package/src/serve_font.js +10 -0
- package/src/serve_rendered.js +150 -23
- package/src/serve_style.js +17 -0
- package/src/server.js +144 -31
- package/src/utils.js +3 -3
- package/test/fixtures/reload-config.json +34 -0
- package/test/fixtures/visual/diffs/static-bearing-pitch-diff.png +0 -0
- package/test/metrics.js +102 -0
- package/test/reload.js +98 -0
- package/test/style.js +36 -0
- package/test/utils/create_terrain_mbtiles.js +16 -11
package/src/main.js
CHANGED
|
@@ -113,6 +113,10 @@ program
|
|
|
113
113
|
'--ignore-missing-files',
|
|
114
114
|
'Continue startup even if configured mbtiles/pmtiles files are missing',
|
|
115
115
|
)
|
|
116
|
+
.option(
|
|
117
|
+
'--metrics',
|
|
118
|
+
'Enable Prometheus metrics endpoint (env: TILESERVER_GL_METRICS)',
|
|
119
|
+
)
|
|
116
120
|
.version(packageJson.version, '-v, --version');
|
|
117
121
|
program.parse(process.argv);
|
|
118
122
|
const opts = program.opts();
|
|
@@ -166,6 +170,11 @@ const startServer = (configPath, config) => {
|
|
|
166
170
|
publicUrl,
|
|
167
171
|
allowedHosts,
|
|
168
172
|
ignoreMissingFiles: opts.ignoreMissingFiles,
|
|
173
|
+
metrics: opts.metrics || process.env.TILESERVER_GL_METRICS === 'true',
|
|
174
|
+
metricsPort: (() => {
|
|
175
|
+
const port = parseInt(process.env.METRICS_PORT, 10);
|
|
176
|
+
return !isNaN(port) && port > 0 ? port : 9090;
|
|
177
|
+
})(),
|
|
169
178
|
});
|
|
170
179
|
};
|
|
171
180
|
|
package/src/mbtiles_wrapper.js
CHANGED
|
@@ -8,6 +8,7 @@ class MBTilesWrapper {
|
|
|
8
8
|
constructor(mbtiles) {
|
|
9
9
|
this._mbtiles = mbtiles;
|
|
10
10
|
this._getInfoP = util.promisify(mbtiles.getInfo.bind(mbtiles));
|
|
11
|
+
this._closeP = util.promisify(mbtiles.close.bind(mbtiles));
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -25,6 +26,14 @@ class MBTilesWrapper {
|
|
|
25
26
|
getInfo() {
|
|
26
27
|
return this._getInfoP();
|
|
27
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Closes the underlying MBTiles database handle.
|
|
32
|
+
* @returns {Promise<void>} A promise that resolves when the database is closed.
|
|
33
|
+
*/
|
|
34
|
+
close() {
|
|
35
|
+
return this._closeP();
|
|
36
|
+
}
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
/**
|
package/src/metrics.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// src/metrics.js
|
|
2
|
+
import {
|
|
3
|
+
Registry,
|
|
4
|
+
Counter,
|
|
5
|
+
Histogram,
|
|
6
|
+
Gauge,
|
|
7
|
+
collectDefaultMetrics,
|
|
8
|
+
} from 'prom-client';
|
|
9
|
+
|
|
10
|
+
export const registry = new Registry();
|
|
11
|
+
|
|
12
|
+
collectDefaultMetrics({ register: registry });
|
|
13
|
+
|
|
14
|
+
export const httpRequestsTotal = new Counter({
|
|
15
|
+
name: 'tileserver_http_requests_total',
|
|
16
|
+
help: 'Total number of HTTP requests',
|
|
17
|
+
labelNames: ['method', 'route', 'status_code'],
|
|
18
|
+
registers: [registry],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const httpRequestDuration = new Histogram({
|
|
22
|
+
name: 'tileserver_http_request_duration_seconds',
|
|
23
|
+
help: 'HTTP request duration in seconds',
|
|
24
|
+
labelNames: ['method', 'route', 'status_code'],
|
|
25
|
+
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
|
|
26
|
+
registers: [registry],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const tilesServedTotal = new Counter({
|
|
30
|
+
name: 'tileserver_tiles_served_total',
|
|
31
|
+
help: 'Total number of tiles served',
|
|
32
|
+
labelNames: ['type', 'name'],
|
|
33
|
+
registers: [registry],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const tileRenderDuration = new Histogram({
|
|
37
|
+
name: 'tileserver_tile_render_duration_seconds',
|
|
38
|
+
help: 'Tile pipeline duration in seconds (includes pool wait + render + encode)',
|
|
39
|
+
labelNames: ['name', 'zoom'],
|
|
40
|
+
buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
|
|
41
|
+
registers: [registry],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const tileErrorsTotal = new Counter({
|
|
45
|
+
name: 'tileserver_tile_errors_total',
|
|
46
|
+
help: 'Total number of tile errors',
|
|
47
|
+
labelNames: ['type', 'name'],
|
|
48
|
+
registers: [registry],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const renderPoolSize = new Gauge({
|
|
52
|
+
name: 'tileserver_render_pool_size',
|
|
53
|
+
help: 'Total objects in render pool',
|
|
54
|
+
labelNames: ['name'],
|
|
55
|
+
registers: [registry],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const renderPoolActive = new Gauge({
|
|
59
|
+
name: 'tileserver_render_pool_active',
|
|
60
|
+
help: 'Active (borrowed) objects in render pool',
|
|
61
|
+
labelNames: ['name'],
|
|
62
|
+
registers: [registry],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const renderPoolWaiting = new Gauge({
|
|
66
|
+
name: 'tileserver_render_pool_waiting',
|
|
67
|
+
help: 'Clients waiting for a render pool object',
|
|
68
|
+
labelNames: ['name'],
|
|
69
|
+
registers: [registry],
|
|
70
|
+
});
|
package/src/pmtiles_adapter.js
CHANGED
|
@@ -232,18 +232,22 @@ class S3Source {
|
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
if (error.name === 'NoSuchKey') {
|
|
235
|
-
throw new Error(`PMTiles file not found: ${this.bucket}/${this.key}
|
|
235
|
+
throw new Error(`PMTiles file not found: ${this.bucket}/${this.key}`, {
|
|
236
|
+
cause: error,
|
|
237
|
+
});
|
|
236
238
|
}
|
|
237
239
|
|
|
238
240
|
if (error.name === 'AccessDenied') {
|
|
239
241
|
throw new Error(
|
|
240
242
|
`Access denied: ${this.bucket}/${this.key}. Check credentials and bucket permissions.`,
|
|
243
|
+
{ cause: error },
|
|
241
244
|
);
|
|
242
245
|
}
|
|
243
246
|
|
|
244
247
|
if (error.name === 'NoSuchBucket') {
|
|
245
248
|
throw new Error(
|
|
246
249
|
`Bucket not found: ${this.bucket}. Check bucket name and endpoint.`,
|
|
250
|
+
{ cause: error },
|
|
247
251
|
);
|
|
248
252
|
}
|
|
249
253
|
|
|
@@ -288,6 +292,23 @@ class PMTilesFileSource {
|
|
|
288
292
|
);
|
|
289
293
|
return { data: ab };
|
|
290
294
|
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Closes the underlying file descriptor for local PMTiles sources.
|
|
298
|
+
* @returns {void}
|
|
299
|
+
*/
|
|
300
|
+
close() {
|
|
301
|
+
if (typeof this.fd === 'number') {
|
|
302
|
+
const fd = this.fd;
|
|
303
|
+
try {
|
|
304
|
+
fs.closeSync(fd);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
console.warn(`Failed to close PMTiles file descriptor ${fd}:`, err);
|
|
307
|
+
} finally {
|
|
308
|
+
this.fd = null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
291
312
|
}
|
|
292
313
|
|
|
293
314
|
/**
|
|
@@ -311,6 +332,22 @@ async function readFileBytes(fd, buffer, offset) {
|
|
|
311
332
|
// Cache for PMTiles objects to avoid creating multiple instances for the same URL
|
|
312
333
|
const pmtilesCache = new Map();
|
|
313
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Closes a PMTiles instance if it owns a closeable local file source.
|
|
337
|
+
* @param {PMTiles} pmtiles - The PMTiles instance to close.
|
|
338
|
+
* @returns {void}
|
|
339
|
+
*/
|
|
340
|
+
function closePMTiles(pmtiles) {
|
|
341
|
+
if (!pmtiles) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const source = pmtiles.source;
|
|
346
|
+
if (source && typeof source.close === 'function') {
|
|
347
|
+
source.close();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
314
351
|
/**
|
|
315
352
|
* Opens a PMTiles file from local filesystem, HTTP URL, or S3 URL.
|
|
316
353
|
* Uses caching to avoid creating multiple PMTiles instances for the same file.
|
|
@@ -347,7 +384,7 @@ export function openPMtiles(
|
|
|
347
384
|
return pmtilesCache.get(cacheKey);
|
|
348
385
|
}
|
|
349
386
|
|
|
350
|
-
let pmtiles
|
|
387
|
+
let pmtiles;
|
|
351
388
|
|
|
352
389
|
if (isS3Url(filePath)) {
|
|
353
390
|
if (verbose >= 2) {
|
|
@@ -384,6 +421,17 @@ export function openPMtiles(
|
|
|
384
421
|
return pmtiles;
|
|
385
422
|
}
|
|
386
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Clears the PMTiles cache and closes any local file descriptors owned by cached sources.
|
|
426
|
+
* @returns {void}
|
|
427
|
+
*/
|
|
428
|
+
export function clearPMtilesCache() {
|
|
429
|
+
for (const pmtiles of pmtilesCache.values()) {
|
|
430
|
+
closePMTiles(pmtiles);
|
|
431
|
+
}
|
|
432
|
+
pmtilesCache.clear();
|
|
433
|
+
}
|
|
434
|
+
|
|
387
435
|
/**
|
|
388
436
|
* Retrieves metadata and header information from a PMTiles archive with retry logic for rate limiting.
|
|
389
437
|
* @param {PMTiles} pmtiles - The PMTiles instance.
|
|
@@ -462,7 +510,7 @@ export async function getPMtilesInfo(pmtiles, inputFile, maxRetries = 3) {
|
|
|
462
510
|
// If not a 429 or last retry, throw immediately
|
|
463
511
|
if (!error.message?.includes('429') || attempt === maxRetries - 1) {
|
|
464
512
|
const errorMessage = `${error.message} for file: ${inputFile}`;
|
|
465
|
-
throw new Error(errorMessage);
|
|
513
|
+
throw new Error(errorMessage, { cause: error });
|
|
466
514
|
}
|
|
467
515
|
}
|
|
468
516
|
}
|
package/src/serve_data.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'path';
|
|
|
5
5
|
|
|
6
6
|
import clone from 'clone';
|
|
7
7
|
import express from 'express';
|
|
8
|
-
import
|
|
8
|
+
import { PbfReader } from 'pbf';
|
|
9
9
|
import { VectorTile } from '@mapbox/vector-tile';
|
|
10
10
|
import { SphericalMercator } from '@mapbox/sphericalmercator';
|
|
11
11
|
|
|
@@ -21,6 +21,8 @@ import { gunzipP, gzipP } from './promises.js';
|
|
|
21
21
|
import { openMbTilesWrapper } from './mbtiles_wrapper.js';
|
|
22
22
|
|
|
23
23
|
import fs from 'node:fs';
|
|
24
|
+
|
|
25
|
+
let metricsModule = null;
|
|
24
26
|
import { fileURLToPath } from 'url';
|
|
25
27
|
|
|
26
28
|
const packageJson = JSON.parse(
|
|
@@ -45,6 +47,16 @@ export const serve_data = {
|
|
|
45
47
|
*/
|
|
46
48
|
init: function (options, repo, programOpts) {
|
|
47
49
|
const { verbose, allowedHosts } = programOpts;
|
|
50
|
+
// Cache metrics module if enabled. Safe because tests verify before production.
|
|
51
|
+
if (programOpts.metrics) {
|
|
52
|
+
import('./metrics.js')
|
|
53
|
+
.then((m) => {
|
|
54
|
+
metricsModule = m;
|
|
55
|
+
})
|
|
56
|
+
.catch((err) => {
|
|
57
|
+
console.error('Failed to import metrics module:', err);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
48
60
|
const app = express().disable('x-powered-by');
|
|
49
61
|
app.use(express.json());
|
|
50
62
|
|
|
@@ -142,7 +154,7 @@ export const serve_data = {
|
|
|
142
154
|
headers['Content-Type'] = 'application/x-protobuf';
|
|
143
155
|
} else if (format === 'geojson') {
|
|
144
156
|
headers['Content-Type'] = 'application/json';
|
|
145
|
-
const tile = new VectorTile(new
|
|
157
|
+
const tile = new VectorTile(new PbfReader(data));
|
|
146
158
|
const geojson = {
|
|
147
159
|
type: 'FeatureCollection',
|
|
148
160
|
features: [],
|
|
@@ -167,6 +179,12 @@ export const serve_data = {
|
|
|
167
179
|
|
|
168
180
|
data = await gzipP(data);
|
|
169
181
|
|
|
182
|
+
if (metricsModule) {
|
|
183
|
+
metricsModule.tilesServedTotal.inc({
|
|
184
|
+
type: 'vector',
|
|
185
|
+
name: req.params.id,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
170
188
|
return res.status(200).send(data);
|
|
171
189
|
});
|
|
172
190
|
|
|
@@ -548,14 +566,14 @@ export const serve_data = {
|
|
|
548
566
|
if (!inputFileStats.isFile() || inputFileStats.size === 0) {
|
|
549
567
|
throw Error(`Not valid input file: "${inputFile}"`);
|
|
550
568
|
}
|
|
551
|
-
} catch (
|
|
569
|
+
} catch (_err) {
|
|
552
570
|
if (ignoreMissingFiles) {
|
|
553
571
|
console.log(
|
|
554
572
|
`WARN: Data source '${id}' file not found: "${inputFile}" - skipping`,
|
|
555
573
|
);
|
|
556
574
|
return;
|
|
557
575
|
}
|
|
558
|
-
throw Error(`Not valid input file: "${inputFile}"
|
|
576
|
+
throw Error(`Not valid input file: "${inputFile}"`, { cause: _err });
|
|
559
577
|
}
|
|
560
578
|
}
|
|
561
579
|
|
|
@@ -624,4 +642,29 @@ export const serve_data = {
|
|
|
624
642
|
sparse,
|
|
625
643
|
};
|
|
626
644
|
},
|
|
645
|
+
/**
|
|
646
|
+
* Removes all items from the repository and closes owned local data sources.
|
|
647
|
+
* @param {object} repo Repository object.
|
|
648
|
+
* @returns {Promise<void>}
|
|
649
|
+
*/
|
|
650
|
+
clear: async function (repo) {
|
|
651
|
+
await Promise.all(
|
|
652
|
+
Object.keys(repo).map(async (id) => {
|
|
653
|
+
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys() iteration
|
|
654
|
+
const item = repo[id];
|
|
655
|
+
try {
|
|
656
|
+
if (item && item.sourceType === 'mbtiles' && item.source) {
|
|
657
|
+
await new Promise((resolve, reject) => {
|
|
658
|
+
item.source.close((err) => (err ? reject(err) : resolve()));
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
} catch (err) {
|
|
662
|
+
console.warn(`Failed to close data source "${id}":`, err);
|
|
663
|
+
} finally {
|
|
664
|
+
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys() iteration
|
|
665
|
+
delete repo[id];
|
|
666
|
+
}
|
|
667
|
+
}),
|
|
668
|
+
);
|
|
669
|
+
},
|
|
627
670
|
};
|
package/src/serve_font.js
CHANGED
|
@@ -4,6 +4,8 @@ import express from 'express';
|
|
|
4
4
|
|
|
5
5
|
import { getFontsPbf, listFonts } from './utils.js';
|
|
6
6
|
|
|
7
|
+
let metricsModule = null;
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Initializes and returns an Express app that serves font files.
|
|
9
11
|
* @param {object} options - Configuration options for the server.
|
|
@@ -13,6 +15,11 @@ import { getFontsPbf, listFonts } from './utils.js';
|
|
|
13
15
|
*/
|
|
14
16
|
export async function serve_font(options, allowedFonts, programOpts) {
|
|
15
17
|
const { verbose } = programOpts;
|
|
18
|
+
// Cache metrics module if enabled. Safe because tests verify before production.
|
|
19
|
+
if (programOpts.metrics) {
|
|
20
|
+
const m = await import('./metrics.js');
|
|
21
|
+
metricsModule = m;
|
|
22
|
+
}
|
|
16
23
|
const app = express().disable('x-powered-by');
|
|
17
24
|
|
|
18
25
|
const lastModified = new Date().toUTCString();
|
|
@@ -64,6 +71,9 @@ export async function serve_font(options, allowedFonts, programOpts) {
|
|
|
64
71
|
);
|
|
65
72
|
res.header('Content-type', 'application/x-protobuf');
|
|
66
73
|
res.header('Last-Modified', lastModified);
|
|
74
|
+
if (metricsModule) {
|
|
75
|
+
metricsModule.tilesServedTotal.inc({ type: 'font', name: sFontStack });
|
|
76
|
+
}
|
|
67
77
|
return res.send(concatenated);
|
|
68
78
|
} catch (err) {
|
|
69
79
|
console.error(
|
package/src/serve_rendered.js
CHANGED
|
@@ -539,6 +539,7 @@ function calcZForBBox(bbox, w, h, query) {
|
|
|
539
539
|
* @param {object} res Express response object.
|
|
540
540
|
* @param {Buffer|null} overlay Optional overlay image.
|
|
541
541
|
* @param {string} mode Rendering mode ('tile' or 'static').
|
|
542
|
+
* @param {string|null} id Style or dataset ID for metrics labeling.
|
|
542
543
|
* @returns {Promise<void>}
|
|
543
544
|
*/
|
|
544
545
|
async function respondImage(
|
|
@@ -556,6 +557,7 @@ async function respondImage(
|
|
|
556
557
|
res,
|
|
557
558
|
overlay = null,
|
|
558
559
|
mode = 'tile',
|
|
560
|
+
id = null,
|
|
559
561
|
) {
|
|
560
562
|
if (
|
|
561
563
|
Math.abs(lon) > 180 ||
|
|
@@ -599,10 +601,14 @@ async function respondImage(
|
|
|
599
601
|
}
|
|
600
602
|
|
|
601
603
|
pool.acquire(async (err, renderer) => {
|
|
604
|
+
const renderStart = process.hrtime.bigint();
|
|
602
605
|
// Check if pool.acquire failed or returned null/invalid renderer
|
|
603
606
|
if (err) {
|
|
604
607
|
console.error('Failed to acquire renderer from pool:', err);
|
|
605
608
|
if (!res.headersSent) {
|
|
609
|
+
if (metricsModule) {
|
|
610
|
+
metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
|
|
611
|
+
}
|
|
606
612
|
return res.status(503).send('Renderer pool error');
|
|
607
613
|
}
|
|
608
614
|
return;
|
|
@@ -613,6 +619,9 @@ async function respondImage(
|
|
|
613
619
|
'Renderer is null - likely crashed or failed to initialize',
|
|
614
620
|
);
|
|
615
621
|
if (!res.headersSent) {
|
|
622
|
+
if (metricsModule) {
|
|
623
|
+
metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
|
|
624
|
+
}
|
|
616
625
|
return res.status(503).send('Renderer unavailable');
|
|
617
626
|
}
|
|
618
627
|
return;
|
|
@@ -627,6 +636,9 @@ async function respondImage(
|
|
|
627
636
|
console.error('Error removing bad renderer:', e);
|
|
628
637
|
}
|
|
629
638
|
if (!res.headersSent) {
|
|
639
|
+
if (metricsModule) {
|
|
640
|
+
metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
|
|
641
|
+
}
|
|
630
642
|
return res.status(503).send('Renderer invalid');
|
|
631
643
|
}
|
|
632
644
|
return;
|
|
@@ -693,6 +705,9 @@ async function respondImage(
|
|
|
693
705
|
console.error('Error removing failed renderer:', e);
|
|
694
706
|
}
|
|
695
707
|
if (!res.headersSent) {
|
|
708
|
+
if (metricsModule) {
|
|
709
|
+
metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
|
|
710
|
+
}
|
|
696
711
|
return res
|
|
697
712
|
.status(500)
|
|
698
713
|
.header('Content-Type', 'text/plain')
|
|
@@ -791,12 +806,34 @@ async function respondImage(
|
|
|
791
806
|
if (err || !buffer) {
|
|
792
807
|
console.error('Sharp error:', err);
|
|
793
808
|
if (!res.headersSent) {
|
|
809
|
+
if (metricsModule) {
|
|
810
|
+
metricsModule.tileErrorsTotal.inc({
|
|
811
|
+
type: 'rendered',
|
|
812
|
+
name: id,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
794
815
|
return res.status(500).send('Image processing failed');
|
|
795
816
|
}
|
|
796
817
|
return;
|
|
797
818
|
}
|
|
798
819
|
|
|
799
820
|
if (!res.headersSent) {
|
|
821
|
+
if (metricsModule) {
|
|
822
|
+
const renderDurationSec =
|
|
823
|
+
Number(process.hrtime.bigint() - renderStart) / 1e9;
|
|
824
|
+
metricsModule.tilesServedTotal.inc({
|
|
825
|
+
type: 'rendered',
|
|
826
|
+
name: id,
|
|
827
|
+
});
|
|
828
|
+
const zoomLabel =
|
|
829
|
+
process.env.TILESERVER_GL_METRICS_ZOOM === 'true'
|
|
830
|
+
? String(z)
|
|
831
|
+
: 'all';
|
|
832
|
+
metricsModule.tileRenderDuration.observe(
|
|
833
|
+
{ name: id, zoom: zoomLabel },
|
|
834
|
+
renderDurationSec,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
800
837
|
res.set({
|
|
801
838
|
'Last-Modified': item.lastModified,
|
|
802
839
|
'Content-Type': `image/${format}`,
|
|
@@ -814,6 +851,9 @@ async function respondImage(
|
|
|
814
851
|
console.error('Error removing renderer after error:', e);
|
|
815
852
|
}
|
|
816
853
|
if (!res.headersSent) {
|
|
854
|
+
if (metricsModule) {
|
|
855
|
+
metricsModule.tileErrorsTotal.inc({ type: 'rendered', name: id });
|
|
856
|
+
}
|
|
817
857
|
return res.status(500).send('Render failed');
|
|
818
858
|
}
|
|
819
859
|
}
|
|
@@ -905,7 +945,7 @@ async function handleTileRequest(
|
|
|
905
945
|
|
|
906
946
|
// prettier-ignore
|
|
907
947
|
return await respondImage(
|
|
908
|
-
options, item, z, tileCenter[0], tileCenter[1], 0, 0, parsedTileSize, parsedTileSize, scale, format, res,
|
|
948
|
+
options, item, z, tileCenter[0], tileCenter[1], 0, 0, parsedTileSize, parsedTileSize, scale, format, res, null, 'tile', id,
|
|
909
949
|
);
|
|
910
950
|
}
|
|
911
951
|
|
|
@@ -944,8 +984,8 @@ async function handleStaticRequest(
|
|
|
944
984
|
// eslint-disable-next-line security/detect-object-injection -- id is route parameter, validated by Express
|
|
945
985
|
const item = repo[id];
|
|
946
986
|
|
|
947
|
-
let parsedWidth
|
|
948
|
-
let parsedHeight
|
|
987
|
+
let parsedWidth;
|
|
988
|
+
let parsedHeight;
|
|
949
989
|
if (widthAndHeight) {
|
|
950
990
|
const sizeMatch = widthAndHeight.match(/^(\d+)x(\d+)$/);
|
|
951
991
|
if (sizeMatch) {
|
|
@@ -1012,7 +1052,7 @@ async function handleStaticRequest(
|
|
|
1012
1052
|
|
|
1013
1053
|
// prettier-ignore
|
|
1014
1054
|
return await respondImage(
|
|
1015
|
-
options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static',
|
|
1055
|
+
options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', id,
|
|
1016
1056
|
);
|
|
1017
1057
|
} else if (staticTypeMatch.groups.minx) {
|
|
1018
1058
|
// Area Based Static Image
|
|
@@ -1052,7 +1092,7 @@ async function handleStaticRequest(
|
|
|
1052
1092
|
|
|
1053
1093
|
// prettier-ignore
|
|
1054
1094
|
return await respondImage(
|
|
1055
|
-
options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static',
|
|
1095
|
+
options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', id,
|
|
1056
1096
|
);
|
|
1057
1097
|
} else if (staticTypeMatch.groups.auto) {
|
|
1058
1098
|
// Area Static Image
|
|
@@ -1111,7 +1151,7 @@ async function handleStaticRequest(
|
|
|
1111
1151
|
|
|
1112
1152
|
// prettier-ignore
|
|
1113
1153
|
return await respondImage(
|
|
1114
|
-
options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static',
|
|
1154
|
+
options, item, z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, format, res, overlay, 'static', id,
|
|
1115
1155
|
);
|
|
1116
1156
|
} else {
|
|
1117
1157
|
return res.sendStatus(404);
|
|
@@ -1119,6 +1159,7 @@ async function handleStaticRequest(
|
|
|
1119
1159
|
}
|
|
1120
1160
|
const existingFonts = {};
|
|
1121
1161
|
let maxScaleFactor = 2;
|
|
1162
|
+
let metricsModule = null;
|
|
1122
1163
|
|
|
1123
1164
|
export const serve_rendered = {
|
|
1124
1165
|
/**
|
|
@@ -1312,6 +1353,13 @@ export const serve_rendered = {
|
|
|
1312
1353
|
|
|
1313
1354
|
const { publicUrl, verbose, fetchTimeout } = programOpts;
|
|
1314
1355
|
|
|
1356
|
+
// Cache metrics module if enabled (avoids per-request dynamic imports).
|
|
1357
|
+
// Guard prevents re-importing per style added. Safe because tests verify before production.
|
|
1358
|
+
if (programOpts.metrics && !metricsModule) {
|
|
1359
|
+
const m = await import('./metrics.js');
|
|
1360
|
+
metricsModule = m;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1315
1363
|
const styleJSON = clone(style);
|
|
1316
1364
|
|
|
1317
1365
|
// Global sparse flag for HTTP/remote sources (from config options)
|
|
@@ -1857,6 +1905,31 @@ export const serve_rendered = {
|
|
|
1857
1905
|
maxPoolSize,
|
|
1858
1906
|
);
|
|
1859
1907
|
}
|
|
1908
|
+
|
|
1909
|
+
if (metricsModule) {
|
|
1910
|
+
map._metricsInterval = setInterval(() => {
|
|
1911
|
+
[map.renderers, map.renderersStatic].forEach((poolArr) => {
|
|
1912
|
+
poolArr.forEach((pool) => {
|
|
1913
|
+
if (!pool) return;
|
|
1914
|
+
try {
|
|
1915
|
+
const total = pool.size ?? 0;
|
|
1916
|
+
const available = pool.available ?? 0;
|
|
1917
|
+
metricsModule.renderPoolSize.set({ name: id }, total);
|
|
1918
|
+
metricsModule.renderPoolActive.set(
|
|
1919
|
+
{ name: id },
|
|
1920
|
+
total - available,
|
|
1921
|
+
);
|
|
1922
|
+
metricsModule.renderPoolWaiting.set(
|
|
1923
|
+
{ name: id },
|
|
1924
|
+
pool.pending ?? 0,
|
|
1925
|
+
);
|
|
1926
|
+
} catch (_) {
|
|
1927
|
+
/* pool may be mid-teardown */
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
});
|
|
1931
|
+
}, 5000);
|
|
1932
|
+
}
|
|
1860
1933
|
},
|
|
1861
1934
|
/**
|
|
1862
1935
|
* Removes an item from the repository.
|
|
@@ -1868,6 +1941,26 @@ export const serve_rendered = {
|
|
|
1868
1941
|
// eslint-disable-next-line security/detect-object-injection -- id is function parameter for removal
|
|
1869
1942
|
const item = repo[id];
|
|
1870
1943
|
if (item) {
|
|
1944
|
+
if (item.map._metricsInterval) {
|
|
1945
|
+
clearInterval(item.map._metricsInterval);
|
|
1946
|
+
}
|
|
1947
|
+
Object.keys(item.map.sources || {}).forEach((sourceId) => {
|
|
1948
|
+
// eslint-disable-next-line security/detect-object-injection -- sourceId is from Object.keys() iteration
|
|
1949
|
+
const source = item.map.sources[sourceId];
|
|
1950
|
+
// eslint-disable-next-line security/detect-object-injection -- sourceId is from Object.keys() iteration
|
|
1951
|
+
const sourceType = item.map.sourceTypes[sourceId];
|
|
1952
|
+
if (
|
|
1953
|
+
sourceType === 'mbtiles' &&
|
|
1954
|
+
source &&
|
|
1955
|
+
typeof source.close === 'function'
|
|
1956
|
+
) {
|
|
1957
|
+
source.close((err) => {
|
|
1958
|
+
if (err) {
|
|
1959
|
+
console.warn('Failed to close MBTiles source:', err);
|
|
1960
|
+
}
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
});
|
|
1871
1964
|
item.map.renderers.forEach((pool) => {
|
|
1872
1965
|
pool.close();
|
|
1873
1966
|
});
|
|
@@ -1879,25 +1972,59 @@ export const serve_rendered = {
|
|
|
1879
1972
|
delete repo[id];
|
|
1880
1973
|
},
|
|
1881
1974
|
/**
|
|
1882
|
-
* Removes all items from the repository.
|
|
1975
|
+
* Removes all items from the repository and closes owned local data sources.
|
|
1883
1976
|
* @param {object} repo Repository object.
|
|
1884
|
-
* @returns {void}
|
|
1977
|
+
* @returns {Promise<void>}
|
|
1885
1978
|
*/
|
|
1886
|
-
clear: function (repo) {
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1979
|
+
clear: async function (repo) {
|
|
1980
|
+
await Promise.all(
|
|
1981
|
+
Object.keys(repo).map(async (id) => {
|
|
1982
|
+
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys() iteration
|
|
1983
|
+
const item = repo[id];
|
|
1984
|
+
try {
|
|
1985
|
+
if (!item) {
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
await Promise.all(
|
|
1990
|
+
Object.keys(item.map.sources || {}).map(async (sourceId) => {
|
|
1991
|
+
// eslint-disable-next-line security/detect-object-injection -- sourceId is from Object.keys() iteration
|
|
1992
|
+
const source = item.map.sources[sourceId];
|
|
1993
|
+
// eslint-disable-next-line security/detect-object-injection -- sourceId is from Object.keys() iteration
|
|
1994
|
+
const sourceType = item.map.sourceTypes[sourceId];
|
|
1995
|
+
if (
|
|
1996
|
+
sourceType === 'mbtiles' &&
|
|
1997
|
+
source &&
|
|
1998
|
+
typeof source.close === 'function'
|
|
1999
|
+
) {
|
|
2000
|
+
await new Promise((resolve) => {
|
|
2001
|
+
source.close((err) => {
|
|
2002
|
+
if (err) {
|
|
2003
|
+
console.warn(
|
|
2004
|
+
`Failed to close MBTiles source "${sourceId}" while clearing rendered repo entry "${id}":`,
|
|
2005
|
+
err,
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
2008
|
+
resolve();
|
|
2009
|
+
});
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
}),
|
|
2013
|
+
);
|
|
2014
|
+
item.map.renderers.forEach((pool) => {
|
|
2015
|
+
pool.close();
|
|
2016
|
+
});
|
|
2017
|
+
item.map.renderersStatic.forEach((pool) => {
|
|
2018
|
+
pool.close();
|
|
2019
|
+
});
|
|
2020
|
+
} catch (err) {
|
|
2021
|
+
console.warn(`Failed to clear rendered repo entry "${id}":`, err);
|
|
2022
|
+
} finally {
|
|
2023
|
+
// eslint-disable-next-line security/detect-object-injection -- id is from Object.keys() iteration
|
|
2024
|
+
delete repo[id];
|
|
2025
|
+
}
|
|
2026
|
+
}),
|
|
2027
|
+
);
|
|
1901
2028
|
},
|
|
1902
2029
|
|
|
1903
2030
|
/**
|