Skip to content

Commit e93feb4

Browse files
author
Lukas Holzer
authored
feat: instrument child process with open telemetry (#5556)
* feat: instrument child process with open telemetry * chore: fix import in tests as location changed in @opentelemetry/[email protected] * chore: fix test * chore: update service name and only enable tracing for trusted plugins * chore: fix tests * chore: stop tracing only if connected to child * chore: fix last tests * chore: pr feedback from joao * chore: update * chore: only instrument the child process for node 18.18 and above * chore: fix windows file urls * chore: remove build command from telemetry
1 parent 1cee56d commit e93feb4

File tree

18 files changed

+506
-1053
lines changed

18 files changed

+506
-1053
lines changed

package-lock.json

Lines changed: 240 additions & 945 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/build/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@
127127
},
128128
"devDependencies": {
129129
"@netlify/nock-udp": "^3.1.2",
130-
"@opentelemetry/sdk-trace-base": "^1.18.1",
131-
"@opentelemetry/api": "^1.7.0",
130+
"@opentelemetry/sdk-trace-base": "~1.22.0",
131+
"@opentelemetry/api": "~1.8.0",
132132
"@types/node": "^14.18.53",
133133
"@vitest/coverage-c8": "^0.33.0",
134134
"atob": "^2.1.2",
@@ -153,7 +153,7 @@
153153
"yarn": "^1.22.4"
154154
},
155155
"peerDependencies": {
156-
"@opentelemetry/api": "^1.7.0",
156+
"@opentelemetry/api": "~1.8.0",
157157
"@netlify/opentelemetry-sdk-setup": "^1.0.5"
158158
},
159159
"peerDependenciesMeta": {

packages/build/src/core/build.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,13 @@ const initAndRunBuild = async function ({
543543
// the build is finished. The exception is when running in the dev timeline
544544
// since those are long-running events by nature.
545545
if (timeline !== 'dev') {
546-
stopPlugins(childProcesses)
546+
await stopPlugins({
547+
childProcesses,
548+
pluginOptions: pluginsOptionsA,
549+
netlifyConfig,
550+
logs,
551+
verbose,
552+
})
547553
}
548554
}
549555
}

packages/build/src/plugins/child/main.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { setInspectColors } from '../../log/colors.js'
2-
import { sendEventToParent, getEventsFromParent } from '../ipc.js'
2+
import { getEventsFromParent, sendEventToParent } from '../ipc.js'
33

4-
import { handleProcessErrors, handleError } from './error.js'
4+
import { handleError, handleProcessErrors } from './error.js'
55
import { load } from './load.js'
66
import { run } from './run.js'
77

@@ -46,6 +46,20 @@ const handleEvent = async function ({
4646
}
4747
}
4848

49-
const EVENTS = { load, run }
49+
const EVENTS = {
50+
load,
51+
run,
52+
// async shutdown hook to stop tracing reliably
53+
shutdown: async () => {
54+
try {
55+
const { stopTracing } = await import('@netlify/opentelemetry-sdk-setup')
56+
await stopTracing()
57+
} catch {
58+
// noop as the opentelemetry-sdk-setup is an optional dependency
59+
// and might not be present in the CLI
60+
}
61+
return { context: {} }
62+
},
63+
}
5064

5165
bootPlugin()
Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,47 @@
1+
import { getGlobalContext, setGlobalContext } from '@netlify/opentelemetry-utils'
2+
import { context, propagation } from '@opentelemetry/api'
3+
14
import { getNewEnvChanges, setEnvChanges } from '../../env/changes.js'
2-
import { logPluginMethodStart, logPluginMethodEnd } from '../../log/messages/ipc.js'
5+
import { logPluginMethodEnd, logPluginMethodStart } from '../../log/messages/ipc.js'
36

47
import { cloneNetlifyConfig, getConfigMutations } from './diff.js'
58
import { getSystemLog } from './systemLog.js'
69
import { getUtils } from './utils.js'
710

8-
// Run a specific plugin event handler
11+
/** Run a specific plugin event handler */
912
export const run = async function (
10-
{ event, error, constants, envChanges, featureFlags, netlifyConfig },
13+
{ event, error, constants, envChanges, featureFlags, netlifyConfig, otelCarrier },
1114
{ methods, inputs, packageJson, verbose },
1215
) {
13-
const method = methods[event]
14-
const runState = {}
15-
const systemLog = getSystemLog()
16-
const utils = getUtils({ event, constants, runState })
17-
const netlifyConfigCopy = cloneNetlifyConfig(netlifyConfig)
18-
const runOptions = {
19-
utils,
20-
constants,
21-
inputs,
22-
netlifyConfig: netlifyConfigCopy,
23-
packageJson,
24-
error,
25-
featureFlags,
26-
systemLog,
27-
}
28-
29-
const envBefore = setEnvChanges(envChanges)
30-
31-
logPluginMethodStart(verbose)
32-
await method(runOptions)
33-
logPluginMethodEnd(verbose)
34-
35-
const newEnvChanges = getNewEnvChanges(envBefore, netlifyConfig, netlifyConfigCopy)
36-
37-
const configMutations = getConfigMutations(netlifyConfig, netlifyConfigCopy, event)
38-
return { ...runState, newEnvChanges, configMutations }
16+
setGlobalContext(propagation.extract(context.active(), otelCarrier))
17+
18+
// set the global context for the plugin run
19+
return context.with(getGlobalContext(), async () => {
20+
const method = methods[event]
21+
const runState = {}
22+
const systemLog = getSystemLog()
23+
const utils = getUtils({ event, constants, runState })
24+
const netlifyConfigCopy = cloneNetlifyConfig(netlifyConfig)
25+
const runOptions = {
26+
utils,
27+
constants,
28+
inputs,
29+
netlifyConfig: netlifyConfigCopy,
30+
packageJson,
31+
error,
32+
featureFlags,
33+
systemLog,
34+
}
35+
36+
const envBefore = setEnvChanges(envChanges)
37+
38+
logPluginMethodStart(verbose)
39+
await method(runOptions)
40+
logPluginMethodEnd(verbose)
41+
42+
const newEnvChanges = getNewEnvChanges(envBefore, netlifyConfig, netlifyConfigCopy)
43+
44+
const configMutations = getConfigMutations(netlifyConfig, netlifyConfigCopy, event)
45+
return { ...runState, newEnvChanges, configMutations }
46+
})
3947
}

packages/build/src/plugins/node_version.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type PluginsOptions = {
1616
loadedFrom: PluginsLoadedFrom
1717
origin: 'config' | string
1818
inputs: Record<string, any>
19+
pluginPackageJson?: Record<string, any>
1920
}
2021

2122
/**
@@ -90,9 +91,9 @@ const addPluginNodeVersion = async function ({
9091
logWarning(
9192
logs,
9293
` We're upgrading our system node version on that day, which means the plugin cannot be executed with your defined Node.js version ${userNodeVersion}.
93-
94+
9495
Please make sure your plugin supports being run on Node.js 20.
95-
96+
9697
Read more about our minimum required version in our ${link(
9798
'forums announcement',
9899
'https://answers.netlify.com/t/build-plugin-update-system-node-js-version-upgrade-to-20/108633',
@@ -131,7 +132,7 @@ const addPluginNodeVersion = async function ({
131132
logWarning(
132133
logs,
133134
` The plugin cannot be executed with your defined Node.js version ${userNodeVersion}
134-
135+
135136
Read more about our minimum required version in our ${link(
136137
'forums announcement',
137138
'https://answers.netlify.com/t/build-plugins-dropping-support-for-node-js-12/79421',

packages/build/src/plugins/spawn.ts

Lines changed: 114 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,31 @@
1-
import { fileURLToPath } from 'url'
1+
import { createRequire } from 'module'
2+
import { fileURLToPath, pathToFileURL } from 'url'
23

3-
import { execaNode } from 'execa'
4+
import { trace } from '@opentelemetry/api'
5+
import { ExecaChildProcess, execaNode } from 'execa'
6+
import { gte } from 'semver'
47

58
import { addErrorInfo } from '../error/info.js'
9+
import { NetlifyConfig } from '../index.js'
10+
import { BufferedLogs } from '../log/logger.js'
611
import {
7-
logRuntime,
8-
logLoadingPlugins,
9-
logOutdatedPlugins,
1012
logIncompatiblePlugins,
1113
logLoadingIntegration,
14+
logLoadingPlugins,
15+
logOutdatedPlugins,
16+
logRuntime,
1217
} from '../log/messages/compatibility.js'
1318
import { isTrustedPlugin } from '../steps/plugin.js'
1419
import { measureDuration } from '../time/main.js'
1520

16-
import { getEventFromChild } from './ipc.js'
21+
import { callChild, getEventFromChild } from './ipc.js'
22+
import { PluginsOptions } from './node_version.js'
1723
import { getSpawnInfo } from './options.js'
1824

1925
const CHILD_MAIN_FILE = fileURLToPath(new URL('child/main.js', import.meta.url))
2026

27+
const require = createRequire(import.meta.url)
28+
2129
// Start child processes used by all plugins
2230
// We fire plugins through child processes so that:
2331
// - each plugin is sandboxed, e.g. cannot access/modify its parent `process`
@@ -35,25 +43,72 @@ const tStartPlugins = async function ({ pluginsOptions, buildDir, childEnv, logs
3543
logIncompatiblePlugins(logs, pluginsOptions)
3644

3745
const childProcesses = await Promise.all(
38-
pluginsOptions.map(({ pluginDir, nodePath, pluginPackageJson }) =>
39-
startPlugin({ pluginDir, nodePath, buildDir, childEnv, systemLogFile, pluginPackageJson }),
46+
pluginsOptions.map(({ pluginDir, nodePath, nodeVersion, pluginPackageJson }) =>
47+
startPlugin({ pluginDir, nodePath, nodeVersion, buildDir, childEnv, systemLogFile, pluginPackageJson }),
4048
),
4149
)
4250
return { childProcesses }
4351
}
4452

4553
export const startPlugins = measureDuration(tStartPlugins, 'start_plugins')
4654

47-
const startPlugin = async function ({ pluginDir, nodePath, buildDir, childEnv, systemLogFile, pluginPackageJson }) {
48-
const childProcess = execaNode(CHILD_MAIN_FILE, [], {
55+
const startPlugin = async function ({
56+
pluginDir,
57+
nodeVersion,
58+
nodePath,
59+
buildDir,
60+
childEnv,
61+
systemLogFile,
62+
pluginPackageJson,
63+
}: {
64+
nodeVersion: string
65+
nodePath: string
66+
pluginDir: string
67+
/** The process cwd that is used to spawn the child process */
68+
buildDir: string
69+
childEnv: Record<string, string>
70+
pluginPackageJson: Record<string, string>
71+
systemLogFile: number
72+
}) {
73+
const ctx = trace.getActiveSpan()?.spanContext()
74+
75+
// the baggage will be passed to the child process when sending the run event
76+
const args = [
77+
...process.argv.filter((arg) => arg.startsWith('--tracing')),
78+
`--tracing.traceId=${ctx?.traceId}`,
79+
`--tracing.parentSpanId=${ctx?.spanId}`,
80+
`--tracing.traceFlags=${ctx?.traceFlags}`,
81+
`--tracing.enabled=${!!isTrustedPlugin(pluginPackageJson?.name)}`,
82+
]
83+
84+
const nodeOptions: string[] = []
85+
86+
// the sdk setup is a optional dependency that might not exist
87+
// only use it if it exists
88+
try {
89+
// the --import preloading is only available in node 18.18.0 and above
90+
// plugins that run on a lower node version will not be able to be instrumented with opentelemetry
91+
if (gte(nodeVersion, '18.18.0')) {
92+
const entry = require.resolve('@netlify/opentelemetry-sdk-setup/bin.js')
93+
// on windows only file:// urls are allowed
94+
nodeOptions.push('--import', pathToFileURL(entry).toString())
95+
}
96+
} catch {
97+
// noop
98+
}
99+
100+
const childProcess = execaNode(CHILD_MAIN_FILE, args, {
49101
cwd: buildDir,
50102
preferLocal: true,
51103
localDir: pluginDir,
52104
nodePath,
53-
// make sure we don't pass build's node cli properties for now (e.g. --import)
54-
nodeOptions: [],
105+
nodeOptions,
55106
execPath: nodePath,
56-
env: childEnv,
107+
env: {
108+
...childEnv,
109+
OTEL_SERVICE_NAME: pluginPackageJson?.name,
110+
OTEL_SERVICE_VERSION: pluginPackageJson?.version,
111+
},
57112
extendEnv: false,
58113
stdio:
59114
isTrustedPlugin(pluginPackageJson?.name) && systemLogFile
@@ -72,14 +127,56 @@ const startPlugin = async function ({ pluginDir, nodePath, buildDir, childEnv, s
72127
}
73128

74129
// Stop all plugins child processes
75-
export const stopPlugins = function (childProcesses) {
76-
childProcesses.forEach(stopPlugin)
130+
export const stopPlugins = async function ({
131+
childProcesses,
132+
logs,
133+
verbose,
134+
pluginOptions,
135+
netlifyConfig,
136+
}: {
137+
logs: BufferedLogs
138+
verbose: boolean
139+
childProcesses: { childProcess: ExecaChildProcess }[]
140+
pluginOptions: PluginsOptions[]
141+
netlifyConfig: NetlifyConfig
142+
}) {
143+
await Promise.all(
144+
childProcesses.map(({ childProcess }, index) => {
145+
return stopPlugin({ childProcess, verbose, logs, pluginOptions: pluginOptions[index], netlifyConfig })
146+
}),
147+
)
77148
}
78149

79-
const stopPlugin = function ({ childProcess }) {
150+
const stopPlugin = async function ({
151+
childProcess,
152+
logs,
153+
pluginOptions: { packageName, inputs, pluginPath, pluginPackageJson: packageJson = {} },
154+
netlifyConfig,
155+
verbose,
156+
}: {
157+
childProcess: ExecaChildProcess
158+
pluginOptions: PluginsOptions
159+
netlifyConfig: NetlifyConfig
160+
verbose: boolean
161+
logs: BufferedLogs
162+
}) {
80163
if (childProcess.connected) {
164+
// reliable stop tracing inside child processes
165+
await callChild({
166+
childProcess,
167+
eventName: 'shutdown',
168+
payload: {
169+
packageName,
170+
pluginPath,
171+
inputs,
172+
packageJson,
173+
verbose,
174+
netlifyConfig,
175+
},
176+
logs,
177+
verbose,
178+
})
81179
childProcess.disconnect()
82180
}
83-
84181
childProcess.kill()
85182
}

0 commit comments

Comments
 (0)