Skip to content

Commit a6e523c

Browse files
authored
Expand the Node.js metrics script usage to v14.10+ (#1198)
* Expand the Node.js metrics script usage to v14+ * Update CHANGELOG.md * Added environment variable to opt-out of the metrics script * Fix sha files that had no ending newline [W-14838650](https://gus.lightning.force.com/lightning/r/a07EE00001iPkvfYAC/view)
1 parent 040b7d6 commit a6e523c

17 files changed

+358
-230
lines changed

.github/workflows/ci.yml

+4-2
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,14 @@ jobs:
6161
strategy:
6262
fail-fast: false
6363
matrix:
64-
version: [latest, lts/*, lts/-1]
64+
# check the minimum node version supported by the metrics script and the latest node version
65+
# (assumes the versions between have backwards-compatible APIs)
66+
version: [14.10.0, latest]
6567
name: Test Metrics (${{ matrix.version }})
6668
runs-on: ubuntu-latest
6769
steps:
6870
- uses: actions/checkout@v4
6971
- uses: actions/setup-node@v4
7072
with:
7173
node-version: ${{ matrix.version }}
72-
- run: node --test metrics/test/metrics.spec.cjs
74+
- run: npx mocha metrics/test/metrics.spec.cjs

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Added Yarn version 3.8.0.
66
- Added Yarn version 4.1.0.
77

8+
- Expand new metrics instrumentation to Node >= 14.10 ([#1198](https://github.com/heroku/heroku-buildpack-nodejs/pull/1198))
89

910
## [v235] - 2024-01-24
1011

bin/report

+2-2
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ kv_pair "lock_file" "$(meta_get "has-node-lock-file")"
113113
kv_pair "uses_workspaces" "$(json_has_key "$BUILD_DIR/package.json" "workspaces")"
114114
# what workspaces are defined? Logs as: `["packages/*","a","b"]`
115115
kv_pair_string "workspaces" "$(read_json "$BUILD_DIR/package.json" ".workspaces")"
116-
# count # of js, jsx, ts, coffee, vue, and html files to approximate project size, exclude any files in node_modules
117-
kv_pair "num_project_files" "$(find "$BUILD_DIR" -name '*.js' -o -name '*.ts' -o -name '*.jsx' -o -name '*.coffee' -o -name '*.vue' -o -name '*.html' | grep -cv node_modules | tr -d '[:space:]')"
116+
# count # of js, cjs, mjs, jsx, ts, coffee, vue, and html files to approximate project size, exclude any files in node_modules
117+
kv_pair "num_project_files" "$(find "$BUILD_DIR" -name '*.js' -o -name '*.cjs' -o -name '*.mjs' -o -name '*.ts' -o -name '*.jsx' -o -name '*.coffee' -o -name '*.vue' -o -name '*.html' | grep -cv node_modules | tr -d '[:space:]')"
118118
# measure how large node_modules is on disk
119119
kv_pair "node_modules_size" "$(measure_size)"
120120

lib/plugin.sh

+37-13
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,50 @@ get_node_major_version() {
44
node --version | cut -d "." -f 1 | sed 's/^v//'
55
}
66

7+
get_node_minor_version() {
8+
node --version | cut -d "." -f 2
9+
}
10+
711
install_plugin() {
812
local major
913
local bp_dir="$1"
1014
local build_dir="$2"
1115
major=$(get_node_major_version)
16+
minor=$(get_node_minor_version)
1217

13-
if [ "${major}" -lt "21" ]; then
14-
local plugin="${bp_dir}/plugin/heroku-nodejs-plugin-node-${major}.tar.gz"
15-
# If we have a version of the plugin compiled for this version of node, and the
16-
# user has not opted out of including the plugin, copy it into the slug.
17-
# It will be included at runtime once the user opts into the Node metrics feature
18-
if [[ -f "${plugin}" ]] && [[ -z "$HEROKU_SKIP_NODE_PLUGIN" ]]; then
19-
mkdir -p "${build_dir}/.heroku/"
20-
tar -xzf "${plugin}" -C "${build_dir}/.heroku/"
21-
fi
18+
if (( major < 14 )) || (( major == 14 && minor < 10 )); then
19+
install_native_plugin "$bp_dir" "$build_dir" "$major"
2220
else
23-
local pluginScript="${bp_dir}/metrics/metrics_collector.cjs"
24-
if [[ -f "${pluginScript}" ]] && [[ -z "$HEROKU_SKIP_NODE_PLUGIN" ]]; then
25-
mkdir -p "${build_dir}/.heroku/metrics"
26-
cp "${pluginScript}" "${build_dir}/.heroku/metrics/"
21+
if [[ -n "$HEROKU_LEGACY_NODE_PLUGIN" ]] && (( major < 21 )); then
22+
warn "The native addon for Node.js language metrics is no longer supported. Unset the HEROKU_LEGACY_NODE_PLUGIN environment variable to migrate to the new metrics collector."
23+
install_native_plugin "$bp_dir" "$build_dir" "$major"
24+
else
25+
install_script_plugin "$bp_dir" "$build_dir"
2726
fi
2827
fi
2928
}
29+
30+
# Node.js versions < 14.10.0 must use the prebuilt native addon
31+
install_native_plugin() {
32+
local bp_dir="$1"
33+
local build_dir="$2"
34+
local major="$3"
35+
local plugin="${bp_dir}/plugin/heroku-nodejs-plugin-node-${major}.tar.gz"
36+
# If we have a version of the plugin compiled for this version of node, and the
37+
# user has not opted out of including the plugin, copy it into the slug.
38+
# It will be included at runtime once the user opts into the Node metrics feature
39+
if [[ -f "${plugin}" ]] && [[ -z "$HEROKU_SKIP_NODE_PLUGIN" ]]; then
40+
mkdir -p "${build_dir}/.heroku/"
41+
tar -xzf "${plugin}" -C "${build_dir}/.heroku/"
42+
fi
43+
}
44+
# Node.js versions >= 14.10.0 can use the metrics script
45+
install_script_plugin() {
46+
local bp_dir="$1"
47+
local build_dir="$2"
48+
local pluginScript="${bp_dir}/metrics/metrics_collector.cjs"
49+
if [[ -f "${pluginScript}" ]] && [[ -z "$HEROKU_SKIP_NODE_PLUGIN" ]]; then
50+
mkdir -p "${build_dir}/.heroku/metrics"
51+
cp "${pluginScript}" "${build_dir}/.heroku/metrics/"
52+
fi
53+
}

metrics/metrics_collector.cjs

+80-45
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,18 @@
2121
* gauges: EventLoopGauges;
2222
* }} MetricsPayload
2323
*/
24-
const { setInterval } = require('node:timers')
25-
const { URL } = require('node:url');
26-
const { debuglog } = require('node:util');
27-
const { monitorEventLoopDelay, PerformanceObserver, constants, performance } = require('node:perf_hooks')
24+
const { setInterval } = require('timers')
25+
const { URL } = require('url');
26+
const { debuglog } = require('util');
27+
const { monitorEventLoopDelay, PerformanceObserver, constants, performance } = require('perf_hooks')
28+
const { request: insecureRequest } = require('http');
29+
const { request: secureRequest } = require('https');
2830

2931
try {
3032
// failures from the instrumentation shouldn't mess with the application
3133
registerInstrumentation()
3234
} catch (e) {
33-
log(`An unexpected error occurred: ${e.message}`)
35+
log(`An unexpected error occurred: ${e.stack}`)
3436
}
3537

3638
/**
@@ -53,33 +55,33 @@ function registerInstrumentation() {
5355
const gcObserver = new PerformanceObserver((value) => {
5456
value.getEntries().forEach(entry => updateMemoryCounters(memoryCounters, entry))
5557
})
56-
gcObserver.observe({ type: 'gc' })
58+
gcObserver.observe({ entryTypes: ['gc'] })
5759

5860
const eventLoopHistogram = monitorEventLoopDelay()
5961
eventLoopHistogram.enable()
6062

6163
let previousEventLoopUtilization = performance.eventLoopUtilization()
6264

6365
const timeout = setInterval(() => {
64-
const eventLoopUtilization = performance.eventLoopUtilization(previousEventLoopUtilization)
65-
eventLoopHistogram.disable()
66-
gcObserver.disconnect()
67-
68-
const payload = {
69-
counters: { ...memoryCounters },
70-
gauges: captureEventLoopGauges(eventLoopUtilization, eventLoopHistogram)
71-
}
66+
try {
67+
const eventLoopUtilization = performance.eventLoopUtilization(previousEventLoopUtilization)
68+
eventLoopHistogram.disable()
69+
gcObserver.disconnect()
7270

73-
sendMetrics(herokuMetricsUrl, payload).catch((err) => {
74-
log(`An error occurred while sending metrics - ${err}`)
75-
})
71+
sendMetrics(herokuMetricsUrl, {
72+
counters: {...memoryCounters},
73+
gauges: captureEventLoopGauges(eventLoopUtilization, eventLoopHistogram)
74+
})
7675

77-
// reset memory and event loop measures
78-
previousEventLoopUtilization = eventLoopUtilization
79-
memoryCounters = initializeMemoryCounters()
80-
gcObserver.observe({ type: 'gc' })
81-
eventLoopHistogram.reset()
82-
eventLoopHistogram.enable()
76+
// reset memory and event loop measures
77+
previousEventLoopUtilization = eventLoopUtilization
78+
memoryCounters = initializeMemoryCounters()
79+
gcObserver.observe({ entryTypes: ['gc'] })
80+
eventLoopHistogram.reset()
81+
eventLoopHistogram.enable()
82+
} catch (e) {
83+
log(`An unexpected error occurred: ${e.stack}`)
84+
}
8385
}, herokuMetricsInterval)
8486

8587
// `setInterval` actually returns a Timeout object but this isn't recognized by the type-checker which
@@ -171,19 +173,31 @@ function initializeMemoryCounters(){
171173
* @param {PerformanceEntry} performanceEntry
172174
*/
173175
function updateMemoryCounters(memoryCounters, performanceEntry) {
174-
// only record entries with that contain a 'detail' object with 'kind' property
175-
// since these are only available for NodeGCPerformanceDetail entries
176-
if ('detail' in performanceEntry && 'kind' in performanceEntry.detail) {
177-
const nsDuration = millisecondsToNanoseconds(performanceEntry.duration)
178-
memoryCounters['node.gc.collections'] += 1
179-
memoryCounters['node.gc.pause.ns'] += nsDuration
180-
if (performanceEntry.detail.kind === constants.NODE_PERFORMANCE_GC_MINOR) {
181-
memoryCounters['node.gc.young.collections'] += 1
182-
memoryCounters['node.gc.young.pause.ns'] += nsDuration
183-
} else {
184-
memoryCounters['node.gc.old.collections'] += 1
185-
memoryCounters['node.gc.old.pause.ns'] += nsDuration
186-
}
176+
const nsDuration = millisecondsToNanoseconds(performanceEntry.duration)
177+
memoryCounters['node.gc.collections'] += 1
178+
memoryCounters['node.gc.pause.ns'] += nsDuration
179+
if (getGcPerformanceEntryKind(performanceEntry) === constants.NODE_PERFORMANCE_GC_MINOR) {
180+
memoryCounters['node.gc.young.collections'] += 1
181+
memoryCounters['node.gc.young.pause.ns'] += nsDuration
182+
} else {
183+
memoryCounters['node.gc.old.collections'] += 1
184+
memoryCounters['node.gc.old.pause.ns'] += nsDuration
185+
}
186+
}
187+
188+
/**
189+
* Reads the `kind` field for the 'gc' performance entry in a backwards-compatible way
190+
* @param {PerformanceEntry} performanceEntry
191+
* @returns {number}
192+
*/
193+
function getGcPerformanceEntryKind(performanceEntry) {
194+
// using try/catch to avoid triggering deprecation warnings
195+
try {
196+
// for v16 and up
197+
return performanceEntry.detail.kind
198+
} catch (e) {
199+
// fallback for v14 & v15
200+
return performanceEntry.kind
187201
}
188202
}
189203

@@ -209,7 +223,7 @@ function captureEventLoopGauges(eventLoopUtilization, eventLoopHistogram) {
209223
* @return {number}
210224
*/
211225
function millisecondsToNanoseconds(ms) {
212-
return ms * 1_000_000
226+
return ms * 1e6 // 1_000_000
213227
}
214228

215229
/**
@@ -218,26 +232,47 @@ function millisecondsToNanoseconds(ms) {
218232
* @return {number}
219233
*/
220234
function nanosecondsToMilliseconds(ns) {
221-
return ns / 1_000_000
235+
return ns / 1e6 // 1_000_000
222236
}
223237

224238
/**
225239
* Sends the collected metrics to the given endpoint using a POST request.
226240
* @param {URL} url
227241
* @param {MetricsPayload} payload
228-
* @returns {Promise<void>}
242+
* @returns void
229243
*/
230244
function sendMetrics(url, payload) {
245+
const request = url.protocol === 'https:' ? secureRequest : insecureRequest
246+
const payloadAsJson = JSON.stringify(payload)
247+
231248
log(`Sending metrics to ${url.toString()}`)
232-
return fetch(url, {
249+
const clientRequest = request({
233250
method: 'POST',
234-
headers: { 'Content-Type': 'application/json' },
235-
body: JSON.stringify(payload)
236-
}).then(res => {
237-
if (res.ok) {
251+
protocol: url.protocol,
252+
hostname: url.hostname,
253+
port: url.port,
254+
path: url.pathname,
255+
headers: {
256+
'Content-Type': 'application/json',
257+
'Content-Length': Buffer.byteLength(payloadAsJson)
258+
}
259+
})
260+
261+
clientRequest.on('response', (res) => {
262+
if (res.statusCode === 200) {
238263
log('Metrics sent successfully')
239264
} else {
240-
log(`Tried to send metrics but response was: ${res.status} - ${res.statusText}`)
265+
log(`Tried to send metrics but response was: ${res.statusCode} - ${res.statusMessage}`)
241266
}
267+
// consume response data to free up memory
268+
// see: https://nodejs.org/docs/latest/api/http.html#http_class_http_clientrequest
269+
res.resume()
242270
})
271+
272+
clientRequest.on('error', (err) => {
273+
log(`An error occurred while sending metrics - ${err}`)
274+
})
275+
276+
clientRequest.write(payloadAsJson)
277+
clientRequest.end()
243278
}

metrics/test/fixtures/clustered_app.cjs

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
const { isPrimary, fork } = require('node:cluster')
1+
const cluster = require('cluster')
22

33
require('./_cpu_and_memory_simulator.cjs')
44

5-
if (isPrimary) {
5+
if (
6+
cluster.isPrimary ||
7+
cluster.isMaster // deprecated in Node v16
8+
) {
69
console.log(`Starting primary cluster ${process.pid} running with NODE_OPTIONS="${process.env.NODE_OPTIONS || ''}"`)
7-
fork()
10+
cluster.fork()
811
process.on('SIGINT', () => {
912
process.exit(0)
1013
})

metrics/test/fixtures/worker_threads_app.cjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const { Worker, isMainThread} = require('node:worker_threads')
1+
const { Worker, isMainThread} = require('worker_threads')
22

33
require('./_cpu_and_memory_simulator.cjs')
44

0 commit comments

Comments
 (0)