@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.
- 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 +160 -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.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.
|
|
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: 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
|
-
#
|
|
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
|
+
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
246
|
+
const { alerts = [] } = await body.json()
|
|
247
|
+
process._rawDebug(`[health-signals] Received ${alerts.length} alerts from scaler`)
|
|
196
248
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
alertId
|
|
201
|
-
|
|
202
|
-
app.
|
|
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
|
|
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()
|