@platformatic/watt-extra 1.10.1-alpha.2 → 1.11.0-alpha.1
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/.claude/settings.local.json +4 -15
- package/lib/watt.js +2 -9
- package/package.json +2 -2
- package/plugins/env.js +4 -2
- package/plugins/health-signals.js +125 -93
- package/plugins/update.js +7 -3
- package/test/health-signals.test.js +50 -28
- package/test/patch-config.test.js +0 -57
- package/test/trigger-flamegraphs.test.js +7 -0
- package/test/update.test.js +48 -1
|
@@ -1,22 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"permissions": {
|
|
3
3
|
"allow": [
|
|
4
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.11.0-alpha.1",
|
|
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.
|
|
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: '
|
|
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: 2000 },
|
|
32
|
+
PLT_HEALTH_SIGNALS_LONG_BATCH_TIMEOUT: { type: 'number', default: 2000 }
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -1,42 +1,59 @@
|
|
|
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
|
-
#
|
|
7
|
-
#
|
|
7
|
+
#signalsByService = {}
|
|
8
|
+
#maxSize = 500
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
this.#signals = []
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
add (signals) {
|
|
10
|
+
addServiceSignals (serviceId, signals) {
|
|
14
11
|
for (const signal of signals) {
|
|
15
|
-
this
|
|
12
|
+
this.addServiceSignal(serviceId, signal.type, signal)
|
|
16
13
|
}
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
23
|
-
const
|
|
24
|
-
this.#
|
|
25
|
-
return
|
|
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
|
-
|
|
31
|
-
|
|
36
|
+
app.getRuntimeId = () => {
|
|
37
|
+
if (!app.runtimeId) {
|
|
38
|
+
app.runtimeId = randomUUID()
|
|
39
|
+
}
|
|
40
|
+
return app.runtimeId
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const signalsCache = new HealthSignalsCache()
|
|
44
|
+
const heapTotalByService = {}
|
|
32
45
|
|
|
33
46
|
// TODO: needed to the UI compatibility
|
|
34
47
|
// remove after depricating the Scaler v1 UI
|
|
35
|
-
|
|
48
|
+
let servicesMetrics = {}
|
|
36
49
|
|
|
37
50
|
// Store listener reference for cleanup
|
|
38
51
|
let healthMetricsListener = null
|
|
39
52
|
|
|
53
|
+
// Store thresholds for use in sendHealthSignals
|
|
54
|
+
let eluThreshold = null
|
|
55
|
+
let heapThresholdMb = null
|
|
56
|
+
|
|
40
57
|
async function setupHealthSignals () {
|
|
41
58
|
const scalerAlgorithmVersion = app.instanceConfig?.scaler?.version ?? 'v1'
|
|
42
59
|
if (scalerAlgorithmVersion !== 'v2') {
|
|
@@ -54,13 +71,6 @@ async function healthSignals (app, _opts) {
|
|
|
54
71
|
return
|
|
55
72
|
}
|
|
56
73
|
|
|
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
74
|
// Skip alerts setup if ICC is not configured
|
|
65
75
|
if (!app.env.PLT_ICC_URL) {
|
|
66
76
|
app.log.info('PLT_ICC_URL not set, skipping alerts setup')
|
|
@@ -68,8 +78,6 @@ async function healthSignals (app, _opts) {
|
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
const scalerUrl = app.instanceConfig?.iccServices?.scaler?.url
|
|
71
|
-
const runtime = app.watt.runtime
|
|
72
|
-
|
|
73
81
|
if (!scalerUrl) {
|
|
74
82
|
app.log.warn(
|
|
75
83
|
'No scaler URL found in ICC services, health alerts disabled'
|
|
@@ -77,6 +85,40 @@ async function healthSignals (app, _opts) {
|
|
|
77
85
|
return
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
const runtime = app.watt.runtime
|
|
89
|
+
const batchShortTimeout = app.env.PLT_HEALTH_SIGNALS_SHORT_BATCH_TIMEOUT
|
|
90
|
+
const batchLongTimeout = app.env.PLT_HEALTH_SIGNALS_LONG_BATCH_TIMEOUT
|
|
91
|
+
eluThreshold = app.env.PLT_ELU_HEALTH_SIGNAL_THRESHOLD
|
|
92
|
+
|
|
93
|
+
// TODO: get the used heap and use the 0.8 by default as a threshold
|
|
94
|
+
let heapThreshold = app.env.PLT_HEAP_HEALTH_SIGNAL_THRESHOLD
|
|
95
|
+
if (typeof heapThreshold === 'string') {
|
|
96
|
+
heapThreshold = parseMemorySize(heapThreshold)
|
|
97
|
+
}
|
|
98
|
+
heapThresholdMb = Math.round(heapThreshold / 1024 / 1024)
|
|
99
|
+
|
|
100
|
+
let batchHasHighValue = false
|
|
101
|
+
let batchStartedAt = null
|
|
102
|
+
setInterval(() => {
|
|
103
|
+
const now = Date.now()
|
|
104
|
+
const batchTimeout = batchHasHighValue
|
|
105
|
+
? batchShortTimeout
|
|
106
|
+
: batchLongTimeout
|
|
107
|
+
|
|
108
|
+
if (now - batchStartedAt >= batchTimeout) {
|
|
109
|
+
batchHasHighValue = false
|
|
110
|
+
batchStartedAt = null
|
|
111
|
+
|
|
112
|
+
const signals = signalsCache.getAllSignals()
|
|
113
|
+
const metrics = servicesMetrics
|
|
114
|
+
servicesMetrics = {}
|
|
115
|
+
|
|
116
|
+
sendHealthSignals(signals, metrics).catch(err => {
|
|
117
|
+
app.log.error({ err }, 'Failed to send health signals to scaler')
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}, 1000).unref()
|
|
121
|
+
|
|
80
122
|
// Remove old listener if it exists (for ICC recovery scenario)
|
|
81
123
|
if (healthMetricsListener) {
|
|
82
124
|
runtime.removeListener('application:worker:health:metrics', healthMetricsListener)
|
|
@@ -95,30 +137,25 @@ async function healthSignals (app, _opts) {
|
|
|
95
137
|
} = healthInfo
|
|
96
138
|
|
|
97
139
|
const { elu, heapUsed, heapTotal } = currentHealth
|
|
140
|
+
const heapUsedMb = Math.round(heapUsed / 1024 / 1024)
|
|
141
|
+
const now = Date.now()
|
|
98
142
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
143
|
+
signalsCache.addServiceSignal(serviceId, 'elu', {
|
|
144
|
+
workerId,
|
|
145
|
+
value: elu,
|
|
146
|
+
timestamp: now
|
|
147
|
+
})
|
|
148
|
+
signalsCache.addServiceSignal(serviceId, 'heap', {
|
|
149
|
+
workerId,
|
|
150
|
+
value: heapUsedMb,
|
|
151
|
+
timestamp: now
|
|
152
|
+
})
|
|
153
|
+
heapTotalByService[serviceId] = heapTotal
|
|
154
|
+
signalsCache.addServiceSignals(serviceId, healthSignals)
|
|
109
155
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
})
|
|
156
|
+
batchStartedAt ??= now
|
|
157
|
+
if (elu > eluThreshold || heapUsedMb > heapThresholdMb) {
|
|
158
|
+
batchHasHighValue = true
|
|
122
159
|
}
|
|
123
160
|
|
|
124
161
|
// TODO: needed to the UI compatibility
|
|
@@ -132,44 +169,35 @@ async function healthSignals (app, _opts) {
|
|
|
132
169
|
metrics.heapUsed = heapUsed
|
|
133
170
|
metrics.heapTotal = heapTotal
|
|
134
171
|
}
|
|
135
|
-
|
|
136
|
-
if (healthSignals.length > 0) {
|
|
137
|
-
await sendHealthSignalsWithTimeout(serviceId, workerId, healthSignals)
|
|
138
|
-
}
|
|
139
172
|
}
|
|
140
173
|
runtime.on('application:worker:health:metrics', healthMetricsListener)
|
|
141
174
|
}
|
|
142
175
|
app.setupHealthSignals = setupHealthSignals
|
|
143
176
|
|
|
144
|
-
async function
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
177
|
+
async function sendHealthSignals (rawSignals) {
|
|
178
|
+
const scalerUrl = app.instanceConfig?.iccServices?.scaler?.url
|
|
179
|
+
const applicationId = app.instanceConfig?.applicationId
|
|
180
|
+
const authHeaders = await app.getAuthorizationHeader()
|
|
158
181
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
182
|
+
// Transform signals to the format expected by ICC LoadPredictor
|
|
183
|
+
const signals = {}
|
|
184
|
+
for (const [serviceId, serviceSignals] of Object.entries(rawSignals)) {
|
|
185
|
+
signals[serviceId] = {
|
|
186
|
+
elu: {
|
|
187
|
+
values: serviceSignals.elu || [],
|
|
188
|
+
options: { threshold: eluThreshold }
|
|
189
|
+
},
|
|
190
|
+
heap: {
|
|
191
|
+
values: serviceSignals.heap || [],
|
|
192
|
+
options: {
|
|
193
|
+
threshold: heapThresholdMb,
|
|
194
|
+
heapTotal: heapTotalByService[serviceId] || 0
|
|
195
|
+
}
|
|
164
196
|
}
|
|
165
|
-
}
|
|
197
|
+
}
|
|
166
198
|
}
|
|
167
|
-
}
|
|
168
199
|
|
|
169
|
-
|
|
170
|
-
const scalerUrl = app.instanceConfig?.iccServices?.scaler?.url
|
|
171
|
-
const applicationId = app.instanceConfig?.applicationId
|
|
172
|
-
const authHeaders = await app.getAuthorizationHeader()
|
|
200
|
+
const runtimeId = app.getRuntimeId()
|
|
173
201
|
|
|
174
202
|
const { statusCode, body } = await request(`${scalerUrl}/signals`, {
|
|
175
203
|
method: 'POST',
|
|
@@ -177,30 +205,34 @@ async function healthSignals (app, _opts) {
|
|
|
177
205
|
'Content-Type': 'application/json',
|
|
178
206
|
...authHeaders
|
|
179
207
|
},
|
|
180
|
-
body: JSON.stringify({
|
|
181
|
-
applicationId,
|
|
182
|
-
serviceId,
|
|
183
|
-
signals,
|
|
184
|
-
elu: metrics.elu,
|
|
185
|
-
heapUsed: metrics.heapUsed,
|
|
186
|
-
heapTotal: metrics.heapTotal
|
|
187
|
-
})
|
|
208
|
+
body: JSON.stringify({ applicationId, runtimeId, signals })
|
|
188
209
|
})
|
|
189
210
|
|
|
190
211
|
if (statusCode !== 200) {
|
|
191
212
|
const error = await body.text()
|
|
192
213
|
app.log.error({ error }, 'Failed to send health signals to scaler')
|
|
214
|
+
return
|
|
193
215
|
}
|
|
194
216
|
|
|
195
|
-
const
|
|
217
|
+
const { alerts = [] } = await body.json()
|
|
218
|
+
const promises = []
|
|
196
219
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
220
|
+
for (const alert of alerts) {
|
|
221
|
+
const { serviceId, workerId, alertId } = alert
|
|
222
|
+
const promise = app.sendFlamegraphs({
|
|
223
|
+
serviceIds: [serviceId],
|
|
224
|
+
workerIds: [workerId],
|
|
225
|
+
alertId
|
|
226
|
+
})
|
|
227
|
+
promises.push(promise)
|
|
228
|
+
}
|
|
229
|
+
const results = await Promise.allSettled(promises)
|
|
230
|
+
|
|
231
|
+
for (const result of results) {
|
|
232
|
+
if (result.status === 'rejected') {
|
|
233
|
+
app.log.error({ err: result.reason }, 'Failed to send a flamegraph')
|
|
234
|
+
}
|
|
235
|
+
}
|
|
204
236
|
}
|
|
205
237
|
}
|
|
206
238
|
|
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
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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.
|
|
96
|
-
assert.ok(receivedSignalReq.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
assert.ok(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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',
|
package/test/update.test.js
CHANGED
|
@@ -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()
|