@uknows/setup 0.0.0-stage → 0.1.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.
@@ -0,0 +1,235 @@
1
+ /*
2
+ * Node.js: add @opentelemetry/api and @opentelemetry/auto-instrumentations-node
3
+ * with the package manager of the repo, then load the register module when
4
+ * node starts: NODE_OPTIONS in the Dockerfile, or the option right after
5
+ * `node` in scripts.start.
6
+ *
7
+ * Safety rule: node crashes at start when the register module is missing, so
8
+ * the start changes only when the packages surely reach the running app.
9
+ * Package managers run with install scripts turned off, so no code of the
10
+ * project or its dependencies runs.
11
+ */
12
+ import { Doc, shellWords } from '../doc.mjs'
13
+ import { addOption, dockerStart, parseDockerfile } from '../dockerfile.mjs'
14
+ import { NODE_REGISTER, PINS, STACKS } from '../contract.mjs'
15
+ import { list } from '../ui.mjs'
16
+ import { upFrom } from '../workspace.mjs'
17
+ import { envOverrides } from './k8s.mjs'
18
+
19
+ const STACK = STACKS.find((s) => s.id === 'node')
20
+ const LOCKS = [['package-lock.json', 'npm'], ['npm-shrinkwrap.json', 'npm'], ['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'],
21
+ ['bun.lockb', 'bun'], ['bun.lock', 'bun']]
22
+ const DEFAULT_LOCK = { npm: 'package-lock.json', pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock' }
23
+ const RUNS_NODE = /^(node|nodejs|npm|npx|yarn|pnpm)$|\.[cm]?js$/
24
+ const BUNDLER = /\b(esbuild|webpack|rollup|tsup|ncc)\b/
25
+ const VALUE_FLAGS = new Set(['-r', '--require', '--import', '--loader', '--experimental-loader', '--env-file', '-C', '--conditions'])
26
+
27
+ /** @type {import('../types.mjs').Applier} */
28
+ export const node = {
29
+ id: 'node',
30
+ plan(service, ctx) {
31
+ const { ws } = ctx
32
+ const out = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] }
33
+ const pkgFile = join(service.dir, 'package.json')
34
+ const pkgText = read(ws, pkgFile)
35
+ let pkg = null
36
+ try { pkg = JSON.parse(pkgText) } catch { /* handled below */ }
37
+ if (pkgText === null) {
38
+ out.manual.push({ title: 'Add OpenTelemetry to your Node.js app', lines: [`This tool did not find ${pkgFile}.`,
39
+ 'Add the packages with your package manager. Then start node with the option below.'],
40
+ snippets: [{ caption: 'Install', language: 'bash', code: STACK.install }, { caption: 'Start command', language: 'bash', code: STACK.start }] })
41
+ return out
42
+ }
43
+ if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) {
44
+ out.manual.push({ title: `Fix ${pkgFile}`, lines: [`This tool cannot read ${pkgFile} as JSON.`, 'Fix the file. Then run npx @uknows/setup again.'] })
45
+ return out
46
+ }
47
+ // Too old for the packages: with --import or --require of the register module, node would not start.
48
+ const v = /^(\d+)(?:\.(\d+))?/.exec(service.runtimeVersion ?? '')
49
+ const [major, minor] = v ? [Number(v[1]), v[2] === undefined ? 99 : Number(v[2])] : [99, 99]
50
+ if (major < 18 || major === 19 || (major === 18 && minor < 19) || (major === 20 && minor < 6)) {
51
+ out.manual.push({ title: 'Use Node.js 18.19 or later', lines: [`This app uses Node.js ${service.runtimeVersion}.`,
52
+ 'The OpenTelemetry packages need Node.js 18.19 or later on version 18, or 20.6 or later.',
53
+ 'Update the Node.js version of the app. Then run npx @uknows/setup again.'] })
54
+ return out
55
+ }
56
+ const docker = service.dockerfile ? read(ws, service.dockerfile) : null
57
+ const info = docker !== null ? parseDockerfile(docker) : null
58
+ const start = info ? dockerStart(info) : null
59
+ const script = typeof pkg.scripts?.start === 'string' ? pkg.scripts.start : null
60
+ const entry = entryOf(info ? start?.argv : service.start?.argv ?? shellWords(script ?? ''), script)
61
+ const esm = /\.m[jt]s$/.test(entry ?? '') || (pkg.type === 'module' && !/\.c[jt]s$/.test(entry ?? ''))
62
+ const flag = esm ? PINS.node.esmFlag : PINS.node.requireFlag
63
+
64
+ const guided = guidedBlock(service, ws, pkg, info, entry, flag)
65
+ if (guided) { out.manual.push(guided); return out }
66
+ const pm = packageManager(service.dir, pkg, ctx.repo ?? ws)
67
+ if (pm.outside) {
68
+ out.manual.push({ title: 'Run setup from the repo root', lines: [
69
+ `${service.dir === '.' ? 'This folder' : service.dir} is part of a workspace. Its ${base(pm.outside)} is in ${dirOf(pm.outside)}, `
70
+ + 'outside the folder this tool works in, so this tool cannot update it.',
71
+ 'Run npx @uknows/setup in the repo root without --path. Then pick this service from the list, or use --all.'] })
72
+ out.stop = true
73
+ return out
74
+ }
75
+ const wanted = [...PINS.node.packages, ...(esm ? [PINS.node.esmPackage] : [])]
76
+ if (pm.cmd === 'bun' || pm.cmd === 'other' || pm.pnp) {
77
+ out.manual.push({ title: 'Add OpenTelemetry with your package manager',
78
+ lines: [pm.pnp ? 'This repo uses Yarn Plug\'n\'Play, so node cannot load the packages with --require alone.'
79
+ : 'This tool adds packages with npm, pnpm or yarn only.', 'Add the packages below as normal dependencies.',
80
+ pm.pnp ? 'Start the app with yarn node and the option below.' : 'Start node with the option below.'],
81
+ snippets: [{ caption: 'Packages', language: 'text', code: wanted.join('\n') },
82
+ { caption: 'Start command', language: 'bash', code: `${pm.pnp ? 'yarn ' : ''}${startLine(flag, entry)}` }] })
83
+ return out
84
+ }
85
+ const added = wanted.filter((name) => !pkg.dependencies?.[name])
86
+ if (added.length) {
87
+ const lock = pm.lock ?? (pm.cmd === 'npm' ? null : join(service.dir, DEFAULT_LOCK[pm.cmd]))
88
+ // No install scripts. npm with a lockfile and pnpm write only package.json and the lockfile.
89
+ const flags = pm.cmd === 'npm' ? ['--ignore-scripts', pm.lock ? '--package-lock-only' : '--no-package-lock']
90
+ : pm.cmd === 'pnpm' ? ['--lockfile-only', ...(ws.exists(join(service.dir, 'pnpm-workspace.yaml')) ? ['-w'] : [])]
91
+ : [pm.berry ? '--mode=skip-build' : '--ignore-scripts']
92
+ const why = `Add ${list(added)} with ${pm.cmd}.`
93
+ const lockOnly = pm.cmd === 'pnpm' || (pm.cmd === 'npm' && Boolean(pm.lock))
94
+ out.commands.push({ cwd: service.dir, cmd: pm.cmd, why, added, lockOnly,
95
+ args: pm.cmd === 'npm' ? ['install', '--save', ...flags, ...added] : ['add', ...flags, ...added],
96
+ undo: { cmd: pm.cmd, args: [pm.cmd === 'npm' ? 'uninstall' : 'remove', ...flags, ...added] },
97
+ touches: [pkgFile, ...(lock ? [lock] : [])] })
98
+ out.summary.push(why)
99
+ if (lockOnly) out.notes.push(`To start the app on this computer, run ${pm.cmd} install first. Your build installs the packages from ${base(lock)}.`)
100
+ }
101
+
102
+ if (info) {
103
+ const runsNode = start && (start.argv ?? [start.raw]).flatMap((w) => w.split(/\s+/)).some((w) => RUNS_NODE.test(base(w.replace(/^["']|["']$/g, ''))))
104
+ if (!runsNode) out.manual.push(startBlock(flag, entry, `the start command in ${service.dockerfile}`))
105
+ else {
106
+ const has = (value) => value.includes(NODE_REGISTER)
107
+ const after = addOption(docker, 'NODE_OPTIONS', flag, has)
108
+ if (after !== docker) {
109
+ out.edits.push({ file: service.dockerfile, before: docker, after, why: 'Load OpenTelemetry when node starts.' })
110
+ out.summary.push(`Load OpenTelemetry when node starts (NODE_OPTIONS in ${service.dockerfile}).`)
111
+ if (info.env.has('NODE_OPTIONS') || info.inherited.has('NODE_OPTIONS')) {
112
+ out.notes.push(`${service.dockerfile} already sets NODE_OPTIONS. A new ENV line before the start keeps that value and adds the option.`)
113
+ }
114
+ }
115
+ out.manual.push(...envOverrides(service, 'NODE_OPTIONS', flag, has))
116
+ }
117
+ } else if (script !== null && service.start?.kind !== 'procfile' && shellWords(script)?.[0] === 'node') {
118
+ if (!script.includes(NODE_REGISTER)) {
119
+ const after = editStartScript(pkgText, flag)
120
+ if (after === null) out.manual.push(startBlock(flag, entry, `scripts.start in ${pkgFile}`))
121
+ else {
122
+ out.edits.push({ file: pkgFile, before: pkgText, after, why: 'Load OpenTelemetry when node starts.' })
123
+ out.summary.push(`Load OpenTelemetry when node starts (start script in ${pkgFile}).`)
124
+ }
125
+ }
126
+ } else {
127
+ out.manual.push(startBlock(flag, entry, script !== null ? `scripts.start in ${pkgFile}` : null))
128
+ }
129
+ return out
130
+ },
131
+ }
132
+
133
+ /** Cases where loading the register module cannot work: guided only, no packages. */
134
+ function guidedBlock(service, ws, pkg, info, entry, flag) {
135
+ if (service.frameworks.includes('next')) {
136
+ return { title: 'Next.js needs its own OpenTelemetry setup', lines: ['Next.js builds its server code, so the --require option cannot load OpenTelemetry.',
137
+ 'Follow the OpenTelemetry guide in the Next.js docs. It adds an instrumentation.ts file.', 'Keep the 5 UKNOWS settings. That setup reads them.'] }
138
+ }
139
+ if (info && (service.frameworks.includes('nitro') || service.frameworks.includes('nuxt'))) {
140
+ const install = info.final.some((x) => x.keyword === 'RUN' && /\b(npm (ci|install|i)|pnpm (install|i)|yarn)\b/.test(x.args))
141
+ const copies = info.final.filter((x) => x.keyword === 'COPY' || x.keyword === 'ADD')
142
+ if (!install && !copies.some((x) => /node_modules/.test(x.args) || !/\.output/.test(x.args))) {
143
+ return { title: 'Put node_modules in the final image', lines: [`The final stage of ${service.dockerfile} copies only the built server, so node cannot load OpenTelemetry.`,
144
+ 'Install the packages in the final stage, or copy node_modules into it.', 'Then run npx @uknows/setup again.'] }
145
+ }
146
+ }
147
+ const bundler = BUNDLER.exec(typeof pkg.scripts?.build === 'string' ? pkg.scripts.build : '')
148
+ if (bundler && !(entry && ws.exists(join(service.dir, entry.replace(/^\.\//, ''))))) {
149
+ return { title: 'Load OpenTelemetry in a bundled app', lines: [`The build script bundles the app with ${bundler[1]}. OpenTelemetry cannot change bundled packages.`,
150
+ 'Add the packages below as normal dependencies. In the bundler config, mark them and the libraries to trace as external.',
151
+ 'Start node with the option below.'],
152
+ snippets: [{ caption: 'Install', language: 'bash', code: STACK.install }, { caption: 'Start command', language: 'bash', code: startLine(flag, entry) }] }
153
+ }
154
+ return null
155
+ }
156
+
157
+ /**
158
+ * The package manager from the packageManager field, else from the lockfile of the service folder or of
159
+ * the workspace root above it (a parent folder whose package.json has workspaces, or with pnpm-workspace.yaml).
160
+ * The search goes on above the --path folder, up to the top of the git repo (view.top). A workspace root or
161
+ * its lockfile up there is `outside`: this tool cannot update it.
162
+ */
163
+ function packageManager(dir, pkg, view) {
164
+ const field = typeof pkg.packageManager === 'string' ? /^(npm|pnpm|yarn|bun)@(\d+)/.exec(pkg.packageManager) : null
165
+ let cmd = field ? field[1] : typeof pkg.packageManager === 'string' ? 'other' : null
166
+ let lock = null, outside = null
167
+ for (const d of upFrom(dir, view.top ?? '.')) {
168
+ const pnpmRoot = view.exists(join(d, 'pnpm-workspace.yaml'))
169
+ const marker = pnpmRoot || /"workspaces"\s*:/.test(read(view, join(d, 'package.json')) ?? '')
170
+ const hit = (d === dir || marker) && LOCKS.find(([file, owner]) => (!cmd || cmd === owner) && read(view, join(d, file)) !== null)
171
+ if ((d === '..' || d.startsWith('../')) && (marker || hit)) { outside = join(d, hit ? hit[0] : pnpmRoot ? 'pnpm-workspace.yaml' : 'package.json'); break }
172
+ if (hit) { lock = join(d, hit[0]); cmd = hit[1]; break }
173
+ }
174
+ cmd ??= 'npm'
175
+ const home = lock ? dirOf(lock) : dir
176
+ const rc = read(view, join(home, '.yarnrc.yml'))
177
+ const berry = cmd === 'yarn' && (field ? Number(field[2]) >= 2 : rc !== null || /^__metadata:/m.test(read(view, lock ?? '') ?? ''))
178
+ const pnp = cmd === 'yarn' && (view.exists(join(home, '.pnp.cjs')) || view.exists(join(home, '.pnp.js'))
179
+ || (berry && !/^\s*nodeLinker\s*:\s*["']?(node-modules|pnpm)/m.test(rc ?? '')))
180
+ return { cmd, lock, pnp, berry, outside }
181
+ }
182
+
183
+ /** The JS file that node runs, from a start argv. `npm start` and friends read scripts.start. */
184
+ function entryOf(argv, script) {
185
+ let words = argv ?? null
186
+ if (words && /^(npm|yarn|pnpm)$/.test(base(words[0])) && (words[1] === 'start' || (words[1] === 'run' && words[2] === 'start'))) words = shellWords(script ?? '')
187
+ if (!words?.length) return null
188
+ if (/\.[cm]?[jt]s$/.test(words[0])) return words[0]
189
+ if (base(words[0]) !== 'node') return null
190
+ for (let i = 1; i < words.length; i++) {
191
+ if (!words[i].startsWith('-')) return words[i]
192
+ if (VALUE_FLAGS.has(words[i])) i++
193
+ }
194
+ return null
195
+ }
196
+
197
+ /** Insert the option right after `node` in scripts.start. Changes only that JSON string. null when unsure. */
198
+ function editStartScript(text, flag) {
199
+ const doc = new Doc(text)
200
+ const from = doc.find(/"scripts"\s*:/)
201
+ const i = from < 0 ? -1 : doc.find(/"start"\s*:\s*"/, from)
202
+ if (i < 0) return null
203
+ const line = doc.get(i)
204
+ const m = /("start"\s*:\s*)("(?:[^"\\]|\\.)*")/.exec(line)
205
+ if (!m) return null
206
+ const value = JSON.parse(m[2]).replace(/^(\s*node)(?=\s|$)/, `$1 ${flag}`)
207
+ doc.set(i, line.slice(0, m.index + m[1].length) + JSON.stringify(value) + line.slice(m.index + m[0].length))
208
+ const after = doc.toString()
209
+ try {
210
+ const expected = JSON.parse(text)
211
+ expected.scripts.start = value
212
+ return JSON.stringify(JSON.parse(after)) === JSON.stringify(expected) ? after : null
213
+ } catch { return null }
214
+ }
215
+
216
+ function startLine(flag, entry) {
217
+ return STACK.start.replace(PINS.node.requireFlag, flag).replace(/server\.js$/, entry ?? 'server.js')
218
+ }
219
+
220
+ function startBlock(flag, entry, where) {
221
+ return { title: 'Load OpenTelemetry when node starts',
222
+ lines: [where ? `This tool cannot change ${where} safely.` : 'This tool did not find the command that starts the app.',
223
+ 'Add the option below to the node command that starts the app. Or set NODE_OPTIONS to it where the app runs.',
224
+ ...STACK.notes.filter((n) => !entry && /server\.js/.test(n))],
225
+ snippets: [{ caption: 'Start command', language: 'bash', code: startLine(flag, entry) },
226
+ { caption: 'Or set NODE_OPTIONS', language: 'bash', code: `NODE_OPTIONS="${flag}"` }] }
227
+ }
228
+
229
+ const base = (word) => (word ?? '').split('/').pop()
230
+ const join = (dir, file) => (dir === '.' ? file : `${dir}/${file}`)
231
+ const dirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')
232
+
233
+ function read(ws, path) {
234
+ try { return path ? ws.read(path) : null } catch { return null }
235
+ }
@@ -0,0 +1,11 @@
1
+ // The app already uses OpenTelemetry: keep its packages and start command. settings.mjs adds the 5 settings.
2
+ import { STACKS } from '../contract.mjs'
3
+
4
+ /** @type {import('../types.mjs').Applier} */
5
+ export const otel = {
6
+ id: 'otel',
7
+ plan() {
8
+ return { summary: ['Keep your OpenTelemetry packages. Point them at UKNOWS with the 5 settings.'], edits: [], commands: [],
9
+ extraSettings: [], manual: [], notes: [...STACKS.find((s) => s.id === 'otel').notes] }
10
+ },
11
+ }
@@ -0,0 +1,51 @@
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.
11
+ */
12
+
13
+ /** Always printed. */
14
+ export const DEFAULTS = ['asyncio', 'dbapi', 'exceptions', 'logging', 'sqlite3', 'threading', 'urllib', 'wsgi']
15
+
16
+ /** [normalized library name, instrumentation suffixes], in upstream order. */
17
+ export const LIBRARIES = [
18
+ ['aio-pika', ['aio-pika']], ['aiohttp', ['aiohttp-client', 'aiohttp-server']], ['aiokafka', ['aiokafka']],
19
+ ['aiopg', ['aiopg']], ['asgiref', ['asgi']], ['asyncclick', ['asyncclick']], ['asyncpg', ['asyncpg']],
20
+ ['boto3', ['boto3sqs']], ['botocore', ['botocore']], ['aiobotocore', ['botocore']],
21
+ ['cassandra-driver', ['cassandra']], ['scylla-driver', ['cassandra']], ['celery', ['celery']], ['click', ['click']],
22
+ ['confluent-kafka', ['confluent-kafka']], ['django', ['django']], ['falcon', ['falcon']], ['fastapi', ['fastapi']],
23
+ ['flask', ['flask']], ['grpcio', ['grpc']], ['httpx', ['httpx']], ['httpx2', ['httpx']], ['jinja2', ['jinja2']],
24
+ ['kafka-python', ['kafka-python']], ['mysql-connector-python', ['mysql']], ['mysqlclient', ['mysqlclient']],
25
+ ['pika', ['pika']], ['psycopg', ['psycopg']], ['psycopg2', ['psycopg2']], ['psycopg2-binary', ['psycopg2']],
26
+ ['pymemcache', ['pymemcache']], ['pymongo', ['pymongo']], ['pymssql', ['pymssql']], ['pymysql', ['pymysql']],
27
+ ['pyramid', ['pyramid']], ['redis', ['redis']], ['remoulade', ['remoulade']], ['requests', ['requests']],
28
+ ['sqlalchemy', ['sqlalchemy']], ['starlette', ['starlette']], ['structlog', ['structlog']], ['tornado', ['tornado']],
29
+ ['tortoise-orm', ['tortoiseorm']], ['urllib3', ['urllib3']],
30
+ ]
31
+
32
+ /** Packages that a framework always installs, so upstream would see them too. */
33
+ export const IMPLIED = { fastapi: ['starlette'], django: ['asgiref'], flask: ['jinja2'], requests: ['urllib3'], boto3: ['botocore'] }
34
+
35
+ /** PEP 503 name: lower case, runs of - _ . become -. */
36
+ export const normalize = (name) => name.toLowerCase().replace(/[-_.]+/g, '-')
37
+
38
+ /**
39
+ * The instrumentation packages for these requirement names: the defaults,
40
+ * then 1 per matching library in table order, without repeats.
41
+ * @param {Iterable<string>} names normalized requirement names
42
+ * @param {string} version e.g. '0.66b1'
43
+ * @returns {string[]} e.g. ['opentelemetry-instrumentation-asyncio==0.66b1', ...]
44
+ */
45
+ export function bootstrap(names, version) {
46
+ const have = new Set(names)
47
+ for (const name of [...have]) for (const extra of IMPLIED[name] ?? []) have.add(extra)
48
+ const suffixes = [...DEFAULTS]
49
+ for (const [library, list] of LIBRARIES) if (have.has(library)) suffixes.push(...list)
50
+ return [...new Set(suffixes)].map((x) => `opentelemetry-instrumentation-${x}==${version}`)
51
+ }