@platformatic/runtime 4.0.0-alpha.1 → 4.0.0-alpha.2

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/index.d.ts CHANGED
@@ -7,10 +7,15 @@ import { JSONSchemaType } from 'ajv'
7
7
  import * as colorette from 'colorette'
8
8
  import { EventEmitter } from 'node:events'
9
9
  import { Logger } from 'pino'
10
- import { PlatformaticRuntimeConfig } from './config.js'
10
+ import { MetricsOptions, PlatformaticRuntimeConfig } from './config.js'
11
11
 
12
12
  export type RuntimeConfiguration = Configuration<PlatformaticRuntimeConfig>
13
13
 
14
+ export interface MetricsConfigUpdateResult {
15
+ success: boolean
16
+ config: MetricsOptions
17
+ }
18
+
14
19
  export type ApplicationCommandContext = {
15
20
  colorette: typeof colorette
16
21
  parseArgs: typeof parseArgs
@@ -40,6 +45,8 @@ export namespace errors {
40
45
  export const AddressInUseError: (port: number, firstApplication: string, secondApplication: string) => FastifyError
41
46
  export const WorkerAddressInUseError: (port: number, application: string) => FastifyError
42
47
  export const RuntimeExitedError: () => FastifyError
48
+ export const MetricsDisabledError: () => FastifyError
49
+ export const FailedToUpdateMetricsConfigError: (worker: string, reason: string) => FastifyError
43
50
  export const UnknownRuntimeAPICommandError: (command: string) => FastifyError
44
51
  export const ApplicationNotFoundError: (id: string) => FastifyError
45
52
  export const ApplicationNotStartedError: (id: string) => FastifyError
@@ -419,6 +426,7 @@ export declare class Runtime extends EventEmitter {
419
426
  getApplicationOpenapiSchema (id: string): Promise<unknown>
420
427
  getApplicationGraphqlSchema (id: string): Promise<unknown>
421
428
  getMetrics (format?: string): Promise<{ metrics: unknown }>
429
+ updateMetricsConfig (metricsConfig: Partial<MetricsOptions>): Promise<MetricsConfigUpdateResult>
422
430
  /**
423
431
  * Returns the current shared context. Synchronous on the main thread.
424
432
  * Do not mutate it in place — use `updateSharedContext()` so changes are
package/lib/errors.js CHANGED
@@ -81,6 +81,11 @@ export const FailedToRetrieveMetricsError = createError(
81
81
  `${ERROR_PREFIX}_FAILED_TO_RETRIEVE_METRICS`,
82
82
  'Failed to retrieve metrics for application with id "%s": %s'
83
83
  )
84
+ export const FailedToUpdateMetricsConfigError = createError(
85
+ `${ERROR_PREFIX}_FAILED_TO_UPDATE_METRICS_CONFIG`,
86
+ 'Failed to update metrics configuration on worker "%s": %s'
87
+ )
88
+ export const MetricsDisabledError = createError(`${ERROR_PREFIX}_METRICS_DISABLED`, 'Metrics are disabled.', 501)
84
89
  export const FailedToRetrieveHealthError = createError(
85
90
  `${ERROR_PREFIX}_FAILED_TO_RETRIEVE_HEALTH`,
86
91
  'Failed to retrieve health for application with id "%s": %s'
@@ -3,11 +3,13 @@ import fastifyWebsocket from '@fastify/websocket'
3
3
  import {
4
4
  applications as applicationSchema,
5
5
  createDirectory,
6
+ createValidator,
6
7
  createSharedTemporaryDirectory,
7
8
  kMetadata,
8
9
  safeRemove,
9
10
  validate
10
11
  } from '@platformatic/foundation'
12
+ import { schemaComponents } from '@platformatic/foundation/schema'
11
13
  import fastify from 'fastify'
12
14
  import { chmod } from 'node:fs/promises'
13
15
  import { platform, tmpdir } from 'node:os'
@@ -16,6 +18,7 @@ import { setTimeout as sleep } from 'node:timers/promises'
16
18
  import { createWebSocketStream } from 'ws'
17
19
  import { prepareAddedApplications } from './config.js'
18
20
  import { readMemory } from './memory.js'
21
+ import { MetricsDisabledError } from './errors.js'
19
22
 
20
23
  const PLATFORMATIC_TMP_DIR = join(tmpdir(), 'platformatic', 'runtimes')
21
24
 
@@ -303,19 +306,31 @@ export async function managementApiPlugin (app, opts) {
303
306
  return reply
304
307
  })
305
308
 
309
+ const metricsSchema = schemaComponents.runtimeProperties.metrics.anyOf[1]
310
+ app.patch('/metrics', {
311
+ schema: {
312
+ body: {
313
+ ...metricsSchema,
314
+ minProperties: 1,
315
+ properties: { ...metricsSchema.properties, enabled: { type: 'boolean' } }
316
+ }
317
+ },
318
+ // A patch must not inject defaults, coerce values, or silently discard unknown options.
319
+ validatorCompiler: ({ schema }) => createValidator(schema, {
320
+ useDefaults: false,
321
+ coerceTypes: false,
322
+ removeAdditional: false
323
+ }, { root: runtime.getRuntimeConfig(true)[kMetadata].root })
324
+ }, async req => runtime.updateMetricsConfig(req.body))
325
+
306
326
  app.get('/metrics', quiet, async (req, reply) => {
307
327
  // The live read: this needs one boolean, and the public getter builds a frozen snapshot of the
308
328
  // whole configuration -- which is the right shape to hand a consumer and the wrong one to pay
309
329
  // for on a scrape.
310
330
  const config = runtime.getRuntimeConfig(true)
311
331
 
312
- if (config.metrics?.enabled === false) {
313
- reply.code(501)
314
- return {
315
- statusCode: 501,
316
- error: 'Not Implemented',
317
- message: 'Metrics are disabled.'
318
- }
332
+ if (config.metrics === false || config.metrics?.enabled === false) {
333
+ throw new MetricsDisabledError()
319
334
  }
320
335
 
321
336
  const accepts = req.accepts()
package/lib/runtime.js CHANGED
@@ -13,11 +13,13 @@ import {
13
13
  import { getExecutable } from '@platformatic/globals'
14
14
  import { ITC } from '@platformatic/itc'
15
15
  import {
16
+ clearRegistry,
16
17
  collectProcessMetrics,
17
18
  client as metricsClient,
18
19
  openTelemetryITCMessage
19
20
  } from '@platformatic/metrics'
20
21
  import fastify from 'fastify'
22
+ import { randomUUID } from 'node:crypto'
21
23
  import { EventEmitter, once } from 'node:events'
22
24
  import { existsSync } from 'node:fs'
23
25
  import { readFile } from 'node:fs/promises'
@@ -25,11 +27,11 @@ import { STATUS_CODES } from 'node:http'
25
27
  import { createRequire } from 'node:module'
26
28
  import { availableParallelism } from 'node:os'
27
29
  import { basename, dirname, isAbsolute, join } from 'node:path'
28
- import { randomUUID } from 'node:crypto'
29
30
  import { Readable } from 'node:stream'
30
31
  import { finished } from 'node:stream/promises'
31
32
  import { setImmediate as immediate, setTimeout as sleep } from 'node:timers/promises'
32
33
  import { pathToFileURL } from 'node:url'
34
+ import { isDeepStrictEqual } from 'node:util'
33
35
  import { Worker } from 'node:worker_threads'
34
36
  import SonicBoom from 'sonic-boom'
35
37
  import { Agent, request, interceptors as undiciInterceptors } from 'undici'
@@ -37,7 +39,6 @@ import { createCoordinator, createInterceptor } from 'undici-thread-interceptor'
37
39
  import { pprofCapturePreloadPath } from './config.js'
38
40
  import {
39
41
  AddressInUseError,
40
- WorkerAddressInUseError,
41
42
  ApplicationAlreadyStartedError,
42
43
  ApplicationNotFoundError,
43
44
  ApplicationNotStartedError,
@@ -48,16 +49,19 @@ import {
48
49
  FailedToLoadExtensionError,
49
50
  FailedToStartExtensionError,
50
51
  FailedToStopExtensionError,
52
+ FailedToUpdateMetricsConfigError,
51
53
  InvalidArgumentError,
52
54
  InvalidExtensionError,
53
55
  LastProfileTimeoutError,
54
56
  MessagingError,
55
57
  MetricFamilyCollisionError,
58
+ MetricsDisabledError,
56
59
  MissingPprofCapture,
57
60
  MixedServingStateError,
58
61
  ReservedITCHandlerNameError,
59
62
  RuntimeAbortedError,
60
63
  RuntimeExtensionBuildAlreadyCalledError,
64
+ WorkerAddressInUseError,
61
65
  WorkerNotFoundError
62
66
  } from './errors.js'
63
67
  import { abstractLogger, createLogger } from './logger.js'
@@ -65,12 +69,12 @@ import { startManagementApi } from './management-api.js'
65
69
  import { createManagementHandlers } from './management-handlers.js'
66
70
  import { OpenTelemetryMetricsForwarder } from './opentelemetry-metrics.js'
67
71
  import { createChannelCreationHook, createTargetPermissionHook } from './policies.js'
72
+ import { PredictiveWorkersScaler } from './predictive-worker-scaler.js'
68
73
  import { startHealthProbesServer, startPrometheusServer } from './prom-server.js'
69
74
  import { startScheduler } from './scheduler.js'
70
75
  import { createSharedStore } from './shared-http-cache.js'
71
76
  import { topologicalLevels, topologicalSort } from './utils.js'
72
77
  import { version } from './version.js'
73
- import { PredictiveWorkersScaler } from './predictive-worker-scaler.js'
74
78
  import { HealthSignalsQueue } from './worker/health-signals.js'
75
79
  import { sendMultipleViaITC, sendViaITC, waitEventFromITC } from './worker/itc.js'
76
80
  import { RoundRobinMap } from './worker/round-robin-map.js'
@@ -81,20 +85,20 @@ import {
81
85
  kHealthCheckTimer,
82
86
  kId,
83
87
  kIsSubprocessHost,
84
- kSubprocessPid,
85
88
  kITC,
86
89
  kLastHealthCheckELU,
87
90
  kStderrMarker,
91
+ kSubprocessPid,
88
92
  kWorkerExitCode,
89
93
  kWorkerHealthSignals,
90
94
  kWorkerId,
91
95
  kWorkerPortOffset,
92
- kWorkerServerOptions,
93
- kWorkerUrl,
94
96
  kWorkersBroadcast,
97
+ kWorkerServerOptions,
95
98
  kWorkerStartAborted,
96
99
  kWorkerStartTime,
97
- kWorkerStatus
100
+ kWorkerStatus,
101
+ kWorkerUrl
98
102
  } from './worker/symbols.js'
99
103
 
100
104
  const kWorkerFile = join(import.meta.dirname, 'worker/main.js')
@@ -268,6 +272,8 @@ export class Runtime extends EventEmitter {
268
272
  #healthProbesServer
269
273
  #metricsServersInitialized
270
274
  #metricsServersStartPromise
275
+ #metricsConfigUpdatePromise
276
+ #metricsConfigUpdateFailed
271
277
  #opentelemetryMetricsForwarder
272
278
  #inspectorServer
273
279
  #metricsLabelName
@@ -353,6 +359,9 @@ export class Runtime extends EventEmitter {
353
359
  this.#extensions = []
354
360
  this.#metricsServersInitialized = false
355
361
  this.#metricsServersStartPromise = null
362
+ this.#metricsConfigUpdatePromise = Promise.resolve()
363
+ this.#metricsConfigUpdateFailed = false
364
+ this.#processMetricsRegistry = null
356
365
  this.#extensionsWantHealthMetrics = false
357
366
  this.#extensionReadinessChecks = new Map()
358
367
  this.#extensionLivenessChecks = new Map()
@@ -406,7 +415,9 @@ export class Runtime extends EventEmitter {
406
415
  this.#managementApi = await startManagementApi(this, config.managementApi)
407
416
  }
408
417
 
409
- await this.#startOpenTelemetryMetricsForwarder(config.metrics?.opentelemetry)
418
+ if (config.metrics && config.metrics.enabled !== false) {
419
+ await this.#startOpenTelemetryMetricsForwarder(config.metrics.opentelemetry)
420
+ }
410
421
 
411
422
  this.#createWorkersBroadcastChannel()
412
423
 
@@ -611,7 +622,7 @@ export class Runtime extends EventEmitter {
611
622
 
612
623
  // Clean up process metrics registry
613
624
  if (this.#processMetricsRegistry) {
614
- this.#processMetricsRegistry.clear()
625
+ clearRegistry(this.#processMetricsRegistry)
615
626
  this.#processMetricsRegistry = null
616
627
  }
617
628
 
@@ -1406,7 +1417,8 @@ export class Runtime extends EventEmitter {
1406
1417
  }
1407
1418
 
1408
1419
  /**
1409
- * Updates the metrics configuration at runtime without restarting the runtime or workers.
1420
+ * Merges top-level metrics options without restarting the runtime or workers.
1421
+ * Nested objects are replaced when supplied. Identical successful updates are no-ops.
1410
1422
  *
1411
1423
  * This method allows you to:
1412
1424
  * - Enable or disable metrics collection
@@ -1425,51 +1437,103 @@ export class Runtime extends EventEmitter {
1425
1437
  * await runtime.updateMetricsConfig({ enabled: false })
1426
1438
  */
1427
1439
  async updateMetricsConfig (metricsConfig) {
1428
- if (this.#prometheusServer) {
1429
- await this.#prometheusServer.close()
1430
- this.#prometheusServer = null
1431
- }
1440
+ // Serialize changes because they share listeners and worker registries.
1441
+ const previousUpdate = this.#metricsConfigUpdatePromise
1442
+ const { promise, resolve } = Promise.withResolvers()
1443
+ this.#metricsConfigUpdatePromise = promise
1444
+ await previousUpdate
1432
1445
 
1433
- if (this.#healthProbesServer) {
1434
- await this.#healthProbesServer.close()
1435
- this.#healthProbesServer = null
1436
- }
1446
+ try {
1447
+ const currentConfig = this.#config.metrics
1448
+ const previousConfig = typeof currentConfig === 'boolean' ? { enabled: currentConfig } : (currentConfig ?? {})
1449
+ metricsConfig = { ...previousConfig, ...metricsConfig }
1437
1450
 
1438
- this.#metricsServersInitialized = false
1451
+ // An omitted enabled option already means enabled for an object configuration.
1452
+ const previousOptions = { ...previousConfig, enabled: previousConfig.enabled !== false }
1453
+ const nextOptions = { ...metricsConfig, enabled: metricsConfig.enabled !== false }
1454
+ if (!this.#metricsConfigUpdateFailed && isDeepStrictEqual(previousOptions, nextOptions)) {
1455
+ this.#config.metrics = metricsConfig
1456
+ return { success: true, config: structuredClone(metricsConfig) }
1457
+ }
1439
1458
 
1440
- if (this.#opentelemetryMetricsForwarder) {
1441
- await this.#opentelemetryMetricsForwarder.close()
1442
- this.#opentelemetryMetricsForwarder = null
1443
- }
1459
+ // Failed updates can be retried even when the main-thread configuration already changed.
1460
+ this.#metricsConfigUpdateFailed = true
1444
1461
 
1445
- this.#config.metrics = metricsConfig
1446
- this.#metricsLabelName = metricsConfig?.applicationLabel || 'applicationId'
1462
+ if (this.#metricsServersStartPromise) {
1463
+ await this.#metricsServersStartPromise
1464
+ }
1447
1465
 
1448
- // Allow extension health routes to be re-applied on the restarted servers.
1449
- for (const entry of this.#extensionHealthRoutes) {
1450
- entry.applied = false
1451
- }
1466
+ if (this.#prometheusServer) {
1467
+ await this.#prometheusServer.close()
1468
+ this.#prometheusServer = null
1469
+ }
1452
1470
 
1453
- await this.#ensureMetricsServersStarted()
1471
+ if (this.#healthProbesServer) {
1472
+ await this.#healthProbesServer.close()
1473
+ this.#healthProbesServer = null
1474
+ }
1454
1475
 
1455
- await this.#startOpenTelemetryMetricsForwarder(metricsConfig?.opentelemetry)
1476
+ this.#metricsServersInitialized = false
1456
1477
 
1457
- const promises = []
1458
- for (const worker of this.#workers.values()) {
1459
- if (worker[kWorkerStatus] === 'started') {
1460
- promises.push(sendViaITC(worker, 'updateMetricsConfig', metricsConfig))
1478
+ if (this.#opentelemetryMetricsForwarder) {
1479
+ await this.#opentelemetryMetricsForwarder.close()
1480
+ this.#opentelemetryMetricsForwarder = null
1461
1481
  }
1462
- }
1463
1482
 
1464
- const results = await Promise.allSettled(promises)
1465
- for (const result of results) {
1466
- if (result.status === 'rejected') {
1467
- this.logger.error({ err: result.reason }, 'Cannot update metrics config on worker')
1483
+ this.#config.metrics = metricsConfig
1484
+ this.#metricsLabelName = metricsConfig?.applicationLabel || 'applicationId'
1485
+
1486
+ if (metricsConfig.enabled === false) {
1487
+ if (this.#processMetricsRegistry) {
1488
+ clearRegistry(this.#processMetricsRegistry)
1489
+ this.#processMetricsRegistry = null
1490
+ }
1491
+ } else if (!this.#processMetricsRegistry) {
1492
+ this.#processMetricsRegistry = new metricsClient.Registry()
1493
+ collectProcessMetrics(this.#processMetricsRegistry)
1468
1494
  }
1469
- }
1470
1495
 
1471
- this.logger.info({ metricsConfig }, 'Metrics configuration updated')
1472
- return { success: true, config: metricsConfig }
1496
+ // Allow extension health routes to be re-applied on the restarted servers.
1497
+ for (const entry of this.#extensionHealthRoutes) {
1498
+ entry.applied = false
1499
+ }
1500
+
1501
+ await this.#ensureMetricsServersStarted()
1502
+
1503
+ if (metricsConfig.enabled !== false) {
1504
+ await this.#startOpenTelemetryMetricsForwarder(metricsConfig.opentelemetry)
1505
+ }
1506
+
1507
+ const promises = []
1508
+ for (const worker of this.#workers.values()) {
1509
+ if (worker[kWorkerStatus] === 'started') {
1510
+ promises.push(
1511
+ sendViaITC(worker, 'updateMetricsConfig', metricsConfig).catch(err => {
1512
+ throw new FailedToUpdateMetricsConfigError(worker[kFullId], err.message)
1513
+ })
1514
+ )
1515
+ }
1516
+ }
1517
+
1518
+ const results = await Promise.allSettled(promises)
1519
+ for (const result of results) {
1520
+ if (result.status === 'rejected') {
1521
+ this.logger.error({ err: result.reason }, 'Cannot update metrics config on worker')
1522
+ }
1523
+ }
1524
+
1525
+ const failure = results.find(result => result.status === 'rejected')
1526
+ if (failure) {
1527
+ throw failure.reason
1528
+ }
1529
+
1530
+ this.#metricsConfigUpdateFailed = false
1531
+ this.logger.info('Metrics configuration updated')
1532
+ return { success: true, config: structuredClone(metricsConfig) }
1533
+ } finally {
1534
+ // Failed and no-op updates must also release the next queued request.
1535
+ resolve()
1536
+ }
1473
1537
  }
1474
1538
 
1475
1539
  async #ensureMetricsServersStarted () {
@@ -1895,7 +1959,7 @@ export class Runtime extends EventEmitter {
1895
1959
 
1896
1960
  async getMetrics (format = 'json') {
1897
1961
  if (this.#config.metrics === false || this.#config.metrics?.enabled === false) {
1898
- throw new Error('Metrics are disabled')
1962
+ throw new MetricsDisabledError()
1899
1963
  }
1900
1964
 
1901
1965
  let metrics = null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "4.0.0-alpha.1",
3
+ "version": "4.0.0-alpha.2",
4
4
  "description": "Platformatic runtime",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -48,13 +48,13 @@
48
48
  "typescript": "~6.0.3",
49
49
  "undici-oidc-interceptor": "^0.9.0",
50
50
  "why-is-node-running": "^2.2.2",
51
- "@platformatic/db": "4.0.0-alpha.1",
52
- "@platformatic/gateway": "4.0.0-alpha.1",
53
- "@platformatic/node": "4.0.0-alpha.1",
54
- "@platformatic/service": "4.0.0-alpha.1",
55
- "@platformatic/sql-graphql": "4.0.0-alpha.1",
56
- "@platformatic/wattpm-pprof-capture": "4.0.0-alpha.1",
57
- "@platformatic/sql-mapper": "4.0.0-alpha.1"
51
+ "@platformatic/node": "4.0.0-alpha.2",
52
+ "@platformatic/service": "4.0.0-alpha.2",
53
+ "@platformatic/sql-graphql": "4.0.0-alpha.2",
54
+ "@platformatic/db": "4.0.0-alpha.2",
55
+ "@platformatic/gateway": "4.0.0-alpha.2",
56
+ "@platformatic/sql-mapper": "4.0.0-alpha.2",
57
+ "@platformatic/wattpm-pprof-capture": "4.0.0-alpha.2"
58
58
  },
59
59
  "dependencies": {
60
60
  "@fastify/accepts": "^5.0.0",
@@ -86,13 +86,13 @@
86
86
  "undici": "^8.5.0",
87
87
  "undici-thread-interceptor": "^2.2.0",
88
88
  "ws": "^8.16.0",
89
- "@platformatic/basic": "4.0.0-alpha.1",
90
- "@platformatic/foundation": "4.0.0-alpha.1",
91
- "@platformatic/generators": "4.0.0-alpha.1",
92
- "@platformatic/itc": "4.0.0-alpha.1",
93
- "@platformatic/metrics": "4.0.0-alpha.1",
94
- "@platformatic/globals": "4.0.0-alpha.1",
95
- "@platformatic/tracing": "4.0.0-alpha.1"
89
+ "@platformatic/basic": "4.0.0-alpha.2",
90
+ "@platformatic/foundation": "4.0.0-alpha.2",
91
+ "@platformatic/globals": "4.0.0-alpha.2",
92
+ "@platformatic/itc": "4.0.0-alpha.2",
93
+ "@platformatic/generators": "4.0.0-alpha.2",
94
+ "@platformatic/metrics": "4.0.0-alpha.2",
95
+ "@platformatic/tracing": "4.0.0-alpha.2"
96
96
  },
97
97
  "engines": {
98
98
  "node": ">=24.20.0"
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/runtime/4.0.0-alpha.1.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/4.0.0-alpha.2.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",