@uknows/setup 0.0.0-stage → 0.2.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/LICENSE +190 -0
- package/README.md +209 -2
- package/bin/uknows-setup.mjs +14 -0
- package/lib/automate.mjs +1299 -0
- package/lib/check.mjs +327 -0
- package/lib/ci.mjs +21 -0
- package/lib/cli.mjs +251 -0
- package/lib/cloudid.mjs +456 -0
- package/lib/code.mjs +147 -0
- package/lib/context.mjs +126 -0
- package/lib/contract-docs.mjs +2736 -0
- package/lib/contract.mjs +149 -0
- package/lib/detect.mjs +627 -0
- package/lib/diff.mjs +147 -0
- package/lib/doc.mjs +98 -0
- package/lib/dockerfile.mjs +455 -0
- package/lib/endpoint.mjs +81 -0
- package/lib/engine.mjs +1753 -0
- package/lib/existing.mjs +562 -0
- package/lib/git.mjs +100 -0
- package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
- package/lib/hooks/pins.mjs +28 -0
- package/lib/hooks/uknows-otel.cjs +82 -0
- package/lib/hooks/uknows_otel.py +179 -0
- package/lib/hosts.mjs +650 -0
- package/lib/locks.mjs +353 -0
- package/lib/manifest.mjs +132 -0
- package/lib/members.mjs +269 -0
- package/lib/plan.mjs +130 -0
- package/lib/settings.mjs +468 -0
- package/lib/signin.mjs +259 -0
- package/lib/stacks/azure.mjs +345 -0
- package/lib/stacks/channel.mjs +125 -0
- package/lib/stacks/dotnet.mjs +472 -0
- package/lib/stacks/functions-hooks.mjs +228 -0
- package/lib/stacks/functions.mjs +148 -0
- package/lib/stacks/guided.mjs +227 -0
- package/lib/stacks/index.mjs +18 -0
- package/lib/stacks/java.mjs +57 -0
- package/lib/stacks/k8s.mjs +31 -0
- package/lib/stacks/node.mjs +358 -0
- package/lib/stacks/otel.mjs +63 -0
- package/lib/stacks/python-bootstrap.mjs +71 -0
- package/lib/stacks/python-lock.mjs +105 -0
- package/lib/stacks/python-sets.mjs +143 -0
- package/lib/stacks/python.mjs +632 -0
- package/lib/toml.mjs +64 -0
- package/lib/types.mjs +208 -0
- package/lib/ui.mjs +225 -0
- package/lib/updates.mjs +398 -0
- package/lib/versions.mjs +115 -0
- package/lib/workspace.mjs +201 -0
- package/package.json +37 -4
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Java: copy the pinned OpenTelemetry Java agent into the final image with the docs COPY --from line
|
|
2
|
+
// (the image digest pins the jar) and load it with JAVA_TOOL_OPTIONS. Build files are never touched.
|
|
3
|
+
// Both lines go after the last RUN, so build steps (mvn, gradle) never load the agent.
|
|
4
|
+
import { JAVA_AGENT, STACKS } from '../contract.mjs'
|
|
5
|
+
import { addOption, insertBeforeStart, parseDockerfile } from '../dockerfile.mjs'
|
|
6
|
+
import { envOverrides } from './k8s.mjs'
|
|
7
|
+
|
|
8
|
+
const DOCS = STACKS.find((s) => s.id === 'java')
|
|
9
|
+
const COPY_LINE = DOCS.dockerfile.split('\n').find((l) => l.startsWith('COPY --from='))
|
|
10
|
+
// 0.1.0 wrote an ADD line with --checksum and --chmod, which need BuildKit. Earlier /docs gave --chmod=644.
|
|
11
|
+
// A Dockerfile with one of those exact lines gets the COPY line instead.
|
|
12
|
+
const ADD_LINE = `ADD --checksum=sha256:${JAVA_AGENT.sha256} --chmod=755 ${JAVA_AGENT.url} /otel/${JAVA_AGENT.file}`
|
|
13
|
+
const OLD_LINES = [ADD_LINE, ADD_LINE.replace('--chmod=755', '--chmod=644')]
|
|
14
|
+
const AGENT_OPTION = /JAVA_TOOL_OPTIONS="([^"]+)"/.exec(DOCS.dockerfile)[1]
|
|
15
|
+
const AGENT_PATH = AGENT_OPTION.replace(/^-javaagent:/, '')
|
|
16
|
+
const note = (re) => DOCS.notes.find((n) => re.test(n))
|
|
17
|
+
|
|
18
|
+
/** @type {import('../types.mjs').Applier} */
|
|
19
|
+
export const java = {
|
|
20
|
+
id: 'java',
|
|
21
|
+
plan(service, ctx) {
|
|
22
|
+
const plan = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] }
|
|
23
|
+
const text = service.dockerfile && ctx.ws.read(service.dockerfile)
|
|
24
|
+
const info = text ? parseDockerfile(text) : null
|
|
25
|
+
const image = info?.image ?? ''
|
|
26
|
+
if (!text || /windows|nanoserver|servercore/i.test(image)) {
|
|
27
|
+
plan.manual.push({ title: `Add the OpenTelemetry Java agent ${JAVA_AGENT.version}`, lines: [
|
|
28
|
+
text ? `The final image ${image} is a Windows image. This tool adds the agent to Linux images only.`
|
|
29
|
+
: 'This app does not build from a Dockerfile, so this tool cannot add the agent.',
|
|
30
|
+
'Download the agent and check its checksum with the commands below.', note(/macOS/),
|
|
31
|
+
`Start the JVM with ${AGENT_OPTION.replace(AGENT_PATH, `<path to ${JAVA_AGENT.file}>`)}, or set it in JAVA_TOOL_OPTIONS.`,
|
|
32
|
+
note(/Put -javaagent before -jar/)],
|
|
33
|
+
snippets: [{ caption: 'Download and check the agent', language: 'bash', code: DOCS.install },
|
|
34
|
+
{ caption: 'Start', language: 'bash', code: DOCS.start }] })
|
|
35
|
+
return plan
|
|
36
|
+
}
|
|
37
|
+
const has = (value) => value.includes(AGENT_OPTION)
|
|
38
|
+
plan.manual.push(...envOverrides(service, 'JAVA_TOOL_OPTIONS', AGENT_OPTION, has))
|
|
39
|
+
const fixed = OLD_LINES.reduce((out, line) => out.split(line).join(COPY_LINE), text)
|
|
40
|
+
const added = [JAVA_AGENT.url, JAVA_AGENT.image, AGENT_PATH].some((s) => fixed.includes(s)) ? fixed : insertBeforeStart(fixed, [COPY_LINE])
|
|
41
|
+
const after = addOption(added, 'JAVA_TOOL_OPTIONS', AGENT_OPTION, has)
|
|
42
|
+
if (after === text) return plan
|
|
43
|
+
if (after === fixed) {
|
|
44
|
+
plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Copy the OpenTelemetry Java agent from the pinned image.' })
|
|
45
|
+
plan.summary.push(`Replace the ADD line of the agent in ${service.dockerfile} with a COPY line from the pinned OpenTelemetry image. It needs no BuildKit.`)
|
|
46
|
+
return plan
|
|
47
|
+
}
|
|
48
|
+
plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Add the OpenTelemetry Java agent and load it when the JVM starts.' })
|
|
49
|
+
plan.summary.push(`Add the OpenTelemetry Java agent ${JAVA_AGENT.version} to ${service.dockerfile}, from the image pinned by its digest.`,
|
|
50
|
+
`Load the agent when the JVM starts (JAVA_TOOL_OPTIONS in ${service.dockerfile}).`)
|
|
51
|
+
plan.notes.push(note(/digest pins/))
|
|
52
|
+
if (info.env.has('JAVA_TOOL_OPTIONS') || info.inherited.has('JAVA_TOOL_OPTIONS')) {
|
|
53
|
+
plan.notes.push(`${service.dockerfile} already sets JAVA_TOOL_OPTIONS. A new ENV line before the start keeps that value and adds the agent.`)
|
|
54
|
+
}
|
|
55
|
+
return plan
|
|
56
|
+
},
|
|
57
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Kubernetes containers that replace what this tool sets in the image: an env value, or the start command.
|
|
2
|
+
// The image edit stays (it works with docker run). These steps say what to change in the manifest too.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Manual steps for containers whose env sets `name`, which replaces the image value that loads OpenTelemetry.
|
|
6
|
+
* @param {import('../types.mjs').Service} service
|
|
7
|
+
* @param {string} name e.g. NODE_OPTIONS
|
|
8
|
+
* @param {string} option what the value needs, e.g. --require ...
|
|
9
|
+
* @param {(value: string) => boolean} has true when a value already has it
|
|
10
|
+
* @returns {import('../types.mjs').Block[]}
|
|
11
|
+
*/
|
|
12
|
+
export function envOverrides(service, name, option, has) {
|
|
13
|
+
return (service.k8s ?? []).filter((k) => k.env && Object.prototype.hasOwnProperty.call(k.env, name) && !(k.env[name] !== null && has(k.env[name])))
|
|
14
|
+
.map((k) => ({ title: `Add the option to ${name} in ${k.file}`, lines: [
|
|
15
|
+
`${k.file} sets ${name} for the container ${k.container}. That value replaces the one in the image, so OpenTelemetry would not load.`,
|
|
16
|
+
`Add ${option} to the end of that value.`] }))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Manual steps for containers whose command (or args, for a CMD) replaces the image start that this tool changed.
|
|
21
|
+
* @param {import('../types.mjs').Service} service
|
|
22
|
+
* @param {'ENTRYPOINT'|'CMD'} instruction the instruction that got the words
|
|
23
|
+
* @param {string} words e.g. opentelemetry-instrument
|
|
24
|
+
* @returns {import('../types.mjs').Block[]}
|
|
25
|
+
*/
|
|
26
|
+
export function startOverrides(service, instruction, words) {
|
|
27
|
+
return (service.k8s ?? []).filter((k) => k.command || (k.args && instruction === 'CMD')).map((k) => ({
|
|
28
|
+
title: `Start the app with ${words} in ${k.file}`, lines: [
|
|
29
|
+
`${k.file} sets ${k.command ? 'command' : 'args'} for the container ${k.container}. That replaces the ${instruction} of the image.`,
|
|
30
|
+
`Put ${words} in front of the command there.`] }))
|
|
31
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Node.js: add @opentelemetry/api and @opentelemetry/auto-instrumentations-node
|
|
3
|
+
* with the package manager of the repo, then load OpenTelemetry when node
|
|
4
|
+
* starts: NODE_OPTIONS in the Dockerfile, or the option right after `node` in
|
|
5
|
+
* scripts.start.
|
|
6
|
+
*
|
|
7
|
+
* It loads the start file uknows-otel.cjs (install.ts NODE_REGISTER_FILE), which
|
|
8
|
+
* this tool adds next to package.json: it stops the app cleanly on SIGTERM after
|
|
9
|
+
* the last data. In an image, a COPY line in the final stage puts it in the
|
|
10
|
+
* WORKDIR. When the build context, the WORKDIR or .dockerignore makes that
|
|
11
|
+
* unsure, it loads the upstream register line instead and says why.
|
|
12
|
+
*
|
|
13
|
+
* Safety rule: node crashes at start when the register module is missing, so
|
|
14
|
+
* the start changes only when the packages surely reach the running app.
|
|
15
|
+
* Package managers run with install scripts turned off, so no code of the
|
|
16
|
+
* project or its dependencies runs.
|
|
17
|
+
*/
|
|
18
|
+
import { buildContext, finalWorkdir, ignoredBy } from '../context.mjs'
|
|
19
|
+
import { unpicked } from '../settings.mjs'
|
|
20
|
+
import { Doc, shellWords } from '../doc.mjs'
|
|
21
|
+
import { addOption, dockerStart, insertBeforeStart, parseDockerfile } from '../dockerfile.mjs'
|
|
22
|
+
import { NODE_REGISTER, NODE_REGISTER_FILE, NODE_START_FILE, PINS, RESOLVE, STACKS } from '../contract.mjs'
|
|
23
|
+
import { list } from '../ui.mjs'
|
|
24
|
+
import { upFrom } from '../workspace.mjs'
|
|
25
|
+
import { envOverrides } from './k8s.mjs'
|
|
26
|
+
|
|
27
|
+
const STACK = STACKS.find((s) => s.id === 'node')
|
|
28
|
+
const START_FILE = NODE_REGISTER_FILE.path
|
|
29
|
+
const START_TEXT = `${NODE_REGISTER_FILE.content}\n`
|
|
30
|
+
/** The docs option for the start file. Its steps go in the advice when this tool cannot add the file. */
|
|
31
|
+
const SIGTERM = STACK.options.find((o) => o.title.startsWith('Stops cleanly on SIGTERM'))
|
|
32
|
+
/** A NODE_OPTIONS value or a start script that loads OpenTelemetry already: the upstream register or the start file. */
|
|
33
|
+
const loads = (value) => value.includes(NODE_REGISTER) || value.includes(START_FILE)
|
|
34
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
35
|
+
const LOCKS = [['package-lock.json', 'npm'], ['npm-shrinkwrap.json', 'npm'], ['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'],
|
|
36
|
+
['bun.lockb', 'bun'], ['bun.lock', 'bun']]
|
|
37
|
+
const DEFAULT_LOCK = { npm: 'package-lock.json', pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock' }
|
|
38
|
+
const RUNS_NODE = /^(node|nodejs|npm|npx|yarn|pnpm)$|\.[cm]?js$/
|
|
39
|
+
const BUNDLER = /\b(esbuild|webpack|rollup|tsup|ncc)\b/
|
|
40
|
+
const VALUE_FLAGS = new Set(['-r', '--require', '--import', '--loader', '--experimental-loader', '--env-file', '-C', '--conditions'])
|
|
41
|
+
|
|
42
|
+
/** @type {import('../types.mjs').Applier} */
|
|
43
|
+
export const node = {
|
|
44
|
+
id: 'node',
|
|
45
|
+
plan(service, ctx) {
|
|
46
|
+
const { ws } = ctx
|
|
47
|
+
const out = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] }
|
|
48
|
+
const pkgFile = join(service.dir, 'package.json')
|
|
49
|
+
const pkgText = read(ws, pkgFile)
|
|
50
|
+
let pkg = null
|
|
51
|
+
try { pkg = JSON.parse(pkgText) } catch { /* handled below */ }
|
|
52
|
+
if (pkgText === null) {
|
|
53
|
+
out.manual.push({ title: 'Add OpenTelemetry to your Node.js app', lines: [`This tool did not find ${pkgFile}.`,
|
|
54
|
+
'Add the packages with your package manager. Then start node with the option below.'],
|
|
55
|
+
snippets: [{ caption: 'Install', language: 'bash', code: STACK.install }, { caption: 'Start command', language: 'bash', code: STACK.start }] })
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) {
|
|
59
|
+
out.manual.push({ title: `Fix ${pkgFile}`, lines: [`This tool cannot read ${pkgFile} as JSON.`, 'Fix the file. Then run npx @uknows/setup again.'] })
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
// Too old for the packages: with --import or --require of the register module, node would not start.
|
|
63
|
+
const v = /^(\d+)(?:\.(\d+))?/.exec(service.runtimeVersion ?? '')
|
|
64
|
+
const [major, minor] = v ? [Number(v[1]), v[2] === undefined ? 99 : Number(v[2])] : [99, 99]
|
|
65
|
+
if (major < 18 || major === 19 || (major === 18 && minor < 19) || (major === 20 && minor < 6)) {
|
|
66
|
+
out.manual.push({ title: 'Use Node.js 18.19 or later', lines: [`This app uses Node.js ${service.runtimeVersion}.`,
|
|
67
|
+
'The OpenTelemetry packages need Node.js 18.19 or later on version 18, or 20.6 or later.',
|
|
68
|
+
'Update the Node.js version of the app. Then run npx @uknows/setup again.'] })
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
const docker = service.dockerfile ? read(ws, service.dockerfile) : null
|
|
72
|
+
const info = docker !== null ? parseDockerfile(docker) : null
|
|
73
|
+
const start = info ? dockerStart(info) : null
|
|
74
|
+
const script = typeof pkg.scripts?.start === 'string' ? pkg.scripts.start : null
|
|
75
|
+
const entry = entryOf(info ? start?.argv : service.start?.argv ?? shellWords(script ?? ''), script)
|
|
76
|
+
const esm = /\.m[jt]s$/.test(entry ?? '') || (pkg.type === 'module' && !/\.c[jt]s$/.test(entry ?? ''))
|
|
77
|
+
const flag = PINS.node.flag(esm)
|
|
78
|
+
|
|
79
|
+
const guided = guidedBlock(service, ws, pkg, info, entry, flag)
|
|
80
|
+
if (guided) { out.manual.push(guided); return out }
|
|
81
|
+
// The docs settings for Node.js: only the resource detectors that ask no cloud metadata address (NETWORK.node).
|
|
82
|
+
for (const line of STACK.extraSettings?.lines ?? []) {
|
|
83
|
+
const at = line.indexOf('=')
|
|
84
|
+
out.extraSettings.push({ name: line.slice(0, at), value: line.slice(at + 1) })
|
|
85
|
+
}
|
|
86
|
+
const pm = packageManager(service.dir, pkg, ctx.repo ?? ws)
|
|
87
|
+
if (pm.outside) {
|
|
88
|
+
out.manual.push({ title: 'Run setup from the repo root', lines: [
|
|
89
|
+
`${service.dir === '.' ? 'This folder' : service.dir} is part of a workspace. Its ${base(pm.outside)} is in ${dirOf(pm.outside)}, `
|
|
90
|
+
+ 'outside the folder this tool works in, so this tool cannot update it.',
|
|
91
|
+
'Run npx @uknows/setup in the repo root without --path. Then pick this service from the list, or use --all.'] })
|
|
92
|
+
out.stop = true
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
const wanted = [...PINS.node.packages, ...(esm ? [PINS.node.esmPackage] : [])]
|
|
96
|
+
if (pm.cmd === 'bun' || pm.cmd === 'other' || pm.pnp) {
|
|
97
|
+
out.manual.push({ title: 'Add OpenTelemetry with your package manager',
|
|
98
|
+
lines: [pm.pnp ? 'This repo uses Yarn Plug\'n\'Play, so node cannot load the packages with --require alone.'
|
|
99
|
+
: 'This tool adds packages with npm, pnpm or yarn only.', 'Add the packages below as normal dependencies.',
|
|
100
|
+
pm.pnp ? 'Start the app with yarn node and the option below.' : 'Start node with the option below.'],
|
|
101
|
+
snippets: [{ caption: 'Packages', language: 'text', code: wanted.join('\n') },
|
|
102
|
+
{ caption: 'Start command', language: 'bash', code: `${pm.pnp ? 'yarn ' : ''}${startLine(flag, entry)}` }] })
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
const added = wanted.filter((name) => !pkg.dependencies?.[name])
|
|
106
|
+
// npm here writes the lock that npm ci in the image reads: a newer npm than the image has stops the run.
|
|
107
|
+
const npmBlock = added.length && pm.cmd === 'npm' && pm.lock && info ? npmGuard(service, info, ctx.tools?.npm) : null
|
|
108
|
+
if (npmBlock) {
|
|
109
|
+
out.manual.push(npmBlock)
|
|
110
|
+
out.stop = true
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
if (added.length) {
|
|
114
|
+
const lock = pm.lock ?? (pm.cmd === 'npm' ? null : join(service.dir, DEFAULT_LOCK[pm.cmd]))
|
|
115
|
+
// No install scripts, no audit and no funding lines (they are about the app, not this change). npm with a
|
|
116
|
+
// lockfile and pnpm write only package.json and the lockfile.
|
|
117
|
+
const flags = pm.cmd === 'npm' ? ['--ignore-scripts', '--no-audit', '--no-fund', pm.lock ? '--package-lock-only' : '--no-package-lock']
|
|
118
|
+
: pm.cmd === 'pnpm' ? ['--lockfile-only', ...(ws.exists(join(service.dir, 'pnpm-workspace.yaml')) ? ['-w'] : [])]
|
|
119
|
+
: [pm.berry ? '--mode=skip-build' : '--ignore-scripts']
|
|
120
|
+
const why = `Add ${list(added)} with ${pm.cmd}.`
|
|
121
|
+
const lockOnly = pm.cmd === 'pnpm' || (pm.cmd === 'npm' && Boolean(pm.lock))
|
|
122
|
+
out.commands.push({ cwd: service.dir, cmd: pm.cmd, why, added, lockOnly,
|
|
123
|
+
args: pm.cmd === 'npm' ? ['install', '--save', ...flags, ...added] : ['add', ...flags, ...added],
|
|
124
|
+
undo: { cmd: pm.cmd, args: [pm.cmd === 'npm' ? 'uninstall' : 'remove', ...flags, ...added] },
|
|
125
|
+
touches: [pkgFile, ...(lock ? [lock] : [])] })
|
|
126
|
+
out.summary.push(why)
|
|
127
|
+
if (lockOnly) out.notes.push(`${pm.cmd} updates only package.json and ${base(lock)} here, so node_modules on this computer does not have the new packages yet. Run ${pm.cmd} install before you start the app on this computer. Your build installs them from ${base(lock)}.`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (info) {
|
|
131
|
+
const runsNode = start && (start.argv ?? [start.raw]).flatMap((w) => w.split(/\s+/)).some((w) => RUNS_NODE.test(base(w.replace(/^["']|["']$/g, ''))))
|
|
132
|
+
if (!runsNode) out.manual.push(startBlock(flag, entry, `the start command in ${service.dockerfile}`))
|
|
133
|
+
else {
|
|
134
|
+
const current = info.env.get('NODE_OPTIONS') ?? info.inherited.get('NODE_OPTIONS')
|
|
135
|
+
const done = Boolean(current && loads(current.value))
|
|
136
|
+
const place = done ? null : placeStartFile(service, ctx.repo ?? ws, info, ctx)
|
|
137
|
+
const option = place?.path ? PINS.node.flag(esm, place.path) : flag
|
|
138
|
+
let after = docker
|
|
139
|
+
if (place?.path) {
|
|
140
|
+
addStartFile(out, ws, place.file)
|
|
141
|
+
const copied = info.final.some((x) => (x.keyword === 'COPY' || x.keyword === 'ADD') && x.args.includes(START_FILE))
|
|
142
|
+
if (!copied) after = insertBeforeStart(after, [place.copy])
|
|
143
|
+
} else if (place) {
|
|
144
|
+
out.manual.push({ title: SIGTERM.title, required: false, lines: [place.why, NODE_START_FILE.fallback, ...SIGTERM.steps], snippets: SIGTERM.snippets })
|
|
145
|
+
}
|
|
146
|
+
after = addOption(after, 'NODE_OPTIONS', option, loads)
|
|
147
|
+
if (after !== docker) {
|
|
148
|
+
out.edits.push({ file: service.dockerfile, before: docker, after, why: place?.path ? `Load OpenTelemetry when node starts. ${NODE_START_FILE.copy}` : 'Load OpenTelemetry when node starts.' })
|
|
149
|
+
out.summary.push(`Load OpenTelemetry when node starts (NODE_OPTIONS in ${service.dockerfile}).`)
|
|
150
|
+
if (current) {
|
|
151
|
+
out.notes.push(`${service.dockerfile} already sets NODE_OPTIONS. A new ENV line before the start keeps that value and adds the option.`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
out.manual.push(...envOverrides(service, 'NODE_OPTIONS', option, loads))
|
|
155
|
+
}
|
|
156
|
+
} else if (script !== null && service.start?.kind !== 'procfile' && shellWords(script)?.[0] === 'node') {
|
|
157
|
+
if (!loads(script)) {
|
|
158
|
+
// npm start runs the script in the package folder, so ./uknows-otel.cjs is the file next to package.json.
|
|
159
|
+
// The start file is only for a start that surely has it: no Dockerfile that could build this app without it, not ignored by git.
|
|
160
|
+
const unsure = scriptStartUnsure(service, ctx)
|
|
161
|
+
const after = editStartScript(pkgText, unsure ? flag : PINS.node.flag(esm, `./${START_FILE}`))
|
|
162
|
+
if (after === null) out.manual.push(startBlock(flag, entry, `scripts.start in ${pkgFile}`))
|
|
163
|
+
else {
|
|
164
|
+
if (unsure) out.manual.push({ title: SIGTERM.title, required: false, lines: [unsure, NODE_START_FILE.fallback, ...SIGTERM.steps], snippets: SIGTERM.snippets })
|
|
165
|
+
else addStartFile(out, ws, join(service.dir, START_FILE))
|
|
166
|
+
out.edits.push({ file: pkgFile, before: pkgText, after, why: 'Load OpenTelemetry when node starts.' })
|
|
167
|
+
out.summary.push(`Load OpenTelemetry when node starts (start script in ${pkgFile}).`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
out.manual.push(startBlock(flag, entry, script !== null ? `scripts.start in ${pkgFile}` : null))
|
|
172
|
+
}
|
|
173
|
+
return out
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Where the start file goes in the image: {file, path, copy}, or {why} when this tool is not sure. The file sits
|
|
179
|
+
* next to package.json. The COPY line takes it from the build context into the WORKDIR of the final stage.
|
|
180
|
+
*/
|
|
181
|
+
function placeStartFile(service, view, info, ctx = {}) {
|
|
182
|
+
const file = join(service.dir, START_FILE)
|
|
183
|
+
const values = { file, dockerfile: service.dockerfile }
|
|
184
|
+
const workdir = finalWorkdir(info)
|
|
185
|
+
if (!workdir) return { why: fill(NODE_START_FILE.noWorkdir, values) }
|
|
186
|
+
const context = buildContext(view, service.dockerfile, info, service.dir)
|
|
187
|
+
if (context === null) return { why: fill(NODE_START_FILE.noContext, values) }
|
|
188
|
+
if (context !== '.' && !file.startsWith(`${context}/`)) return { why: fill(NODE_START_FILE.outside, values) }
|
|
189
|
+
const source = context === '.' ? file : file.slice(context.length + 1)
|
|
190
|
+
const ignore = ignoredBy(view, context, service.dockerfile, source)
|
|
191
|
+
if (ignore) return { why: fill(NODE_START_FILE.ignored, { ...values, ignore }) }
|
|
192
|
+
// The pipeline builds from a checkout: a file that git ignores never reaches it, and the COPY line fails there.
|
|
193
|
+
if (!read(view, file) && ctx.ignored?.(file)) return { why: fill(NODE_START_FILE.gitignored, values) }
|
|
194
|
+
const dir = workdir === '/' ? '' : workdir
|
|
195
|
+
return { file, path: `${dir}/${START_FILE}`, copy: `COPY ${source} ${dir}/` }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Why a start script must not load the start file, or null when it can: git ignores the file, so a checkout lacks it,
|
|
200
|
+
* or the repo has a Dockerfile that this tool did not pick for any service (an image built from it would not copy the
|
|
201
|
+
* file, and node stops at start when a --require file is missing). The upstream register line has no such file.
|
|
202
|
+
*/
|
|
203
|
+
function scriptStartUnsure(service, ctx) {
|
|
204
|
+
const view = ctx.repo ?? ctx.ws
|
|
205
|
+
const file = join(service.dir, START_FILE)
|
|
206
|
+
if (!read(view, file) && ctx.ignored?.(file)) return fill(NODE_START_FILE.gitignored, { file })
|
|
207
|
+
const others = unpicked(ctx)
|
|
208
|
+
return others.length ? fill(NODE_START_FILE.dockerfiles, { file, dockerfiles: list(others.slice(0, 5)) }) : null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Add the start file when it is not there. A file that is there stays as it is: it can be the user's own copy. */
|
|
212
|
+
function addStartFile(out, ws, file) {
|
|
213
|
+
const current = read(ws, file)
|
|
214
|
+
if (current === null) {
|
|
215
|
+
out.edits.push({ file, before: null, after: START_TEXT, why: fill(NODE_START_FILE.add, { file }) })
|
|
216
|
+
out.summary.push(fill(NODE_START_FILE.add, { file }))
|
|
217
|
+
} else if (current !== START_TEXT) out.notes.push(fill(NODE_START_FILE.keep, { file }))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A required step when npm on this computer is newer (by major version) than the npm of the image that runs npm ci:
|
|
222
|
+
* the npm of a RUN npm install -g npm@X line, else the npm that the node image of that stage comes with (PINS.node.npm).
|
|
223
|
+
* null when the versions fit or are not known.
|
|
224
|
+
*/
|
|
225
|
+
export function npmGuard(service, info, have) {
|
|
226
|
+
const haveMajor = Number(/^(\d+)\./.exec(have ?? '')?.[1] ?? NaN)
|
|
227
|
+
if (!Number.isFinite(haveMajor)) return null
|
|
228
|
+
const runs = (stage) => stage.instructions.filter((x) => x.keyword === 'RUN')
|
|
229
|
+
const text = (x) => (x.argv ? x.argv.join(' ') : x.args)
|
|
230
|
+
let image = null, line = null, need = null
|
|
231
|
+
for (const stage of info.stages) {
|
|
232
|
+
for (const x of runs(stage)) {
|
|
233
|
+
const pin = /\bnpm\s+(?:install|i)\s+(?:-g|--global)\s+npm@(\d+)/.exec(text(x))
|
|
234
|
+
if (pin) { need = Number(pin[1]); image = `npm@${pin[1]}`; line = x.start + 1 }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (need === null) {
|
|
238
|
+
const stages = info.stages, byName = new Map(stages.filter((st) => st.name).map((st) => [st.name.toLowerCase(), st]))
|
|
239
|
+
const stage = stages.find((st) => runs(st).some((x) => /\bnpm\s+(?:ci|install|i)\b/.test(text(x)))) ?? stages.at(-1)
|
|
240
|
+
if (!stage) return null
|
|
241
|
+
let from = stage.image
|
|
242
|
+
for (let hop = 0; hop < 5 && byName.has(from.toLowerCase()); hop++) from = byName.get(from.toLowerCase()).image
|
|
243
|
+
const m = /(?:^|\/)node:(\d+)(?:\.(\d+))?/.exec(from)
|
|
244
|
+
if (!m) return null
|
|
245
|
+
const v = [Number(m[1]), m[2] === undefined ? 99 : Number(m[2])]
|
|
246
|
+
need = PINS.node.npm.find(([[a, b]]) => v[0] > a || (v[0] === a && v[1] >= b))?.[1] ?? null
|
|
247
|
+
image = from
|
|
248
|
+
line = (info.instructions.find((x) => x.keyword === 'FROM' && x.args.split(/\s+/).includes(stage.image))?.start ?? 0) + 1
|
|
249
|
+
}
|
|
250
|
+
if (need === null || haveMajor <= need) return null
|
|
251
|
+
const values = { file: service.dockerfile, line, image, need, have }
|
|
252
|
+
const fill = (t) => t.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
253
|
+
return { title: fill(RESOLVE.npm.title), lines: RESOLVE.npm.lines.map(fill) }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Cases where loading the register module cannot work: guided only, no packages. */
|
|
257
|
+
function guidedBlock(service, ws, pkg, info, entry, flag) {
|
|
258
|
+
if (service.frameworks.includes('next')) {
|
|
259
|
+
return { title: 'Next.js needs its own OpenTelemetry setup', lines: ['Next.js builds its server code, so the --require option cannot load OpenTelemetry.',
|
|
260
|
+
'Follow the OpenTelemetry guide in the Next.js docs. It adds an instrumentation.ts file.', 'Keep the 5 UKNOWS settings. That setup reads them.'] }
|
|
261
|
+
}
|
|
262
|
+
if (info && (service.frameworks.includes('nitro') || service.frameworks.includes('nuxt'))) {
|
|
263
|
+
const install = info.final.some((x) => x.keyword === 'RUN' && /\b(npm (ci|install|i)|pnpm (install|i)|yarn)\b/.test(x.args))
|
|
264
|
+
const copies = info.final.filter((x) => x.keyword === 'COPY' || x.keyword === 'ADD')
|
|
265
|
+
if (!install && !copies.some((x) => /node_modules/.test(x.args) || !/\.output/.test(x.args))) {
|
|
266
|
+
return { title: 'Put node_modules in the final image', verify: true, lines: [`The final stage of ${service.dockerfile} copies only the built server, so node cannot load OpenTelemetry.`,
|
|
267
|
+
'Install the packages in the final stage, or copy node_modules into it.', 'Then run npx @uknows/setup again.'] }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const bundler = BUNDLER.exec(typeof pkg.scripts?.build === 'string' ? pkg.scripts.build : '')
|
|
271
|
+
if (bundler && !(entry && ws.exists(join(service.dir, entry.replace(/^\.\//, ''))))) {
|
|
272
|
+
return { title: 'Load OpenTelemetry in a bundled app', lines: [`The build script bundles the app with ${bundler[1]}. OpenTelemetry cannot change bundled packages.`,
|
|
273
|
+
'Add the packages below as normal dependencies. In the bundler config, mark them and the libraries to trace as external.',
|
|
274
|
+
'Start node with the option below.'],
|
|
275
|
+
snippets: [{ caption: 'Install', language: 'bash', code: STACK.install }, { caption: 'Start command', language: 'bash', code: startLine(flag, entry) }] }
|
|
276
|
+
}
|
|
277
|
+
return null
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The package manager from the packageManager field, else from the lockfile of the service folder or of
|
|
282
|
+
* the workspace root above it (a parent folder whose package.json has workspaces, or with pnpm-workspace.yaml).
|
|
283
|
+
* The search goes on above the --path folder, up to the top of the git repo (view.top). A workspace root or
|
|
284
|
+
* its lockfile up there is `outside`: this tool cannot update it.
|
|
285
|
+
*/
|
|
286
|
+
export function packageManager(dir, pkg, view) {
|
|
287
|
+
const field = typeof pkg.packageManager === 'string' ? /^(npm|pnpm|yarn|bun)@(\d+)/.exec(pkg.packageManager) : null
|
|
288
|
+
let cmd = field ? field[1] : typeof pkg.packageManager === 'string' ? 'other' : null
|
|
289
|
+
let lock = null, outside = null
|
|
290
|
+
for (const d of upFrom(dir, view.top ?? '.')) {
|
|
291
|
+
const pnpmRoot = view.exists(join(d, 'pnpm-workspace.yaml'))
|
|
292
|
+
const marker = pnpmRoot || /"workspaces"\s*:/.test(read(view, join(d, 'package.json')) ?? '')
|
|
293
|
+
const hit = (d === dir || marker) && LOCKS.find(([file, owner]) => (!cmd || cmd === owner) && read(view, join(d, file)) !== null)
|
|
294
|
+
if ((d === '..' || d.startsWith('../')) && (marker || hit)) { outside = join(d, hit ? hit[0] : pnpmRoot ? 'pnpm-workspace.yaml' : 'package.json'); break }
|
|
295
|
+
if (hit) { lock = join(d, hit[0]); cmd = hit[1]; break }
|
|
296
|
+
}
|
|
297
|
+
cmd ??= 'npm'
|
|
298
|
+
const home = lock ? dirOf(lock) : dir
|
|
299
|
+
const rc = read(view, join(home, '.yarnrc.yml'))
|
|
300
|
+
const berry = cmd === 'yarn' && (field ? Number(field[2]) >= 2 : rc !== null || /^__metadata:/m.test(read(view, lock ?? '') ?? ''))
|
|
301
|
+
const pnp = cmd === 'yarn' && (view.exists(join(home, '.pnp.cjs')) || view.exists(join(home, '.pnp.js'))
|
|
302
|
+
|| (berry && !/^\s*nodeLinker\s*:\s*["']?(node-modules|pnpm)/m.test(rc ?? '')))
|
|
303
|
+
return { cmd, lock, pnp, berry, outside }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The JS file that node runs, from a start argv. `npm start` and friends read scripts.start. */
|
|
307
|
+
export function entryOf(argv, script) {
|
|
308
|
+
let words = argv ?? null
|
|
309
|
+
if (words && /^(npm|yarn|pnpm)$/.test(base(words[0])) && (words[1] === 'start' || (words[1] === 'run' && words[2] === 'start'))) words = shellWords(script ?? '')
|
|
310
|
+
if (!words?.length) return null
|
|
311
|
+
if (/\.[cm]?[jt]s$/.test(words[0])) return words[0]
|
|
312
|
+
if (base(words[0]) !== 'node') return null
|
|
313
|
+
for (let i = 1; i < words.length; i++) {
|
|
314
|
+
if (!words[i].startsWith('-')) return words[i]
|
|
315
|
+
if (VALUE_FLAGS.has(words[i])) i++
|
|
316
|
+
}
|
|
317
|
+
return null
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Insert the option right after `node` in scripts.start. Changes only that JSON string. null when unsure. */
|
|
321
|
+
function editStartScript(text, flag) {
|
|
322
|
+
const doc = new Doc(text)
|
|
323
|
+
const from = doc.find(/"scripts"\s*:/)
|
|
324
|
+
const i = from < 0 ? -1 : doc.find(/"start"\s*:\s*"/, from)
|
|
325
|
+
if (i < 0) return null
|
|
326
|
+
const line = doc.get(i)
|
|
327
|
+
const m = /("start"\s*:\s*)("(?:[^"\\]|\\.)*")/.exec(line)
|
|
328
|
+
if (!m) return null
|
|
329
|
+
const value = JSON.parse(m[2]).replace(/^(\s*node)(?=\s|$)/, `$1 ${flag}`)
|
|
330
|
+
doc.set(i, line.slice(0, m.index + m[1].length) + JSON.stringify(value) + line.slice(m.index + m[0].length))
|
|
331
|
+
const after = doc.toString()
|
|
332
|
+
try {
|
|
333
|
+
const expected = JSON.parse(text)
|
|
334
|
+
expected.scripts.start = value
|
|
335
|
+
return JSON.stringify(JSON.parse(after)) === JSON.stringify(expected) ? after : null
|
|
336
|
+
} catch { return null }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function startLine(flag, entry) {
|
|
340
|
+
return STACK.start.replace(PINS.node.requireFlag, flag).replace(/server\.js$/, entry ?? 'server.js')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function startBlock(flag, entry, where) {
|
|
344
|
+
return { title: 'Load OpenTelemetry when node starts',
|
|
345
|
+
lines: [where ? `This tool cannot change ${where} safely.` : 'This tool did not find the command that starts the app.',
|
|
346
|
+
'Add the option below to the node command that starts the app. Or set NODE_OPTIONS to it where the app runs.',
|
|
347
|
+
...STACK.notes.filter((n) => !entry && /server\.js/.test(n))],
|
|
348
|
+
snippets: [{ caption: 'Start command', language: 'bash', code: startLine(flag, entry) },
|
|
349
|
+
{ caption: 'Or set NODE_OPTIONS', language: 'bash', code: `NODE_OPTIONS="${flag}"` }] }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const base = (word) => (word ?? '').split('/').pop()
|
|
353
|
+
const join = (dir, file) => (dir === '.' ? file : `${dir}/${file}`)
|
|
354
|
+
const dirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')
|
|
355
|
+
|
|
356
|
+
function read(ws, path) {
|
|
357
|
+
try { return path ? ws.read(path) : null } catch { return null }
|
|
358
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The app already uses OpenTelemetry: keep its packages and start command. settings.mjs adds the 3 shared
|
|
2
|
+
// settings (no none lines, so the metrics and logs of the app keep going where they go now).
|
|
3
|
+
import { AZURE_MONITOR, DOCS_URL, DOTNET_TRACES_ONLY, INGEST_ENDPOINT, OTHER_EXPORTERS_NOTE, STACKS, TRACES_ONLY_NOTE, TRACES_ONLY_PLAN } from '../contract.mjs'
|
|
4
|
+
|
|
5
|
+
const STACK = STACKS.find((s) => s.id === 'otel')
|
|
6
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The notes of the docs page, and after its .NET note the 2 notes that only setup prints: the pages name UKNOWS only,
|
|
10
|
+
* and these name products that setup finds in a repo.
|
|
11
|
+
*/
|
|
12
|
+
export const OTEL_NOTES = STACK.notes.flatMap((n) => (n === DOTNET_TRACES_ONLY.note ? [n, AZURE_MONITOR.otelNote, OTHER_EXPORTERS_NOTE] : [n]))
|
|
13
|
+
|
|
14
|
+
/** The notes that name languages or features. A run keeps the parts that fit what it found. */
|
|
15
|
+
const NOTE = {
|
|
16
|
+
/** "For Python, use ... For Go, use ...": 1 sentence for each language. */
|
|
17
|
+
exporter: OTEL_NOTES.find((n) => /^For Python, use /.test(n)),
|
|
18
|
+
collector: OTEL_NOTES.find((n) => /Collector/.test(n)),
|
|
19
|
+
azure: AZURE_MONITOR.otelNote,
|
|
20
|
+
}
|
|
21
|
+
/** The label that each sentence of NOTE.exporter starts with. */
|
|
22
|
+
const EXPORTER_LANG = { python: 'Python', go: 'Go' }
|
|
23
|
+
/** The SDKs that send metrics and logs over OTLP by default. settings.mjs uses the traces-only settings for them. */
|
|
24
|
+
const SDK_SIGNALS = new Set(['node', 'python', 'java'])
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The notes of the docs page for this run. With no language known (the menu pick "I already use OpenTelemetry"),
|
|
28
|
+
* every note stays. Else only the notes for this language and for what the repo uses:
|
|
29
|
+
* - the exporter sentence of this language only (Python, Go),
|
|
30
|
+
* - the traces-only note only when settings.mjs did not already say what this run does with traces only,
|
|
31
|
+
* and in a form for this language,
|
|
32
|
+
* - the .NET note only for .NET, and only when this run does not use the traces-only settings,
|
|
33
|
+
* - the Azure Monitor note only when the code calls Azure Monitor,
|
|
34
|
+
* - the other-exporters note never: existing.mjs gives it as advice when the repo has another exporter.
|
|
35
|
+
* @param {string[]} notes
|
|
36
|
+
* @param {{lang?: string|null, existing?: Partial<import('../types.mjs').Existing>, tracesOnly?: boolean, endpoint?: string, docs?: string}} found
|
|
37
|
+
* endpoint and docs: the ingest address and the docs page of this run.
|
|
38
|
+
* @returns {string[]}
|
|
39
|
+
*/
|
|
40
|
+
export function otelNotes(notes, { lang = null, existing = {}, tracesOnly = false, endpoint = INGEST_ENDPOINT, docs = DOCS_URL } = {}) {
|
|
41
|
+
if (!lang) return notes
|
|
42
|
+
const anchors = existing.anchors ?? []
|
|
43
|
+
return notes.flatMap((note) => {
|
|
44
|
+
if (note === NOTE.exporter) {
|
|
45
|
+
const label = EXPORTER_LANG[lang]
|
|
46
|
+
return note.split(/(?<=\.) (?=For )/).filter((sentence) => label && sentence.startsWith(`For ${label}, `))
|
|
47
|
+
}
|
|
48
|
+
if (note === NOTE.collector) return [`${note} See ${docs}?method=manual&stack=otel#install-app.`]
|
|
49
|
+
if (note === TRACES_ONLY_NOTE) return tracesOnly || SDK_SIGNALS.has(lang) ? [] : [fill(TRACES_ONLY_PLAN.none, { endpoint })]
|
|
50
|
+
if (note === DOTNET_TRACES_ONLY.note) return lang === 'dotnet' && !tracesOnly ? [note] : []
|
|
51
|
+
if (note === NOTE.azure) return anchors.some((a) => a.kind === 'azure-call' || a.kind === 'appinsights-call') ? [note] : []
|
|
52
|
+
if (note === OTHER_EXPORTERS_NOTE) return []
|
|
53
|
+
return [note]
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @type {import('../types.mjs').Applier} */
|
|
58
|
+
export const otel = {
|
|
59
|
+
id: 'otel',
|
|
60
|
+
plan() {
|
|
61
|
+
return { summary: [STACK.summary], edits: [], commands: [], extraSettings: [], manual: [], notes: [...OTEL_NOTES] }
|
|
62
|
+
},
|
|
63
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What `opentelemetry-bootstrap -a requirements` prints, without a Python
|
|
3
|
+
* environment. Source: bootstrap_gen.py of opentelemetry-python-contrib
|
|
4
|
+
* v0.66b1. Upstream checks the installed packages; this tool reads the
|
|
5
|
+
* requirement names instead, plus a few packages that the common frameworks
|
|
6
|
+
* always pull in (IMPLIED).
|
|
7
|
+
*
|
|
8
|
+
* Left out on purpose: openai-v2 and vertexai (not pinned to 0.66b1),
|
|
9
|
+
* psutil -> system-metrics (metrics are off) and the upstream quirk that maps
|
|
10
|
+
* pydantic to tortoiseorm. The default logging package comes only with the UKNOWS
|
|
11
|
+
* log bridge (logs: true). On its own, in 0.66b1, it adds an OpenTelemetry
|
|
12
|
+
* LoggingHandler to the root logger, also with OTEL_LOGS_EXPORTER=none. Then Python
|
|
13
|
+
* never uses its last-resort handler, so the warnings and errors of loggers with no
|
|
14
|
+
* handler no longer reach stderr. With the bridge, OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION
|
|
15
|
+
* =false keeps it off the root logger and the package gives only its LoggingHandler.
|
|
16
|
+
*/
|
|
17
|
+
import { compareVersion } from '../versions.mjs'
|
|
18
|
+
|
|
19
|
+
/** Always printed. */
|
|
20
|
+
export const DEFAULTS = ['asyncio', 'dbapi', 'exceptions', 'sqlite3', 'threading', 'urllib', 'wsgi']
|
|
21
|
+
|
|
22
|
+
/** [normalized library name, instrumentation suffixes], in upstream order. */
|
|
23
|
+
export const LIBRARIES = [
|
|
24
|
+
['aio-pika', ['aio-pika']], ['aiohttp', ['aiohttp-client', 'aiohttp-server']], ['aiokafka', ['aiokafka']],
|
|
25
|
+
['aiopg', ['aiopg']], ['asgiref', ['asgi']], ['asyncclick', ['asyncclick']], ['asyncpg', ['asyncpg']],
|
|
26
|
+
['boto3', ['boto3sqs']], ['botocore', ['botocore']], ['aiobotocore', ['botocore']],
|
|
27
|
+
['cassandra-driver', ['cassandra']], ['scylla-driver', ['cassandra']], ['celery', ['celery']], ['click', ['click']],
|
|
28
|
+
['confluent-kafka', ['confluent-kafka']], ['django', ['django']], ['falcon', ['falcon']], ['fastapi', ['fastapi']],
|
|
29
|
+
['flask', ['flask']], ['grpcio', ['grpc']], ['httpx', ['httpx']], ['httpx2', ['httpx']], ['jinja2', ['jinja2']],
|
|
30
|
+
['kafka-python', ['kafka-python']], ['mysql-connector-python', ['mysql']], ['mysqlclient', ['mysqlclient']],
|
|
31
|
+
['pika', ['pika']], ['psycopg', ['psycopg']], ['psycopg2', ['psycopg2']], ['psycopg2-binary', ['psycopg2']],
|
|
32
|
+
['pymemcache', ['pymemcache']], ['pymongo', ['pymongo']], ['pymssql', ['pymssql']], ['pymysql', ['pymysql']],
|
|
33
|
+
['pyramid', ['pyramid']], ['redis', ['redis']], ['remoulade', ['remoulade']], ['requests', ['requests']],
|
|
34
|
+
['sqlalchemy', ['sqlalchemy']], ['starlette', ['starlette']], ['structlog', ['structlog']], ['tornado', ['tornado']],
|
|
35
|
+
['tortoise-orm', ['tortoiseorm']], ['urllib3', ['urllib3']],
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The first contrib release of each instrumentation package on PyPI (tools/check-python-sets.mjs). A set older than
|
|
40
|
+
* this leaves the package out, because it does not exist there. Packages missing here are older than every set.
|
|
41
|
+
*/
|
|
42
|
+
export const SINCE = { exceptions: '0.64b0', structlog: '0.65b0', asyncclick: '0.53b0', pymssql: '0.51b0', click: '0.50b0',
|
|
43
|
+
aiokafka: '0.49b0', threading: '0.46b0', psycopg: '0.45b0', asyncio: '0.44b0', 'aiohttp-server': '0.42b0', cassandra: '0.41b0',
|
|
44
|
+
mysqlclient: '0.40b0', tortoiseorm: '0.36b0', 'aio-pika': '0.32b0', boto3sqs: '0.32b0', 'confluent-kafka': '0.32b0',
|
|
45
|
+
remoulade: '0.32b0', 'kafka-python': '0.28b0', pika: '0.25b0', httpx: '0.23b0', urllib3: '0.20b0', urllib: '0.17b0' }
|
|
46
|
+
|
|
47
|
+
/** Packages that a framework always installs, so upstream would see them too. */
|
|
48
|
+
export const IMPLIED = { fastapi: ['starlette'], django: ['asgiref'], flask: ['jinja2'], requests: ['urllib3'], boto3: ['botocore'] }
|
|
49
|
+
|
|
50
|
+
/** PEP 503 name: lower case, runs of - _ . become -. */
|
|
51
|
+
export const normalize = (name) => name.toLowerCase().replace(/[-_.]+/g, '-')
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The instrumentation packages for these requirement names: the defaults,
|
|
55
|
+
* then 1 per matching library in table order, without repeats. Packages that
|
|
56
|
+
* are newer than version (SINCE) are left out.
|
|
57
|
+
* @param {Iterable<string>} names normalized requirement names
|
|
58
|
+
* @param {string} version the contrib release of the set, e.g. '0.66b1'
|
|
59
|
+
* @param {{floor?: string|null, logs?: boolean}} [options] floor: write '>=' limits from this release (or the first
|
|
60
|
+
* release of the package, when it is newer) instead of '==' pins, for uv and Poetry projects. logs: add the logging
|
|
61
|
+
* package, for the UKNOWS log bridge
|
|
62
|
+
* @returns {string[]} e.g. ['opentelemetry-instrumentation-asyncio==0.66b1', ...]
|
|
63
|
+
*/
|
|
64
|
+
export function bootstrap(names, version, { floor = null, logs = false } = {}) {
|
|
65
|
+
const have = new Set(names)
|
|
66
|
+
for (const name of [...have]) for (const extra of IMPLIED[name] ?? []) have.add(extra)
|
|
67
|
+
const suffixes = [...DEFAULTS, ...(logs ? ['logging'] : [])]
|
|
68
|
+
for (const [library, list] of LIBRARIES) if (have.has(library)) suffixes.push(...list)
|
|
69
|
+
return [...new Set(suffixes)].filter((x) => !SINCE[x] || compareVersion(SINCE[x], version) <= 0)
|
|
70
|
+
.map((x) => `opentelemetry-instrumentation-${x}${floor ? `>=${[floor, SINCE[x] ?? floor].sort(compareVersion).at(-1)}` : `==${version}`}`)
|
|
71
|
+
}
|