@platformatic/watt-extra 1.10.1-alpha.2 → 1.11.0-alpha.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.
@@ -1,22 +1,11 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [
4
- "Read(//work/workspaces/workspace-platformatic/platformatic/**)",
5
- "Bash(npx borp:*)",
6
- "Bash(timeout 30 npx borp -c 1 --timeout=20000 ./test/trigger-flamegraphs.test.js)",
7
- "Bash(xargs cat:*)",
8
- "Bash(pnpm install)",
9
- "Bash(find:*)",
10
- "Bash(cat:*)",
11
- "WebFetch(domain:github.com)",
4
+ "Bash(node --test-only:*)",
12
5
  "Bash(node --test:*)",
13
- "Bash(for i in 1 2 3)",
14
- "Bash(do echo \"Run $i:\")",
15
- "Bash(done)",
16
- "Bash(git stash:*)",
17
- "Bash(echo:*)",
18
- "Bash(grep:*)",
19
- "Bash(gh api:*)"
6
+ "Bash(for i in {1..3})",
7
+ "Bash(do echo \"=== Run $i ===\")",
8
+ "Bash(done)"
20
9
  ],
21
10
  "deny": [],
22
11
  "ask": []
package/lib/watt.js CHANGED
@@ -513,8 +513,7 @@ class Watt {
513
513
  const config = runtime.getRuntimeConfig(true)
514
514
 
515
515
  for (const app of config.applications ?? []) {
516
- this.#logger.info({ name: app.id, type: app.type }, `Configuring service ${app.id} of type ${app.type}`)
517
- if (app.type === '@platformatic/next') {
516
+ if (app.type === 'next') {
518
517
  await this.#configureNextService(runtime, app)
519
518
  } else if (
520
519
  [
@@ -531,8 +530,6 @@ class Watt {
531
530
  async #configureNextService (runtime, service) {
532
531
  let nextSchema
533
532
 
534
- this.#logger.info(`Configuring next service: ${service}`)
535
-
536
533
  try {
537
534
  const nextPackage = createRequire(
538
535
  resolve(service.path, 'index.js')
@@ -550,13 +547,9 @@ class Watt {
550
547
 
551
548
  const patches = []
552
549
 
553
- const isCacheIsSchema = 'cache' in nextSchema.properties
554
- this.#logger.info(`'cache' in nextSchema.properties: ${isCacheIsSchema}`)
555
-
556
- if (isCacheIsSchema) {
550
+ if ('cache' in nextSchema.properties) {
557
551
  const httpCache = this.#instanceConfig?.httpCache?.clientOpts || {}
558
552
  const { keyPrefix, host, port, username, password } = httpCache
559
- this.#logger.info({ keyPrefix, host, port }, `Configuring cache for next service ${service.id}`)
560
553
 
561
554
  if (!keyPrefix || !host || !port) {
562
555
  this.#logger.warn(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/watt-extra",
3
- "version": "1.10.1-alpha.2",
3
+ "version": "1.11.0-alpha.0",
4
4
  "description": "The Platformatic runtime manager",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -24,7 +24,7 @@
24
24
  "@platformatic/node": "^3.25.0",
25
25
  "@platformatic/service": "^3.25.0",
26
26
  "atomic-sleep": "^1.0.0",
27
- "borp": "^0.21.0",
27
+ "borp": "^1.0.0",
28
28
  "eslint": "9",
29
29
  "fastify": "^5.4.0",
30
30
  "fastify-plugin": "^5.0.1",
package/plugins/env.js CHANGED
@@ -26,8 +26,10 @@ const schema = {
26
26
  PLT_JWT_EXPIRATION_OFFSET_SEC: { type: 'number', default: 60 },
27
27
  PLT_UPDATES_RECONNECT_INTERVAL_SEC: { type: 'number', default: 1 },
28
28
  PLT_ELU_HEALTH_SIGNAL_THRESHOLD: { type: 'number', default: 0.8 },
29
- PLT_HEAP_HEALTH_SIGNAL_THRESHOLD: { type: ['number', 'string'], default: '4GB' },
30
- PLT_ALERTS_GRACE_PERIOD_SEC: { type: 'number', default: 30 }
29
+ PLT_HEAP_HEALTH_SIGNAL_THRESHOLD: { type: ['number', 'string'], default: '200MB' },
30
+ PLT_ALERTS_GRACE_PERIOD_SEC: { type: 'number', default: 30 },
31
+ PLT_HEALTH_SIGNALS_SHORT_BATCH_TIMEOUT: { type: 'number', default: 5000 },
32
+ PLT_HEALTH_SIGNALS_LONG_BATCH_TIMEOUT: { type: 'number', default: 30000 }
31
33
  }
32
34
  }
33
35
 
@@ -1,42 +1,60 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import { request } from 'undici'
2
3
  import semver from 'semver'
3
4
  import { parseMemorySize } from '@platformatic/foundation'
4
5
 
5
6
  class HealthSignalsCache {
6
- #signals = []
7
- #size = 100
7
+ #signalsByService = {}
8
+ #maxSize = 500
8
9
 
9
- constructor () {
10
- this.#signals = []
11
- }
12
-
13
- add (signals) {
10
+ addServiceSignals (serviceId, signals) {
14
11
  for (const signal of signals) {
15
- this.#signals.push(signal)
12
+ this.addServiceSignal(serviceId, signal.type, signal)
16
13
  }
17
- if (this.#signals.length > this.#size) {
18
- this.#signals.splice(0, this.#signals.length - this.#size)
14
+ }
15
+
16
+ addServiceSignal (serviceId, signalType, signal) {
17
+ this.#signalsByService[serviceId] ??= {}
18
+ this.#signalsByService[serviceId][signalType] ??= []
19
+
20
+ const signals = this.#signalsByService[serviceId][signalType]
21
+ signals.push(signal)
22
+
23
+ if (signals.length > this.#maxSize) {
24
+ signals.splice(0, signals.length - this.#maxSize)
19
25
  }
20
26
  }
21
27
 
22
- getAll () {
23
- const values = this.#signals
24
- this.#signals = []
25
- return values
28
+ getAllSignals () {
29
+ const signalsByService = this.#signalsByService
30
+ this.#signalsByService = {}
31
+ return signalsByService
26
32
  }
27
33
  }
28
34
 
29
35
  async function healthSignals (app, _opts) {
30
- const signalsCaches = {}
31
- const servicesSendingStatuses = {}
36
+ app.getRuntimeId = () => {
37
+ if (!app.runtimeId) {
38
+ app.runtimeId = randomUUID()
39
+ process._rawDebug(`[health-signals] Generated new runtimeId: ${app.runtimeId}`)
40
+ }
41
+ return app.runtimeId
42
+ }
43
+
44
+ const signalsCache = new HealthSignalsCache()
45
+ const heapTotalByService = {}
32
46
 
33
47
  // TODO: needed to the UI compatibility
34
48
  // remove after depricating the Scaler v1 UI
35
- const servicesMetrics = {}
49
+ let servicesMetrics = {}
36
50
 
37
51
  // Store listener reference for cleanup
38
52
  let healthMetricsListener = null
39
53
 
54
+ // Store thresholds for use in sendHealthSignals
55
+ let eluThreshold = null
56
+ let heapThresholdMb = null
57
+
40
58
  async function setupHealthSignals () {
41
59
  const scalerAlgorithmVersion = app.instanceConfig?.scaler?.version ?? 'v1'
42
60
  if (scalerAlgorithmVersion !== 'v2') {
@@ -54,13 +72,6 @@ async function healthSignals (app, _opts) {
54
72
  return
55
73
  }
56
74
 
57
- const eluThreshold = app.env.PLT_ELU_HEALTH_SIGNAL_THRESHOLD
58
-
59
- let heapThreshold = app.env.PLT_HEAP_HEALTH_SIGNAL_THRESHOLD
60
- if (typeof heapThreshold === 'string') {
61
- heapThreshold = parseMemorySize(heapThreshold)
62
- }
63
-
64
75
  // Skip alerts setup if ICC is not configured
65
76
  if (!app.env.PLT_ICC_URL) {
66
77
  app.log.info('PLT_ICC_URL not set, skipping alerts setup')
@@ -68,8 +79,6 @@ async function healthSignals (app, _opts) {
68
79
  }
69
80
 
70
81
  const scalerUrl = app.instanceConfig?.iccServices?.scaler?.url
71
- const runtime = app.watt.runtime
72
-
73
82
  if (!scalerUrl) {
74
83
  app.log.warn(
75
84
  'No scaler URL found in ICC services, health alerts disabled'
@@ -77,6 +86,51 @@ async function healthSignals (app, _opts) {
77
86
  return
78
87
  }
79
88
 
89
+ const runtime = app.watt.runtime
90
+ const batchShortTimeout = app.env.PLT_HEALTH_SIGNALS_SHORT_BATCH_TIMEOUT
91
+ const batchLongTimeout = app.env.PLT_HEALTH_SIGNALS_LONG_BATCH_TIMEOUT
92
+ eluThreshold = app.env.PLT_ELU_HEALTH_SIGNAL_THRESHOLD
93
+
94
+ // TODO: get the used heap and use the 0.8 by default as a threshold
95
+ let heapThreshold = app.env.PLT_HEAP_HEALTH_SIGNAL_THRESHOLD
96
+ if (typeof heapThreshold === 'string') {
97
+ heapThreshold = parseMemorySize(heapThreshold)
98
+ }
99
+ heapThresholdMb = Math.round(heapThreshold / 1024 / 1024)
100
+
101
+ process._rawDebug(`[health-signals] Config: eluThreshold=${eluThreshold}, heapThresholdMb=${heapThresholdMb}, batchShortTimeout=${batchShortTimeout}ms, batchLongTimeout=${batchLongTimeout}ms`)
102
+
103
+ let batchHasHighValue = false
104
+ let batchStartedAt = null
105
+ setInterval(() => {
106
+ const now = Date.now()
107
+ const batchTimeout = batchHasHighValue
108
+ ? batchShortTimeout
109
+ : batchLongTimeout
110
+
111
+ if (batchStartedAt !== null) {
112
+ const elapsed = now - batchStartedAt
113
+ process._rawDebug(`[health-signals] Batch check: elapsed=${elapsed}ms, timeout=${batchTimeout}ms, hasHighValue=${batchHasHighValue}`)
114
+ }
115
+
116
+ if (now - batchStartedAt >= batchTimeout) {
117
+ process._rawDebug(`[health-signals] Batch timeout reached, sending signals. hasHighValue=${batchHasHighValue}`)
118
+ batchHasHighValue = false
119
+ batchStartedAt = null
120
+
121
+ const signals = signalsCache.getAllSignals()
122
+ const metrics = servicesMetrics
123
+ servicesMetrics = {}
124
+
125
+ const serviceIds = Object.keys(signals)
126
+ process._rawDebug(`[health-signals] Sending signals for services: ${serviceIds.join(', ') || '(none)'}`)
127
+
128
+ sendHealthSignals(signals, metrics).catch(err => {
129
+ app.log.error({ err }, 'Failed to send health signals to scaler')
130
+ })
131
+ }
132
+ }, 1000).unref()
133
+
80
134
  // Remove old listener if it exists (for ICC recovery scenario)
81
135
  if (healthMetricsListener) {
82
136
  runtime.removeListener('application:worker:health:metrics', healthMetricsListener)
@@ -95,30 +149,32 @@ async function healthSignals (app, _opts) {
95
149
  } = healthInfo
96
150
 
97
151
  const { elu, heapUsed, heapTotal } = currentHealth
152
+ const heapUsedMb = Math.round(heapUsed / 1024 / 1024)
153
+ const now = Date.now()
98
154
 
99
- if (elu > eluThreshold) {
100
- healthSignals.push({
101
- type: 'elu',
102
- value: currentHealth.elu,
103
- description:
104
- `The ${serviceId} has an ELU of ${(elu * 100).toFixed(2)} %, ` +
105
- `above the maximum allowed usage of ${(eluThreshold * 100).toFixed(2)} %`,
106
- timestamp: Date.now()
107
- })
108
- }
155
+ const eluExceedsThreshold = elu > eluThreshold
156
+ const heapExceedsThreshold = heapUsedMb > heapThresholdMb
157
+ process._rawDebug(`[health-signals] Received metrics: service=${serviceId}, worker=${workerId}, elu=${(elu * 100).toFixed(2)}% (threshold=${(eluThreshold * 100).toFixed(2)}%, exceeds=${eluExceedsThreshold}), heap=${heapUsedMb}MB (threshold=${heapThresholdMb}MB, exceeds=${heapExceedsThreshold}), customSignals=${healthSignals.length}`)
109
158
 
110
- if (heapThreshold && heapUsed > heapThreshold) {
111
- const usedHeapMb = Math.round(heapUsed / 1024 / 1024)
112
- const heapThresholdMb = Math.round(heapThreshold / 1024 / 1024)
113
-
114
- healthSignals.push({
115
- type: 'heapUsed',
116
- value: currentHealth.heapUsed,
117
- description:
118
- `The ${serviceId} is using ${usedHeapMb} MB of heap, ` +
119
- `above the maximum allowed usage of ${heapThresholdMb} MB`,
120
- timestamp: Date.now()
121
- })
159
+ signalsCache.addServiceSignal(serviceId, 'elu', {
160
+ workerId,
161
+ value: elu,
162
+ timestamp: now
163
+ })
164
+ signalsCache.addServiceSignal(serviceId, 'heap', {
165
+ workerId,
166
+ value: heapUsedMb,
167
+ timestamp: now
168
+ })
169
+ heapTotalByService[serviceId] = heapTotal
170
+ signalsCache.addServiceSignals(serviceId, healthSignals)
171
+
172
+ batchStartedAt ??= now
173
+ if (eluExceedsThreshold || heapExceedsThreshold) {
174
+ if (!batchHasHighValue) {
175
+ process._rawDebug(`[health-signals] High value detected! Switching to short batch timeout`)
176
+ }
177
+ batchHasHighValue = true
122
178
  }
123
179
 
124
180
  // TODO: needed to the UI compatibility
@@ -132,44 +188,42 @@ async function healthSignals (app, _opts) {
132
188
  metrics.heapUsed = heapUsed
133
189
  metrics.heapTotal = heapTotal
134
190
  }
135
-
136
- if (healthSignals.length > 0) {
137
- await sendHealthSignalsWithTimeout(serviceId, workerId, healthSignals)
138
- }
139
191
  }
140
192
  runtime.on('application:worker:health:metrics', healthMetricsListener)
141
193
  }
142
194
  app.setupHealthSignals = setupHealthSignals
143
195
 
144
- async function sendHealthSignalsWithTimeout (serviceId, workerId, signals) {
145
- signalsCaches[serviceId] ??= new HealthSignalsCache()
146
- servicesSendingStatuses[serviceId] ??= false
147
-
148
- const signalsCache = signalsCaches[serviceId]
149
- signalsCache.add(signals)
150
-
151
- if (!servicesSendingStatuses[serviceId]) {
152
- servicesSendingStatuses[serviceId] = true
153
- setTimeout(async () => {
154
- servicesSendingStatuses[serviceId] = false
155
-
156
- const metrics = servicesMetrics[serviceId]
157
- servicesMetrics[serviceId] = null
196
+ async function sendHealthSignals (rawSignals) {
197
+ const scalerUrl = app.instanceConfig?.iccServices?.scaler?.url
198
+ const applicationId = app.instanceConfig?.applicationId
199
+ const authHeaders = await app.getAuthorizationHeader()
158
200
 
159
- try {
160
- const signals = signalsCache.getAll()
161
- await sendHealthSignals(serviceId, workerId, signals, metrics)
162
- } catch (err) {
163
- app.log.error({ err }, 'Failed to send health signals to scaler')
201
+ // Transform signals to the format expected by ICC LoadPredictor
202
+ const signals = {}
203
+ for (const [serviceId, serviceSignals] of Object.entries(rawSignals)) {
204
+ signals[serviceId] = {
205
+ elu: {
206
+ values: serviceSignals.elu || [],
207
+ options: { threshold: eluThreshold }
208
+ },
209
+ heap: {
210
+ values: serviceSignals.heap || [],
211
+ options: {
212
+ threshold: heapThresholdMb,
213
+ heapTotal: heapTotalByService[serviceId] || 0
214
+ }
164
215
  }
165
- }, 5000).unref()
216
+ }
217
+ process._rawDebug(`[health-signals] Prepared signals for ${serviceId}: eluValues=${signals[serviceId].elu.values.length}, heapValues=${signals[serviceId].heap.values.length}, heapTotal=${signals[serviceId].heap.options.heapTotal}`)
166
218
  }
167
- }
168
219
 
169
- async function sendHealthSignals (serviceId, workerId, signals, metrics) {
170
- const scalerUrl = app.instanceConfig?.iccServices?.scaler?.url
171
- const applicationId = app.instanceConfig?.applicationId
172
- const authHeaders = await app.getAuthorizationHeader()
220
+ if (Object.keys(signals).length === 0) {
221
+ process._rawDebug(`[health-signals] No signals to send, skipping request`)
222
+ return
223
+ }
224
+
225
+ const runtimeId = app.getRuntimeId()
226
+ process._rawDebug(`[health-signals] Sending to ${scalerUrl}/signals: applicationId=${applicationId}, runtimeId=${runtimeId}`)
173
227
 
174
228
  const { statusCode, body } = await request(`${scalerUrl}/signals`, {
175
229
  method: 'POST',
@@ -177,30 +231,43 @@ async function healthSignals (app, _opts) {
177
231
  'Content-Type': 'application/json',
178
232
  ...authHeaders
179
233
  },
180
- body: JSON.stringify({
181
- applicationId,
182
- serviceId,
183
- signals,
184
- elu: metrics.elu,
185
- heapUsed: metrics.heapUsed,
186
- heapTotal: metrics.heapTotal
187
- })
234
+ body: JSON.stringify({ applicationId, runtimeId, signals })
188
235
  })
189
236
 
237
+ process._rawDebug(`[health-signals] Scaler response: statusCode=${statusCode}`)
238
+
190
239
  if (statusCode !== 200) {
191
240
  const error = await body.text()
241
+ process._rawDebug(`[health-signals] Scaler error response: ${error}`)
192
242
  app.log.error({ error }, 'Failed to send health signals to scaler')
243
+ return
193
244
  }
194
245
 
195
- const alert = await body.json()
246
+ const { alerts = [] } = await body.json()
247
+ process._rawDebug(`[health-signals] Received ${alerts.length} alerts from scaler`)
196
248
 
197
- app.sendFlamegraphs({
198
- serviceIds: [serviceId],
199
- workerIds: [workerId],
200
- alertId: alert.id
201
- }).catch(err => {
202
- app.log.error({ err }, 'Failed to send a flamegraph')
203
- })
249
+ const promises = []
250
+
251
+ for (const alert of alerts) {
252
+ const { serviceId, workerId, alertId } = alert
253
+ process._rawDebug(`[health-signals] Processing alert: alertId=${alertId}, serviceId=${serviceId}, workerId=${workerId}`)
254
+ const promise = app.sendFlamegraphs({
255
+ serviceIds: [serviceId],
256
+ workerIds: [workerId],
257
+ alertId
258
+ })
259
+ promises.push(promise)
260
+ }
261
+ const results = await Promise.allSettled(promises)
262
+
263
+ for (const result of results) {
264
+ if (result.status === 'rejected') {
265
+ process._rawDebug(`[health-signals] Flamegraph send failed: ${result.reason}`)
266
+ app.log.error({ err: result.reason }, 'Failed to send a flamegraph')
267
+ } else {
268
+ process._rawDebug(`[health-signals] Flamegraph sent successfully`)
269
+ }
270
+ }
204
271
  }
205
272
  }
206
273
 
package/plugins/update.js CHANGED
@@ -2,11 +2,14 @@ import WebSocket from 'ws'
2
2
  import { once } from 'node:events'
3
3
  import { setTimeout as sleep } from 'node:timers/promises'
4
4
 
5
- function createWebSocketUrl (httpUrl, path) {
5
+ function createWebSocketUrl (httpUrl, path, queryParams = {}) {
6
6
  const url = new URL(httpUrl)
7
7
  url.protocol = url.protocol.replace('http', 'ws')
8
8
  const basePath = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`
9
9
  url.pathname = `${basePath}${path}`
10
+ for (const [key, value] of Object.entries(queryParams)) {
11
+ url.searchParams.set(key, value)
12
+ }
10
13
  return url.toString()
11
14
  }
12
15
 
@@ -64,8 +67,9 @@ async function updatePlugin (app) {
64
67
  return null
65
68
  }
66
69
 
67
- const wsUrl = createWebSocketUrl(iccUrl, `api/updates/applications/${applicationId}`)
68
- app.log.info(`Connecting to updates websocket at ${wsUrl}`)
70
+ const runtimeId = app.getRuntimeId()
71
+ const wsUrl = createWebSocketUrl(iccUrl, `api/updates/applications/${applicationId}`, { runtimeId })
72
+ app.log.info({ runtimeId }, `Connecting to updates websocket at ${wsUrl}`)
69
73
 
70
74
  try {
71
75
  const headers = await app.getAuthorizationHeader()
@@ -31,7 +31,12 @@ test('should send health signals when service becomes unhealthy', async (t) => {
31
31
  processSignals: (req) => {
32
32
  assert.equal(req.headers.authorization, 'Bearer test-token')
33
33
  receivedSignalReqs.push(req.body)
34
- return { id: 'test-alert-id' }
34
+ // Real ICC returns { alerts: [{ serviceId, workerId, alertId }] }
35
+ return {
36
+ alerts: [
37
+ { serviceId: 'main', workerId: 'main:0', alertId: 'test-alert-id' }
38
+ ]
39
+ }
35
40
  },
36
41
  processFlamegraphs: (req) => {
37
42
  const alertId = req.query.alertId
@@ -87,36 +92,53 @@ test('should send health signals when service becomes unhealthy', async (t) => {
87
92
  assert.strictEqual(statusCode, 200)
88
93
  }
89
94
 
90
- assert.strictEqual(receivedSignalReqs.length, 1)
95
+ // Multiple batches may be sent due to timing, verify we received at least one
96
+ assert.ok(receivedSignalReqs.length >= 1, `Expected at least 1 signal request, got ${receivedSignalReqs.length}`)
91
97
 
92
- const receivedSignalReq = receivedSignalReqs[0]
93
- assert.ok(receivedSignalReq, 'Alert should have been received')
98
+ // Use the last signal request which should have the most complete data
99
+ const receivedSignalReq = receivedSignalReqs[receivedSignalReqs.length - 1]
100
+ assert.ok(receivedSignalReq, 'Signal request should have been received')
94
101
  assert.strictEqual(receivedSignalReq.applicationId, applicationId)
95
- assert.strictEqual(receivedSignalReq.serviceId, 'main')
96
- assert.ok(receivedSignalReq.elu > 0.9)
97
- assert.ok(receivedSignalReq.heapUsed > 0)
98
- assert.ok(receivedSignalReq.heapTotal > 0)
99
-
100
- const receivedSignals = receivedSignalReq.signals
101
- assert.ok(receivedSignals.length > 5)
102
-
103
- const eluSignals = receivedSignals.filter(
104
- (signal) => signal.type === 'elu'
105
- )
106
- const customSignals = receivedSignals.filter(
107
- (signal) => signal.type === 'custom'
108
- )
109
- assert.strictEqual(customSignals.length, 1)
110
-
111
- for (const receivedSignal of eluSignals) {
112
- assert.strictEqual(receivedSignal.type, 'elu')
113
- assert.ok(receivedSignal.value > 0.9)
114
- assert.ok(receivedSignal.timestamp > 0)
102
+ assert.ok(receivedSignalReq.runtimeId, 'runtimeId should be present')
103
+ assert.ok(typeof receivedSignalReq.runtimeId === 'string', 'runtimeId should be a string')
104
+
105
+ // Verify v2 signals format: { serviceId: { elu: { values, options }, heap: { values, options } } }
106
+ const signals = receivedSignalReq.signals
107
+ assert.ok(signals, 'signals should be present')
108
+ assert.ok(signals.main, 'main service signals should be present')
109
+
110
+ // Check ELU signals structure
111
+ const eluSignals = signals.main.elu
112
+ assert.ok(eluSignals, 'ELU signals should be present')
113
+ assert.ok(Array.isArray(eluSignals.values), 'ELU values should be an array')
114
+ assert.ok(eluSignals.options, 'ELU options should be present')
115
+ assert.ok(typeof eluSignals.options.threshold === 'number', 'ELU threshold should be a number')
116
+
117
+ // Check heap signals structure
118
+ const heapSignals = signals.main.heap
119
+ assert.ok(heapSignals, 'Heap signals should be present')
120
+ assert.ok(Array.isArray(heapSignals.values), 'Heap values should be an array')
121
+ assert.ok(heapSignals.options, 'Heap options should be present')
122
+ assert.ok(typeof heapSignals.options.threshold === 'number', 'Heap threshold should be a number')
123
+
124
+ // Verify ELU values have the correct structure with workerId
125
+ assert.ok(eluSignals.values.length > 0, 'Should have ELU values')
126
+ for (const eluValue of eluSignals.values) {
127
+ assert.ok(typeof eluValue.value === 'number', 'ELU value should be a number')
128
+ assert.ok(typeof eluValue.timestamp === 'number', 'ELU timestamp should be a number')
129
+ assert.ok(typeof eluValue.workerId === 'string', 'ELU workerId should be a string')
115
130
  }
116
- for (const receivedSignal of customSignals) {
117
- assert.strictEqual(receivedSignal.type, 'custom')
118
- assert.strictEqual(receivedSignal.value, 42)
119
- assert.ok(receivedSignal.timestamp > 0)
131
+
132
+ // Check that at least one ELU value is high (from CPU intensive operation)
133
+ const highEluValue = eluSignals.values.find(v => v.value > 0.9)
134
+ assert.ok(highEluValue, 'Should have at least one high ELU value')
135
+
136
+ // Verify heap values have the correct structure with workerId
137
+ assert.ok(heapSignals.values.length > 0, 'Should have heap values')
138
+ for (const heapValue of heapSignals.values) {
139
+ assert.ok(typeof heapValue.value === 'number', 'Heap value should be a number')
140
+ assert.ok(typeof heapValue.timestamp === 'number', 'Heap timestamp should be a number')
141
+ assert.ok(typeof heapValue.workerId === 'string', 'Heap workerId should be a string')
120
142
  }
121
143
 
122
144
  // Wait for the second flamegraph to be generated
@@ -411,60 +411,3 @@ test('should merge user telemetry config with ICC exporter', async (t) => {
411
411
  assert.ok(hasUserSkip, 'User skip pattern should be preserved')
412
412
  assert.ok(hasDefaultSkip, 'Default skip pattern should be added')
413
413
  })
414
-
415
- test('should configure next service with cache', async (t) => {
416
- const appName = 'test-app'
417
- const applicationId = randomUUID()
418
- const applicationPath = join(__dirname, 'fixtures', 'runtime-next')
419
- const nextServicePath = join(applicationPath, 'web', 'next')
420
-
421
- await installDeps(t, applicationPath)
422
- await installDeps(t, nextServicePath, ['@platformatic/next', 'next'])
423
-
424
- const { execa } = await import('execa')
425
- await execa(join(__dirname, '../node_modules/.bin/plt'), ['build'], {
426
- cwd: applicationPath,
427
- })
428
-
429
- const icc = await startICC(t, {
430
- applicationId,
431
- port: 3001,
432
- controlPlaneResponse: {
433
- applicationId,
434
- httpCache: {
435
- clientOpts: {
436
- keyPrefix: 'test-prefix',
437
- host: '127.0.0.1',
438
- port: 6379,
439
- username: 'user',
440
- password: 'pass'
441
- }
442
- }
443
- }
444
- })
445
-
446
- setUpEnvironment({
447
- PLT_APP_NAME: appName,
448
- PLT_APP_DIR: applicationPath,
449
- PLT_ICC_URL: icc.iccUrl,
450
- PLT_APP_PORT: 3043,
451
- PLT_METRICS_PORT: 9092
452
- })
453
-
454
- const app = await start()
455
-
456
- t.after(async () => {
457
- await app.close()
458
- await icc.close()
459
- })
460
-
461
- const runtimeConfig = app.watt.runtime.getRuntimeConfig(true)
462
- const nextApp = runtimeConfig.applications.find(a => a.type === '@platformatic/next')
463
- assert.ok(nextApp, 'Next service should be found in applications')
464
-
465
- const nextConfig = await app.watt.runtime.getApplicationConfig(nextApp.id)
466
- assert.ok(nextConfig.cache, 'Cache should be configured for next service')
467
- assert.strictEqual(nextConfig.cache.adapter, 'valkey')
468
- assert.strictEqual(nextConfig.cache.prefix, 'test-prefix')
469
- assert.strictEqual(nextConfig.cache.maxTTL, 604800)
470
- })
@@ -79,6 +79,7 @@ function createMockApp (port, includeScalerUrl = true, env = {}) {
79
79
  }
80
80
  }
81
81
 
82
+ let runtimeId = null
82
83
  const app = {
83
84
  log: {
84
85
  info: () => {},
@@ -93,6 +94,12 @@ function createMockApp (port, includeScalerUrl = true, env = {}) {
93
94
  getAuthorizationHeader: async () => {
94
95
  return { Authorization: 'Bearer test-token' }
95
96
  },
97
+ getRuntimeId: () => {
98
+ if (!runtimeId) {
99
+ runtimeId = 'test-runtime-id'
100
+ }
101
+ return runtimeId
102
+ },
96
103
  env: {
97
104
  PLT_APP_NAME: 'test-app',
98
105
  PLT_APP_DIR: '/path/to/app',
@@ -6,7 +6,8 @@ import updatePlugin from '../plugins/update.js'
6
6
  import { once, EventEmitter } from 'node:events'
7
7
  import { setTimeout as sleep } from 'node:timers/promises'
8
8
 
9
- function createMockApp (port) {
9
+ function createMockApp (port, options = {}) {
10
+ let runtimeId = null
10
11
  return {
11
12
  log: {
12
13
  info: () => {},
@@ -20,6 +21,12 @@ function createMockApp (port) {
20
21
  getAuthorizationHeader: async () => {
21
22
  return { Authorization: 'Bearer test-token' }
22
23
  },
24
+ getRuntimeId: () => {
25
+ if (!runtimeId) {
26
+ runtimeId = options.runtimeId || 'test-runtime-id'
27
+ }
28
+ return runtimeId
29
+ },
23
30
  env: {
24
31
  PLT_APP_NAME: 'test-app',
25
32
  PLT_APP_DIR: '/path/to/app',
@@ -92,6 +99,46 @@ test('update plugin connects to websocket', async (t) => {
92
99
  equal(!!subscriptionAckLog, true, 'Should log subscription acknowledgment')
93
100
  })
94
101
 
102
+ test('update plugin includes runtimeId in websocket URL', async (t) => {
103
+ const ee = new EventEmitter()
104
+ setUpEnvironment()
105
+
106
+ const expectedRuntimeId = 'test-runtime-id-12345'
107
+ let receivedUrl = null
108
+
109
+ // Setup WebSocket server
110
+ const wss = new WebSocketServer({ port })
111
+ t.after(async () => wss.close())
112
+
113
+ wss.on('connection', (ws, req) => {
114
+ receivedUrl = req.url
115
+
116
+ ws.on('message', (data) => {
117
+ const message = JSON.parse(data.toString())
118
+ if (message.command === 'subscribe' && message.topic === '/config') {
119
+ ws.send(JSON.stringify({ command: 'ack' }))
120
+ ee.emit('subscriptionAckSent')
121
+ }
122
+ })
123
+ })
124
+
125
+ const app = createMockApp(port, { runtimeId: expectedRuntimeId })
126
+ t.after(() => app.closeUpdates())
127
+
128
+ await updatePlugin(app)
129
+
130
+ const ack = once(ee, 'subscriptionAckSent')
131
+ app.connectToUpdates()
132
+ await ack
133
+
134
+ // Verify runtimeId is in the URL query params
135
+ equal(
136
+ receivedUrl.includes(`runtimeId=${expectedRuntimeId}`),
137
+ true,
138
+ `WebSocket URL should include runtimeId query param. Received URL: ${receivedUrl}`
139
+ )
140
+ })
141
+
95
142
  test('update plugin handles config update messages', async (t) => {
96
143
  const ee = new EventEmitter()
97
144
  setUpEnvironment()