@platformatic/watt-extra 1.4.0 → 1.5.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/app.js +4 -2
- package/index.js +1 -0
- package/package.json +10 -8
- package/plugins/alerts.js +6 -3
- package/plugins/env.js +4 -1
- package/plugins/health-signals.js +181 -0
- package/plugins/metadata.js +47 -9
- package/test/fixtures/service-1/routes/root.cjs +57 -39
- package/test/health-signals.test.js +130 -0
- package/test/helper.js +3 -0
- package/.claude/settings.local.json +0 -11
package/app.js
CHANGED
|
@@ -8,11 +8,12 @@ import scheduler from './plugins/scheduler.js'
|
|
|
8
8
|
import auth from './plugins/auth.js'
|
|
9
9
|
import update from './plugins/update.js'
|
|
10
10
|
import alert from './plugins/alerts.js'
|
|
11
|
+
import healthSignals from './plugins/health-signals.js'
|
|
11
12
|
import flamegraphs from './plugins/flamegraphs.js'
|
|
12
13
|
|
|
13
14
|
async function buildApp (logger) {
|
|
14
15
|
const app = {
|
|
15
|
-
log: logger
|
|
16
|
+
log: logger
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
avvio(app)
|
|
@@ -22,6 +23,7 @@ async function buildApp (logger) {
|
|
|
22
23
|
.use(auth)
|
|
23
24
|
.use(init)
|
|
24
25
|
.use(alert)
|
|
26
|
+
.use(healthSignals)
|
|
25
27
|
.use(metadata)
|
|
26
28
|
.use(compliancy)
|
|
27
29
|
.use(scheduler)
|
|
@@ -101,7 +103,7 @@ async function buildApp (logger) {
|
|
|
101
103
|
{
|
|
102
104
|
err: err.message,
|
|
103
105
|
attemptNumber: retries,
|
|
104
|
-
nextRetryMs: currentRetryInterval
|
|
106
|
+
nextRetryMs: currentRetryInterval
|
|
105
107
|
},
|
|
106
108
|
`Failed to send info to ICC, retrying in ${currentRetryInterval}ms`
|
|
107
109
|
)
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platformatic/watt-extra",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0-alpha.0",
|
|
4
4
|
"description": "The Platformatic runtime manager",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -19,25 +19,27 @@
|
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@fastify/websocket": "^11.1.0",
|
|
22
|
-
"@platformatic/composer": "^3.
|
|
23
|
-
"@platformatic/next": "^3.
|
|
24
|
-
"@platformatic/node": "^3.
|
|
25
|
-
"@platformatic/service": "^3.
|
|
22
|
+
"@platformatic/composer": "^3.14.0",
|
|
23
|
+
"@platformatic/next": "^3.14.0",
|
|
24
|
+
"@platformatic/node": "^3.14.0",
|
|
25
|
+
"@platformatic/service": "^3.14.0",
|
|
26
|
+
"atomic-sleep": "^1.0.0",
|
|
26
27
|
"borp": "^0.21.0",
|
|
27
28
|
"eslint": "9",
|
|
28
29
|
"fastify": "^5.4.0",
|
|
29
30
|
"fastify-plugin": "^5.0.1",
|
|
30
31
|
"neostandard": "^0.12.0",
|
|
31
32
|
"next": "^15.3.4",
|
|
32
|
-
"platformatic": "^3.
|
|
33
|
+
"platformatic": "^3.14.0",
|
|
33
34
|
"pprof-format": "^2.1.0",
|
|
34
35
|
"why-is-node-running": "^2.3.0"
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|
|
37
38
|
"@datadog/pprof": "^5.9.0",
|
|
38
39
|
"@fastify/error": "^4.2.0",
|
|
39
|
-
"@platformatic/runtime": "^3.
|
|
40
|
-
"@platformatic/
|
|
40
|
+
"@platformatic/runtime": "^3.14.0",
|
|
41
|
+
"@platformatic/foundation": "^3.14.0",
|
|
42
|
+
"@platformatic/wattpm-pprof-capture": "^3.14.0",
|
|
41
43
|
"avvio": "^9.1.0",
|
|
42
44
|
"chalk": "^4.1.2",
|
|
43
45
|
"commist": "^3.2.0",
|
package/plugins/alerts.js
CHANGED
|
@@ -10,6 +10,9 @@ async function alerts (app, _opts) {
|
|
|
10
10
|
const lastServicesAlertTime = {}
|
|
11
11
|
|
|
12
12
|
async function setupAlerts () {
|
|
13
|
+
const scalerAlgorithmVersion = app.env.PLT_SCALER_ALGORITHM_VERSION
|
|
14
|
+
if (scalerAlgorithmVersion !== 'v1') return
|
|
15
|
+
|
|
13
16
|
// Skip alerts setup if ICC is not configured
|
|
14
17
|
if (!app.env.PLT_ICC_URL) {
|
|
15
18
|
app.log.info('PLT_ICC_URL not set, skipping alerts setup')
|
|
@@ -85,13 +88,13 @@ async function alerts (app, _opts) {
|
|
|
85
88
|
method: 'POST',
|
|
86
89
|
headers: {
|
|
87
90
|
'Content-Type': 'application/json',
|
|
88
|
-
...authHeaders
|
|
91
|
+
...authHeaders
|
|
89
92
|
},
|
|
90
93
|
body: JSON.stringify({
|
|
91
94
|
applicationId: app.instanceConfig?.applicationId,
|
|
92
95
|
alert: healthInfo,
|
|
93
|
-
healthHistory: healthCache
|
|
94
|
-
})
|
|
96
|
+
healthHistory: healthCache
|
|
97
|
+
})
|
|
95
98
|
})
|
|
96
99
|
|
|
97
100
|
if (statusCode !== 200) {
|
package/plugins/env.js
CHANGED
|
@@ -22,7 +22,10 @@ const schema = {
|
|
|
22
22
|
PLT_FLAMEGRAPHS_ELU_THRESHOLD: { type: 'number', default: 0.4 },
|
|
23
23
|
PLT_FLAMEGRAPHS_GRACE_PERIOD: { type: 'number', default: 3000 },
|
|
24
24
|
PLT_JWT_EXPIRATION_OFFSET_SEC: { type: 'number', default: 60 },
|
|
25
|
-
PLT_UPDATES_RECONNECT_INTERVAL_SEC: { type: 'number', default: 1 }
|
|
25
|
+
PLT_UPDATES_RECONNECT_INTERVAL_SEC: { type: 'number', default: 1 },
|
|
26
|
+
PLT_SCALER_ALGORITHM_VERSION: { type: 'string', default: 'v1', enum: ['v1', 'v2'] },
|
|
27
|
+
PLT_ELU_HEALTH_SIGNAL_THRESHOLD: { type: 'number', default: 0.9 },
|
|
28
|
+
PLT_HEAP_HEALTH_SIGNAL_THRESHOLD: { type: ['number', 'string'], default: '4GB' }
|
|
26
29
|
}
|
|
27
30
|
}
|
|
28
31
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { request } from 'undici'
|
|
2
|
+
import { parseMemorySize } from '@platformatic/foundation'
|
|
3
|
+
|
|
4
|
+
class HealthSignalsCache {
|
|
5
|
+
#signals = []
|
|
6
|
+
#size = 100
|
|
7
|
+
|
|
8
|
+
constructor () {
|
|
9
|
+
this.#signals = []
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
add (signals) {
|
|
13
|
+
for (const signal of signals) {
|
|
14
|
+
this.#signals.push(signal)
|
|
15
|
+
}
|
|
16
|
+
if (this.#signals.length > this.#size) {
|
|
17
|
+
this.#signals.splice(0, this.#signals.length - this.#size)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
getAll () {
|
|
22
|
+
const values = this.#signals
|
|
23
|
+
this.#signals = []
|
|
24
|
+
return values
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function healthSignals (app, _opts) {
|
|
29
|
+
const signalsCaches = {}
|
|
30
|
+
const servicesSendingStatuses = {}
|
|
31
|
+
|
|
32
|
+
// TODO: needed to the UI compatibility
|
|
33
|
+
// remove after depricating the Scaler v1 UI
|
|
34
|
+
const servicesMetrics = {}
|
|
35
|
+
|
|
36
|
+
async function setupHealthSignals () {
|
|
37
|
+
const scalerAlgorithmVersion = app.env.PLT_SCALER_ALGORITHM_VERSION
|
|
38
|
+
if (scalerAlgorithmVersion !== 'v2') return
|
|
39
|
+
|
|
40
|
+
const eluThreshold = app.env.PLT_ELU_HEALTH_SIGNAL_THRESHOLD
|
|
41
|
+
|
|
42
|
+
let heapThreshold = app.env.PLT_HEAP_HEALTH_SIGNAL_THRESHOLD
|
|
43
|
+
if (typeof heapThreshold === 'string') {
|
|
44
|
+
heapThreshold = parseMemorySize(heapThreshold)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Skip alerts setup if ICC is not configured
|
|
48
|
+
if (!app.env.PLT_ICC_URL) {
|
|
49
|
+
app.log.info('PLT_ICC_URL not set, skipping alerts setup')
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const scalerUrl = app.instanceConfig?.iccServices?.scaler?.url
|
|
54
|
+
const runtime = app.watt.runtime
|
|
55
|
+
|
|
56
|
+
if (!scalerUrl) {
|
|
57
|
+
app.log.warn(
|
|
58
|
+
'No scaler URL found in ICC services, health alerts disabled'
|
|
59
|
+
)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
runtime.on('application:worker:health:metrics', async (healthInfo) => {
|
|
64
|
+
if (!healthInfo) {
|
|
65
|
+
app.log.error('No health metrics info received')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const {
|
|
69
|
+
application: serviceId,
|
|
70
|
+
currentHealth,
|
|
71
|
+
healthSignals
|
|
72
|
+
} = healthInfo
|
|
73
|
+
|
|
74
|
+
const { elu, heapUsed, heapTotal } = currentHealth
|
|
75
|
+
|
|
76
|
+
if (elu > eluThreshold) {
|
|
77
|
+
healthSignals.push({
|
|
78
|
+
type: 'elu',
|
|
79
|
+
value: currentHealth.elu,
|
|
80
|
+
description:
|
|
81
|
+
`The ${serviceId} has an ELU of ${(elu * 100).toFixed(2)} %, ` +
|
|
82
|
+
`above the maximum allowed usage of ${(eluThreshold * 100).toFixed(2)} %`,
|
|
83
|
+
timestamp: Date.now()
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (heapThreshold && heapUsed > heapThreshold) {
|
|
88
|
+
const usedHeapMb = Math.round(heapUsed / 1024 / 1024)
|
|
89
|
+
const heapThresholdMb = Math.round(heapThreshold / 1024 / 1024)
|
|
90
|
+
|
|
91
|
+
healthSignals.push({
|
|
92
|
+
type: 'heapUsed',
|
|
93
|
+
value: currentHealth.heapUsed,
|
|
94
|
+
description:
|
|
95
|
+
`The ${serviceId} is using ${usedHeapMb} MB of heap, ` +
|
|
96
|
+
`above the maximum allowed usage of ${heapThresholdMb} MB`,
|
|
97
|
+
timestamp: Date.now()
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// TODO: needed to the UI compatibility
|
|
102
|
+
// remove after depricating the Scaler v1 UI
|
|
103
|
+
servicesMetrics[serviceId] ??= { elu: 0, heapUsed: 0, heapTotal: 0 }
|
|
104
|
+
const metrics = servicesMetrics[serviceId]
|
|
105
|
+
if (elu > metrics.elu) {
|
|
106
|
+
metrics.elu = elu
|
|
107
|
+
}
|
|
108
|
+
if (heapUsed > metrics.heapUsed) {
|
|
109
|
+
metrics.heapUsed = heapUsed
|
|
110
|
+
metrics.heapTotal = heapTotal
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (healthSignals.length > 0) {
|
|
114
|
+
await sendHealthSignalsWithTimeout(serviceId, healthSignals)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
app.setupHealthSignals = setupHealthSignals
|
|
119
|
+
|
|
120
|
+
async function sendHealthSignalsWithTimeout (serviceId, signals) {
|
|
121
|
+
signalsCaches[serviceId] ??= new HealthSignalsCache()
|
|
122
|
+
servicesSendingStatuses[serviceId] ??= false
|
|
123
|
+
|
|
124
|
+
const signalsCache = signalsCaches[serviceId]
|
|
125
|
+
signalsCache.add(signals)
|
|
126
|
+
|
|
127
|
+
if (!servicesSendingStatuses[serviceId]) {
|
|
128
|
+
servicesSendingStatuses[serviceId] = true
|
|
129
|
+
setTimeout(async () => {
|
|
130
|
+
servicesSendingStatuses[serviceId] = false
|
|
131
|
+
|
|
132
|
+
const metrics = servicesMetrics[serviceId]
|
|
133
|
+
servicesMetrics[serviceId] = null
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const signals = signalsCache.getAll()
|
|
137
|
+
await sendHealthSignals(serviceId, signals, metrics)
|
|
138
|
+
} catch (err) {
|
|
139
|
+
app.log.error({ err }, 'Failed to send health signals to scaler')
|
|
140
|
+
}
|
|
141
|
+
}, 5000).unref()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function sendHealthSignals (serviceId, signals, metrics) {
|
|
146
|
+
const scalerUrl = app.instanceConfig?.iccServices?.scaler?.url
|
|
147
|
+
const applicationId = app.instanceConfig?.applicationId
|
|
148
|
+
const authHeaders = await app.getAuthorizationHeader()
|
|
149
|
+
|
|
150
|
+
const { statusCode, body } = await request(`${scalerUrl}/signals`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: {
|
|
153
|
+
'Content-Type': 'application/json',
|
|
154
|
+
...authHeaders
|
|
155
|
+
},
|
|
156
|
+
body: JSON.stringify({
|
|
157
|
+
applicationId,
|
|
158
|
+
serviceId,
|
|
159
|
+
signals,
|
|
160
|
+
elu: metrics.elu,
|
|
161
|
+
heapUsed: metrics.heapUsed,
|
|
162
|
+
heapTotal: metrics.heapTotal
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
if (statusCode !== 200) {
|
|
167
|
+
const error = await body.text()
|
|
168
|
+
app.log.error({ error }, 'Failed to send health signals to scaler')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const alert = await body.json()
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await app.sendFlamegraphs({ serviceIds: [serviceId], alertId: alert.id })
|
|
175
|
+
} catch (err) {
|
|
176
|
+
app.log.error({ err }, 'Failed to send a flamegraph')
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export default healthSignals
|
package/plugins/metadata.js
CHANGED
|
@@ -42,15 +42,17 @@ async function metadata (app, _opts) {
|
|
|
42
42
|
)
|
|
43
43
|
)
|
|
44
44
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
const workersCount = getWorkersCount(runtimeConfig)
|
|
46
|
+
for (const service of services) {
|
|
47
|
+
const serviceWorkers = workersCount[service.id]
|
|
48
|
+
if (serviceWorkers?.workers) {
|
|
49
|
+
service.workers = serviceWorkers.workers
|
|
50
|
+
}
|
|
51
|
+
if (serviceWorkers?.minWorkers) {
|
|
52
|
+
service.minWorkers = serviceWorkers.minWorkers
|
|
53
|
+
}
|
|
54
|
+
if (serviceWorkers?.maxWorkers) {
|
|
55
|
+
service.maxWorkers = serviceWorkers.maxWorkers
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -91,6 +93,42 @@ async function metadata (app, _opts) {
|
|
|
91
93
|
}
|
|
92
94
|
}
|
|
93
95
|
app.sendMetadata = sendMetadata
|
|
96
|
+
|
|
97
|
+
function getWorkersCount (runtimeConfig) {
|
|
98
|
+
const verticalScalerConfig = runtimeConfig.verticalScaler
|
|
99
|
+
const serviceWorkers = {}
|
|
100
|
+
|
|
101
|
+
for (const application of runtimeConfig.applications) {
|
|
102
|
+
const { workers } = application
|
|
103
|
+
if (!workers) continue
|
|
104
|
+
|
|
105
|
+
if (typeof workers === 'number') {
|
|
106
|
+
serviceWorkers[application.id] = {
|
|
107
|
+
workers,
|
|
108
|
+
minWorkers: workers,
|
|
109
|
+
maxWorkers: workers
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (typeof workers === 'object') {
|
|
113
|
+
serviceWorkers[application.id] = {
|
|
114
|
+
workers: workers.static,
|
|
115
|
+
minWorkers: workers.minimum ?? workers.static,
|
|
116
|
+
maxWorkers: workers.maximum ?? workers.static
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (verticalScalerConfig?.enabled) {
|
|
122
|
+
for (const applicationId in verticalScalerConfig.applications) {
|
|
123
|
+
const scalingConfig = verticalScalerConfig.applications[applicationId]
|
|
124
|
+
serviceWorkers[applicationId] ??= {}
|
|
125
|
+
serviceWorkers[applicationId].maxWorkers ??= scalingConfig.maxWorkers
|
|
126
|
+
serviceWorkers[applicationId].minWorkers ??= scalingConfig.minWorkers
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return serviceWorkers
|
|
131
|
+
}
|
|
94
132
|
}
|
|
95
133
|
|
|
96
134
|
export default metadata
|
|
@@ -1,48 +1,66 @@
|
|
|
1
|
-
|
|
1
|
+
'use strict'
|
|
2
2
|
|
|
3
|
-
const { join } = require(
|
|
4
|
-
const { readFile } = require(
|
|
5
|
-
const { request } = require(
|
|
3
|
+
const { join } = require('node:path')
|
|
4
|
+
const { readFile } = require('node:fs/promises')
|
|
5
|
+
const { request } = require('undici')
|
|
6
|
+
const atomicSleep = require('atomic-sleep')
|
|
6
7
|
|
|
7
8
|
module.exports = async function (fastify) {
|
|
8
|
-
fastify.get(
|
|
9
|
-
return { hello:
|
|
10
|
-
})
|
|
9
|
+
fastify.get('/example', async () => {
|
|
10
|
+
return { hello: 'world' }
|
|
11
|
+
})
|
|
11
12
|
|
|
12
|
-
fastify.get(
|
|
13
|
-
return fastify.platformatic.config
|
|
14
|
-
})
|
|
13
|
+
fastify.get('/config', async () => {
|
|
14
|
+
return fastify.platformatic.config
|
|
15
|
+
})
|
|
15
16
|
|
|
16
|
-
fastify.get(
|
|
17
|
+
fastify.get('/preprocess', async () => {
|
|
17
18
|
return {
|
|
18
|
-
base:
|
|
19
|
-
leadingSlash:
|
|
20
|
-
withPrefix:
|
|
21
|
-
externalUrl:
|
|
22
|
-
}
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
fastify.get(
|
|
26
|
-
const customExtFilePath = join(__dirname,
|
|
27
|
-
const customExtFile = await readFile(customExtFilePath,
|
|
28
|
-
return { data: customExtFile }
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
fastify.get(
|
|
32
|
-
return { env: process.env }
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
fastify.post(
|
|
36
|
-
const { method, url } = req.body
|
|
19
|
+
base: '~PLT_BASE_PATH',
|
|
20
|
+
leadingSlash: '/~PLT_BASE_PATH',
|
|
21
|
+
withPrefix: '~PLT_BASE_PATH/foo',
|
|
22
|
+
externalUrl: '~PLT_EXTERNAL_APP_URL'
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
fastify.get('/custom-ext-file', async () => {
|
|
27
|
+
const customExtFilePath = join(__dirname, '..', 'file.custom')
|
|
28
|
+
const customExtFile = await readFile(customExtFilePath, 'utf8')
|
|
29
|
+
return { data: customExtFile }
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
fastify.get('/env', async () => {
|
|
33
|
+
return { env: process.env }
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
fastify.post('/request', async (req) => {
|
|
37
|
+
const { method, url } = req.body
|
|
37
38
|
|
|
38
39
|
const { statusCode, headers, body } = await request(url, {
|
|
39
|
-
method: method ??
|
|
40
|
+
method: method ?? 'GET',
|
|
40
41
|
headers: {
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
})
|
|
44
|
-
const data = await body.text()
|
|
45
|
-
|
|
46
|
-
return { statusCode, headers, data }
|
|
47
|
-
})
|
|
48
|
-
|
|
42
|
+
'content-type': 'application/json'
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
const data = await body.text()
|
|
46
|
+
|
|
47
|
+
return { statusCode, headers, data }
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
fastify.post('/cpu-intensive', async (req) => {
|
|
51
|
+
// Simulate a CPU intensive operation
|
|
52
|
+
const timeout = req.query.timeout || 10000
|
|
53
|
+
atomicSleep(timeout)
|
|
54
|
+
|
|
55
|
+
return { status: 'ok' }
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
fastify.post('/custom-health-signal', async (req) => {
|
|
59
|
+
const { type, value, description } = req.body
|
|
60
|
+
await globalThis.platformatic.sendHealthSignal({
|
|
61
|
+
type,
|
|
62
|
+
value,
|
|
63
|
+
description
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { join, dirname } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
7
|
+
import { Profile } from 'pprof-format'
|
|
8
|
+
import { request } from 'undici'
|
|
9
|
+
import { setUpEnvironment, startICC } from './helper.js'
|
|
10
|
+
import { start } from '../index.js'
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
13
|
+
const __dirname = dirname(__filename)
|
|
14
|
+
|
|
15
|
+
test('should send health signals when service becomes unhealthy', async (t) => {
|
|
16
|
+
const applicationName = 'test-app'
|
|
17
|
+
const applicationId = randomUUID()
|
|
18
|
+
const applicationPath = join(__dirname, 'fixtures', 'service-1')
|
|
19
|
+
|
|
20
|
+
const receivedSignalReqs = []
|
|
21
|
+
const receivedFlamegraphReqs = []
|
|
22
|
+
|
|
23
|
+
const getAuthorizationHeader = async (headers) => {
|
|
24
|
+
return { ...headers, authorization: 'Bearer test-token' }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const icc = await startICC(t, {
|
|
28
|
+
applicationId,
|
|
29
|
+
applicationName,
|
|
30
|
+
processSignals: (req) => {
|
|
31
|
+
assert.equal(req.headers.authorization, 'Bearer test-token')
|
|
32
|
+
receivedSignalReqs.push(req.body)
|
|
33
|
+
return { id: 'test-alert-id' }
|
|
34
|
+
},
|
|
35
|
+
processFlamegraphs: (req) => {
|
|
36
|
+
const alertId = req.query.alertId
|
|
37
|
+
assert.strictEqual(alertId, 'test-alert-id')
|
|
38
|
+
assert.strictEqual(req.headers.authorization, 'Bearer test-token')
|
|
39
|
+
receivedFlamegraphReqs.push(req.body)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
setUpEnvironment({
|
|
44
|
+
PLT_APP_NAME: applicationName,
|
|
45
|
+
PLT_APP_DIR: applicationPath,
|
|
46
|
+
PLT_ICC_URL: 'http://127.0.0.1:3000',
|
|
47
|
+
PLT_DISABLE_FLAMEGRAPHS: false,
|
|
48
|
+
PLT_FLAMEGRAPHS_INTERVAL_SEC: 2,
|
|
49
|
+
PLT_FLAMEGRAPHS_ELU_THRESHOLD: 0,
|
|
50
|
+
PLT_SCALER_ALGORITHM_VERSION: 'v2'
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const app = await start()
|
|
54
|
+
app.getAuthorizationHeader = getAuthorizationHeader
|
|
55
|
+
|
|
56
|
+
t.after(async () => {
|
|
57
|
+
await app.close()
|
|
58
|
+
await icc.close()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Wait for the first flamegraph to be generated
|
|
62
|
+
await sleep(5000)
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
const { statusCode } = await request('http://127.0.0.1:3042/custom-health-signal', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
'Content-Type': 'application/json'
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
type: 'custom',
|
|
72
|
+
value: 42,
|
|
73
|
+
description: 'This is a custom health signal'
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
assert.strictEqual(statusCode, 200)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
const { statusCode } = await request('http://127.0.0.1:3042/cpu-intensive', {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: {
|
|
83
|
+
'Content-Type': 'application/json'
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({ timeout: 3000 })
|
|
86
|
+
})
|
|
87
|
+
assert.strictEqual(statusCode, 200)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
assert.strictEqual(receivedSignalReqs.length, 1)
|
|
91
|
+
|
|
92
|
+
const receivedSignalReq = receivedSignalReqs[0]
|
|
93
|
+
assert.ok(receivedSignalReq, 'Alert should have been received')
|
|
94
|
+
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)
|
|
115
|
+
}
|
|
116
|
+
for (const receivedSignal of customSignals) {
|
|
117
|
+
assert.strictEqual(receivedSignal.type, 'custom')
|
|
118
|
+
assert.strictEqual(receivedSignal.value, 42)
|
|
119
|
+
assert.ok(receivedSignal.timestamp > 0)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Wait for the second flamegraph to be generated
|
|
123
|
+
await sleep(2000)
|
|
124
|
+
|
|
125
|
+
// assert.strictEqual(receivedFlamegraphReqs.length, 1)
|
|
126
|
+
|
|
127
|
+
const receivedFlamegraph = receivedFlamegraphReqs[0]
|
|
128
|
+
const profile = Profile.decode(receivedFlamegraph)
|
|
129
|
+
assert.ok(profile, 'Profile should be decoded')
|
|
130
|
+
})
|
package/test/helper.js
CHANGED
|
@@ -191,6 +191,9 @@ async function startICC (t, opts = {}) {
|
|
|
191
191
|
icc.post('/alerts', async (req) => {
|
|
192
192
|
return opts.processAlerts?.(req)
|
|
193
193
|
})
|
|
194
|
+
icc.post('/signals', async (req) => {
|
|
195
|
+
return opts.processSignals?.(req)
|
|
196
|
+
})
|
|
194
197
|
icc.post('/pods/:podId/services/:serviceId/flamegraph', async (req) => {
|
|
195
198
|
return opts.processFlamegraphs?.(req)
|
|
196
199
|
})
|