@platformatic/watt-extra 0.1.8-alpha.0 → 0.1.9

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.
@@ -41,7 +41,7 @@ jobs:
41
41
  version: 10
42
42
 
43
43
  - name: Use Node.js ${{ matrix.node-version }}
44
- uses: actions/setup-node@v4
44
+ uses: actions/setup-node@v5
45
45
  with:
46
46
  node-version: ${{ matrix.node-version }}
47
47
  # https://github.com/actions/setup-node/blob/main/docs/advanced-usage.md#use-private-packages
package/lib/watt.js CHANGED
@@ -201,6 +201,7 @@ class Watt {
201
201
  applicationId: this.#instanceConfig?.applicationId,
202
202
  instanceId: this.#instanceId,
203
203
  },
204
+ applicationLabel: this.#instanceConfig?.applicationMetricsLabel ?? 'serviceId'
204
205
  }
205
206
 
206
207
  if (this.#env.PLT_DISABLE_FLAMEGRAPHS !== true) {
@@ -251,11 +252,11 @@ class Watt {
251
252
  }
252
253
 
253
254
  #getTrafficInterceptorConfig () {
254
- if (!this.#instanceConfig?.iccServices?.trafficante?.url) {
255
+ if (!this.#instanceConfig?.iccServices?.trafficInspector?.url) {
255
256
  return
256
257
  }
257
- const { origin: trafficanteOrigin, pathname: trafficantePath } = new URL(
258
- this.#instanceConfig.iccServices.trafficante.url
258
+ const { origin: trafficInspectorOrigin, pathname: trafficInspectorPath } = new URL(
259
+ this.#instanceConfig.iccServices.trafficInspector.url
259
260
  )
260
261
  return {
261
262
  module: require.resolve(
@@ -271,9 +272,9 @@ class Watt {
271
272
  },
272
273
  maxResponseSize: 5 * 1024 * 1024, // 5MB
273
274
  trafficInspectorOptions: {
274
- url: trafficanteOrigin,
275
- pathSendBody: join(trafficantePath, '/requests'),
276
- pathSendMeta: join(trafficantePath, '/requests/hash'),
275
+ url: trafficInspectorOrigin,
276
+ pathSendBody: join(trafficInspectorPath, '/requests'),
277
+ pathSendMeta: join(trafficInspectorPath, '/requests/hash'),
277
278
  },
278
279
  matchingDomains: [this.#env.PLT_APP_INTERNAL_SUB_DOMAIN],
279
280
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/watt-extra",
3
- "version": "0.1.8-alpha.0",
3
+ "version": "0.1.9",
4
4
  "description": "The Platformatic runtime manager",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -13,25 +13,25 @@
13
13
  },
14
14
  "devDependencies": {
15
15
  "@fastify/websocket": "^11.1.0",
16
- "@platformatic/composer": "^3.0.5",
17
- "@platformatic/next": "^3.0.5",
18
- "@platformatic/node": "^3.0.5",
19
- "@platformatic/service": "^3.0.5",
16
+ "@platformatic/composer": "^3.3.0",
17
+ "@platformatic/next": "^3.3.0",
18
+ "@platformatic/node": "^3.3.0",
19
+ "@platformatic/service": "^3.3.0",
20
20
  "borp": "^0.20.0",
21
21
  "eslint": "9",
22
22
  "fastify": "^5.4.0",
23
23
  "fastify-plugin": "^5.0.1",
24
24
  "neostandard": "^0.12.0",
25
25
  "next": "^15.3.4",
26
- "platformatic": "^3.0.5",
26
+ "platformatic": "^3.3.0",
27
27
  "pprof-format": "^2.1.0",
28
28
  "why-is-node-running": "^2.3.0"
29
29
  },
30
30
  "dependencies": {
31
31
  "@datadog/pprof": "^5.9.0",
32
32
  "@fastify/error": "^4.2.0",
33
- "@platformatic/runtime": "^3.0.5",
34
- "@platformatic/wattpm-pprof-capture": "^3.0.5",
33
+ "@platformatic/runtime": "^3.3.0",
34
+ "@platformatic/wattpm-pprof-capture": "^3.3.0",
35
35
  "avvio": "^9.1.0",
36
36
  "chalk": "^4.1.2",
37
37
  "commist": "^3.2.0",
package/plugins/alerts.js CHANGED
@@ -4,6 +4,10 @@ async function alerts (app, _opts) {
4
4
  const healthCache = [] // It's OK to have this in memory, this is per-pod.
5
5
  const podHealthWindow =
6
6
  app.instanceConfig?.config?.scaler?.podHealthWindow || 60 * 1000
7
+ const alertRetentionWindow =
8
+ app.instanceConfig?.config?.scaler?.alertRetentionWindow || 10 * 1000
9
+
10
+ const lastServicesAlertTime = {}
7
11
 
8
12
  async function setupAlerts () {
9
13
  // Skip alerts setup if ICC is not configured
@@ -62,6 +66,17 @@ async function alerts (app, _opts) {
62
66
  // }
63
67
 
64
68
  if (healthInfo.unhealthy) {
69
+ const currentTime = Date.now()
70
+
71
+ const serviceId = healthInfo.id
72
+ const lastAlertTime = lastServicesAlertTime[serviceId]
73
+
74
+ if (lastAlertTime && currentTime - lastAlertTime < alertRetentionWindow) {
75
+ app.log.debug('Skipping alert, within retention window')
76
+ return
77
+ }
78
+
79
+ lastServicesAlertTime[serviceId] = currentTime
65
80
  delete healthInfo.healthConfig
66
81
 
67
82
  const authHeaders = await app.getAuthorizationHeader()
@@ -86,7 +101,6 @@ async function alerts (app, _opts) {
86
101
  }
87
102
 
88
103
  const alert = await body.json()
89
- const serviceId = healthInfo.id
90
104
 
91
105
  try {
92
106
  await app.sendFlamegraphs({
@@ -60,7 +60,7 @@ async function compliancy (app, _opts) {
60
60
  const { compliant, report } = JSON.parse(res.body)
61
61
 
62
62
  if (!compliant) {
63
- app.log.error(report, 'Compliancy check failed')
63
+ app.log.warn(report, 'Compliancy check failed')
64
64
  }
65
65
  }
66
66
  }
@@ -327,6 +327,94 @@ test('should not fail when health info is missing', async (t) => {
327
327
  assert.strictEqual(alertReceived, null, 'No alert should have been received')
328
328
  })
329
329
 
330
+ test('should respect alert retention window', async (t) => {
331
+ const applicationName = 'test-app'
332
+ const applicationId = randomUUID()
333
+ const applicationPath = join(__dirname, 'fixtures', 'service-1')
334
+
335
+ const alertsReceived = []
336
+
337
+ const getAuthorizationHeader = async (headers) => {
338
+ return { ...headers, authorization: 'Bearer test-token' }
339
+ }
340
+
341
+ const icc = await startICC(t, {
342
+ applicationId,
343
+ applicationName,
344
+ iccConfig: {
345
+ scaler: {
346
+ alertRetentionWindow: 500
347
+ }
348
+ },
349
+ processAlerts: (req) => {
350
+ const alert = req.body
351
+ assert.equal(req.headers.authorization, 'Bearer test-token')
352
+ alertsReceived.push(alert)
353
+ return { id: 'test-alert-id', ...alert }
354
+ }
355
+ })
356
+
357
+ setUpEnvironment({
358
+ PLT_APP_NAME: applicationName,
359
+ PLT_APP_DIR: applicationPath,
360
+ PLT_ICC_URL: 'http://127.0.0.1:3000'
361
+ })
362
+
363
+ const app = await start()
364
+
365
+ app.getAuthorizationHeader = getAuthorizationHeader
366
+
367
+ await app.setupAlerts()
368
+
369
+ t.after(async () => {
370
+ await app.close()
371
+ await icc.close()
372
+ })
373
+
374
+ // Create a health info template
375
+ const createHealthInfo = (serviceId, unhealthy = true) => ({
376
+ id: serviceId,
377
+ service: serviceId,
378
+ currentHealth: {
379
+ elu: unhealthy ? 0.95 : 0.5,
380
+ heapUsed: 76798040,
381
+ heapTotal: 99721216
382
+ },
383
+ unhealthy,
384
+ healthConfig: {
385
+ enabled: true,
386
+ interval: 1000,
387
+ gracePeriod: 1000,
388
+ maxUnhealthyChecks: 10,
389
+ maxELU: 0.99,
390
+ maxHeapUsed: 0.99,
391
+ maxHeapTotal: 4294967296
392
+ }
393
+ })
394
+
395
+ // Send first unhealthy event - should trigger alert
396
+ app.watt.runtime.emit('application:worker:health', createHealthInfo('service-1', true))
397
+ await sleep(50)
398
+
399
+ // Send second unhealthy event immediately - should trigger alert
400
+ app.watt.runtime.emit('application:worker:health', createHealthInfo('service-2', true))
401
+ await sleep(50)
402
+
403
+ // Send second unhealthy event immediately - should be ignored due to retention window
404
+ app.watt.runtime.emit('application:worker:health', createHealthInfo('service-1', true))
405
+ await sleep(100)
406
+
407
+ assert.strictEqual(alertsReceived.length, 2, 'Only one alert should be sent within retention window')
408
+
409
+ await sleep(500)
410
+
411
+ // Send third unhealthy event - should trigger second alert
412
+ app.watt.runtime.emit('application:worker:health', createHealthInfo('service-1', true))
413
+ await sleep(100)
414
+
415
+ assert.strictEqual(alertsReceived.length, 3, 'Second alert should be sent after retention window expires')
416
+ })
417
+
330
418
  test('should not set up alerts when scaler URL is missing', async (t) => {
331
419
  const applicationName = 'test-app'
332
420
  const applicationId = randomUUID()
@@ -128,7 +128,7 @@ test('should spawn an app with auto caching', async (t) => {
128
128
  assert.strictEqual(headers['x-foo-bar'], undefined)
129
129
  assert.strictEqual(headers['x-custom-cache-tags'], undefined)
130
130
 
131
- // Wait for interceptor to send data to the Trafficante
131
+ // Wait for interceptor to send data to the Traffic Inspector
132
132
  await sleep(1000)
133
133
 
134
134
  assert.strictEqual(savedRequestHashes.length, 1)
package/test/helper.js CHANGED
@@ -34,6 +34,7 @@ async function startICC (t, opts = {}) {
34
34
  let {
35
35
  applicationId,
36
36
  applicationName,
37
+ applicationMetricsLabel,
37
38
  iccServices,
38
39
  iccConfig = {},
39
40
  enableOpenTelemetry = false,
@@ -46,8 +47,8 @@ async function startICC (t, opts = {}) {
46
47
  riskEngine: {
47
48
  url: 'http://127.0.0.1:3000/risk-service'
48
49
  },
49
- trafficante: {
50
- url: 'http://127.0.0.1:3000/trafficante'
50
+ trafficInspector: {
51
+ url: 'http://127.0.0.1:3000/traffic-inspector'
51
52
  },
52
53
  compliance: {
53
54
  url: 'http://127.0.0.1:3000/compliance'
@@ -108,6 +109,7 @@ async function startICC (t, opts = {}) {
108
109
  return controlPlaneResponse || {
109
110
  applicationId,
110
111
  applicationName,
112
+ applicationMetricsLabel,
111
113
  iccServices,
112
114
  config: iccConfig,
113
115
  enableOpenTelemetry,
@@ -143,7 +145,7 @@ async function startICC (t, opts = {}) {
143
145
  })
144
146
  }, { prefix: '/compliance' })
145
147
 
146
- // Trafficante
148
+ // Traffic Inspector
147
149
  await icc.register(async (icc) => {
148
150
  icc.post('/requests/hash', async (req) => {
149
151
  const { taxonomyId, applicationId } = JSON.parse(req.headers['x-labels'])
@@ -159,7 +161,7 @@ async function startICC (t, opts = {}) {
159
161
 
160
162
  opts.saveRequest?.({ taxonomyId, applicationId, request, response })
161
163
  })
162
- }, { prefix: '/trafficante' })
164
+ }, { prefix: '/traffic-inspector' })
163
165
 
164
166
  // Risk Service
165
167
  await icc.register(async (icc) => {
package/test/init.test.js CHANGED
@@ -63,7 +63,7 @@ test('init plugin with optional PLT_APP_NAME - uses ICC response', async (t) =>
63
63
  applicationName, // ICC returns the application name
64
64
  iccServices: {
65
65
  riskEngine: { url: 'http://127.0.0.1:3000/risk-service' },
66
- trafficante: { url: 'http://127.0.0.1:3000/trafficante' },
66
+ trafficInspector: { url: 'http://127.0.0.1:3000/traffic-inspector' },
67
67
  compliance: { url: 'http://127.0.0.1:3000/compliance' },
68
68
  cron: { url: 'http://127.0.0.1:3000/cron' },
69
69
  scaler: { url: 'http://127.0.0.1:3000/scaler' }
@@ -166,7 +166,7 @@ test('init plugin sends correct request structure when PLT_APP_NAME provided', a
166
166
  applicationName,
167
167
  iccServices: {
168
168
  riskEngine: { url: 'http://127.0.0.1:3000/risk-service' },
169
- trafficante: { url: 'http://127.0.0.1:3000/trafficante' },
169
+ trafficInspector: { url: 'http://127.0.0.1:3000/traffic-inspector' },
170
170
  compliance: { url: 'http://127.0.0.1:3000/compliance' },
171
171
  cron: { url: 'http://127.0.0.1:3000/cron' },
172
172
  scaler: { url: 'http://127.0.0.1:3000/scaler' }
@@ -212,7 +212,7 @@ test('init plugin sends request without applicationName when not provided', asyn
212
212
  applicationName, // ICC provides the name
213
213
  iccServices: {
214
214
  riskEngine: { url: 'http://127.0.0.1:3000/risk-service' },
215
- trafficante: { url: 'http://127.0.0.1:3000/trafficante' },
215
+ trafficInspector: { url: 'http://127.0.0.1:3000/traffic-inspector' },
216
216
  compliance: { url: 'http://127.0.0.1:3000/compliance' },
217
217
  cron: { url: 'http://127.0.0.1:3000/cron' },
218
218
  scaler: { url: 'http://127.0.0.1:3000/scaler' }
@@ -0,0 +1,107 @@
1
+ import assert from 'node:assert'
2
+ import { test } from 'node:test'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { request } from 'undici'
5
+ import { join, dirname } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { setUpEnvironment, startICC } from './helper.js'
8
+ import { start } from '../index.js'
9
+
10
+ const __filename = fileURLToPath(import.meta.url)
11
+ const __dirname = dirname(__filename)
12
+
13
+ test('should generate metrics with a correct labels', async (t) => {
14
+ const applicationName = 'test-app'
15
+ const applicationId = randomUUID()
16
+ const applicationPath = join(__dirname, 'fixtures', 'service-1')
17
+
18
+ const icc = await startICC(t, {
19
+ applicationId,
20
+ applicationName
21
+ })
22
+
23
+ process.env.PLT_TEST_APP_1_URL = 'http://test-app-1:3042'
24
+ t.after(() => {
25
+ delete process.env.PLT_TEST_APP_1_URL
26
+ })
27
+
28
+ setUpEnvironment({
29
+ PLT_APP_NAME: applicationName,
30
+ PLT_APP_DIR: applicationPath,
31
+ PLT_ICC_URL: 'http://127.0.0.1:3000',
32
+ })
33
+
34
+ const app = await start()
35
+
36
+ t.after(async () => {
37
+ await app.close()
38
+ await icc.close()
39
+ })
40
+
41
+ const { statusCode, body } = await request('http://127.0.0.1:9090/metrics', {
42
+ headers: {
43
+ accept: 'application/json',
44
+ }
45
+ })
46
+ assert.strictEqual(statusCode, 200)
47
+
48
+ const metrics = await body.json()
49
+
50
+ {
51
+ const eluMetrics = metrics.find((metric) => metric.name === 'nodejs_eventloop_utilization')
52
+ assert.ok(eluMetrics)
53
+
54
+ const labels = eluMetrics.values[0].labels
55
+ assert.strictEqual(labels.applicationId, applicationId)
56
+ assert.strictEqual(labels.serviceId, 'main')
57
+ }
58
+ })
59
+
60
+ test('should generate metrics with a custom metrics label', async (t) => {
61
+ const applicationName = 'test-app'
62
+ const applicationId = randomUUID()
63
+ const applicationPath = join(__dirname, 'fixtures', 'service-1')
64
+ const applicationMetricsLabel = 'customLabel'
65
+
66
+ const icc = await startICC(t, {
67
+ applicationId,
68
+ applicationName,
69
+ applicationMetricsLabel
70
+ })
71
+
72
+ process.env.PLT_TEST_APP_1_URL = 'http://test-app-1:3042'
73
+ t.after(() => {
74
+ delete process.env.PLT_TEST_APP_1_URL
75
+ })
76
+
77
+ setUpEnvironment({
78
+ PLT_APP_NAME: applicationName,
79
+ PLT_APP_DIR: applicationPath,
80
+ PLT_ICC_URL: 'http://127.0.0.1:3000',
81
+ })
82
+
83
+ const app = await start()
84
+
85
+ t.after(async () => {
86
+ await app.close()
87
+ await icc.close()
88
+ })
89
+
90
+ const { statusCode, body } = await request('http://127.0.0.1:9090/metrics', {
91
+ headers: {
92
+ accept: 'application/json',
93
+ }
94
+ })
95
+ assert.strictEqual(statusCode, 200)
96
+
97
+ const metrics = await body.json()
98
+
99
+ {
100
+ const eluMetrics = metrics.find((metric) => metric.name === 'nodejs_eventloop_utilization')
101
+ assert.ok(eluMetrics)
102
+
103
+ const labels = eluMetrics.values[0].labels
104
+ assert.strictEqual(labels.applicationId, applicationId)
105
+ assert.strictEqual(labels[applicationMetricsLabel], 'main')
106
+ }
107
+ })
@@ -80,6 +80,7 @@ test('should spawn a service app settings labels for metrics', async (t) => {
80
80
  instanceId: app.instanceId,
81
81
  applicationId,
82
82
  },
83
+ applicationLabel: 'serviceId'
83
84
  }
84
85
  assert.deepStrictEqual(metrics, expectedMetrics)
85
86
  })
@@ -1,2 +0,0 @@
1
- ignoredBuiltDependencies:
2
- - better-sqlite3