@uknows/setup 0.1.0 → 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/README.md +99 -32
- package/lib/automate.mjs +1299 -0
- package/lib/check.mjs +149 -33
- package/lib/ci.mjs +21 -0
- package/lib/cli.mjs +114 -29
- package/lib/cloudid.mjs +456 -0
- package/lib/code.mjs +147 -0
- package/lib/context.mjs +126 -0
- package/lib/contract-docs.mjs +1780 -198
- package/lib/contract.mjs +93 -10
- package/lib/detect.mjs +96 -44
- package/lib/dockerfile.mjs +83 -0
- package/lib/endpoint.mjs +36 -5
- package/lib/engine.mjs +1272 -126
- package/lib/existing.mjs +448 -54
- package/lib/git.mjs +53 -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 +521 -16
- package/lib/locks.mjs +353 -0
- package/lib/manifest.mjs +27 -7
- package/lib/members.mjs +269 -0
- package/lib/plan.mjs +130 -0
- package/lib/settings.mjs +216 -28
- 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 +336 -29
- package/lib/stacks/functions-hooks.mjs +228 -0
- package/lib/stacks/functions.mjs +148 -0
- package/lib/stacks/guided.mjs +153 -30
- package/lib/stacks/index.mjs +10 -2
- package/lib/stacks/java.mjs +14 -13
- package/lib/stacks/node.mjs +142 -19
- package/lib/stacks/otel.mjs +56 -4
- package/lib/stacks/python-bootstrap.mjs +27 -7
- package/lib/stacks/python-lock.mjs +105 -0
- package/lib/stacks/python-sets.mjs +143 -0
- package/lib/stacks/python.mjs +261 -63
- package/lib/toml.mjs +64 -0
- package/lib/types.mjs +52 -5
- package/lib/ui.mjs +139 -20
- package/lib/updates.mjs +398 -0
- package/lib/versions.mjs +115 -0
- package/lib/workspace.mjs +4 -1
- package/package.json +3 -8
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Azure Monitor (App Insights) apps: keep it, and add 1 OTLP exporter to the same tracer provider, so nothing is
|
|
3
|
+
* traced twice. This mode never adds opentelemetry-instrument, NODE_OPTIONS or instrument.sh.
|
|
4
|
+
* Python: the exporter package at the locked opentelemetry-sdk version, a lock refresh (uv lock or poetry lock),
|
|
5
|
+
* and the docs block after the 1 configure_azure_monitor(...) statement.
|
|
6
|
+
* .NET: the exporter package at the resolved OpenTelemetry version, the using line and 1 line after CreateBuilder.
|
|
7
|
+
* Node.js: printed code and packages (option objects vary, and OpenTelemetry JS 2 cannot add a processor later).
|
|
8
|
+
* Anything that is not plain becomes a printed step. The text comes from AZURE_MONITOR in the docs file.
|
|
9
|
+
*/
|
|
10
|
+
import { posix } from 'node:path'
|
|
11
|
+
import { AZURE_MONITOR, PINS, RESOLVE } from '../contract.mjs'
|
|
12
|
+
import { Doc } from '../doc.mjs'
|
|
13
|
+
import { addUsing, csharpBuilder, insertPythonBlock, maskCsharp, pythonCalls, pythonStatement } from '../code.mjs'
|
|
14
|
+
import { dependencies, findExisting } from '../existing.mjs'
|
|
15
|
+
import { nearest, packageKey } from '../locks.mjs'
|
|
16
|
+
import { pythonProjectName } from '../members.mjs'
|
|
17
|
+
import { pyName, tomlString, tomlTable } from '../toml.mjs'
|
|
18
|
+
import { upFrom } from '../workspace.mjs'
|
|
19
|
+
import { addItems, offlineRestore, restoreStep } from './dotnet.mjs'
|
|
20
|
+
import { appendLines, compiledRequirements, dockerInstalls, editPyproject, parsePyproject } from './python.mjs'
|
|
21
|
+
import { lockStep } from './python-lock.mjs'
|
|
22
|
+
|
|
23
|
+
const AM = AZURE_MONITOR
|
|
24
|
+
const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
|
|
25
|
+
const dirOf = (path) => posix.dirname(path)
|
|
26
|
+
const read = (view, path) => { try { return view.read(path) } catch { return null } }
|
|
27
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
28
|
+
const LOCK_CMD = { 'uv.lock': 'uv', 'poetry.lock': 'poetry', 'pdm.lock': 'pdm' }
|
|
29
|
+
|
|
30
|
+
/** @type {import('../types.mjs').Applier} */
|
|
31
|
+
export const azure = {
|
|
32
|
+
id: 'azure',
|
|
33
|
+
plan(service, ctx) {
|
|
34
|
+
// What this mode is, and what it does not add for this stack.
|
|
35
|
+
const start = AM.plan.start[service.lang]
|
|
36
|
+
const out = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [AM.plan.mode, ...(start ? [fill(AM.plan.keep, { start })] : [])] }
|
|
37
|
+
const view = ctx.repo ?? ctx.ws
|
|
38
|
+
const existing = ctx.existing ?? findExisting(service, view)
|
|
39
|
+
if (service.lang === 'python') {
|
|
40
|
+
python(service, ctx, existing, out)
|
|
41
|
+
if (!out.stop) out.manual.push(...untracedServer(service, view, existing))
|
|
42
|
+
}
|
|
43
|
+
else if (service.lang === 'dotnet') dotnet(service, ctx, existing, out)
|
|
44
|
+
else if (service.lang === 'node') node(service, ctx, existing, out)
|
|
45
|
+
else out.manual.push(printedBlock('Add UKNOWS beside Azure Monitor', ['This tool has no automatic steps for Azure Monitor in this language.'], []))
|
|
46
|
+
out.manual.push(samplingAdvice(existing, service.lang))
|
|
47
|
+
return out
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The sampling that applies, as advice: code arguments, OTEL_TRACES_SAMPLER, or the default of 5 a second. Then why it
|
|
53
|
+
* matters, the option names of this stack and how to send every trace, each said once.
|
|
54
|
+
*/
|
|
55
|
+
function samplingAdvice(existing, lang) {
|
|
56
|
+
const anchors = existing.anchors ?? []
|
|
57
|
+
const code = anchors.find((a) => a.kind === 'sampling')
|
|
58
|
+
const env = anchors.find((a) => a.kind === 'sampler-env')
|
|
59
|
+
const line = code ? fill(AM.plan.samplingCode, { file: code.file, line: code.line, value: code.what })
|
|
60
|
+
: env ? fill(AM.plan.samplingEnv, { file: env.file, value: env.value ?? env.what }) : AM.plan.samplingDefault
|
|
61
|
+
const options = AM.plan.samplingOptions[lang] ?? AM.plan.samplingOptions.python
|
|
62
|
+
return { title: 'Check the sampling of Azure Monitor', lines: [line, ...AM.plan.sampling.map((l) => fill(l, { options }))], required: false }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A required step with the code to add by hand. The Keep line names only what this stack must not add. */
|
|
66
|
+
function printedBlock(title, reasons, snippets, lang = null) {
|
|
67
|
+
const start = AM.plan.start[lang]
|
|
68
|
+
const steps = AM.steps.map((step) => (start && /^Keep APPLICATIONINSIGHTS_CONNECTION_STRING/.test(step) ? fill(AM.plan.keep, { start }) : step))
|
|
69
|
+
return { title, lines: [...reasons, ...steps], snippets }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Python ----------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
function python(service, ctx, existing, out) {
|
|
75
|
+
const { ws } = ctx
|
|
76
|
+
const view = ctx.repo ?? ws
|
|
77
|
+
const exporterName = AM.python.package
|
|
78
|
+
const sdk = existing.installed?.packages.get('opentelemetry-sdk')
|
|
79
|
+
let requirement = sdk?.version ? `${exporterName}==${sdk.version}` : exporterName
|
|
80
|
+
const printed = (reasons) => {
|
|
81
|
+
out.manual.push(printedBlock('Add UKNOWS beside Azure Monitor', reasons, [
|
|
82
|
+
{ caption: 'Package to add', language: 'text', code: requirement },
|
|
83
|
+
{ caption: `Python: after the ${AM.python.call}...) statement`, language: 'python', code: AM.python.code }], 'python'))
|
|
84
|
+
}
|
|
85
|
+
// The manifest that lists azure-monitor-opentelemetry: in a uv workspace, the member that declares it.
|
|
86
|
+
const calls = pythonCallSites(existing, view)
|
|
87
|
+
const declared = (existing.azure ?? []).filter((e) => /(?:^|\/)(pyproject\.toml|requirements[\w.-]*\.txt)$/.test(e.file))
|
|
88
|
+
const near = calls[0] && declared.find((e) => calls[0].file === e.file || calls[0].file.startsWith(`${dirOf(e.file)}/`) || dirOf(e.file) === '.')
|
|
89
|
+
const manifest = (near ?? declared[0])?.file
|
|
90
|
+
if (!manifest) return printed(['This tool did not find the file that lists azure-monitor-opentelemetry.'])
|
|
91
|
+
|
|
92
|
+
// The image must install the file that gets the package, or the import in the block would fail at start.
|
|
93
|
+
const docker = service.dockerfile ? read(ws, service.dockerfile) : null
|
|
94
|
+
if (docker !== null && !imageInstalls(service, docker, manifest, view, existing)) {
|
|
95
|
+
return printed([`${service.dockerfile} does not install ${manifest}, so the image would not have ${exporterName}. This tool changed nothing.`])
|
|
96
|
+
}
|
|
97
|
+
// The code goes in automatically only when it is safe: 1 call, a whole statement, no pylint config.
|
|
98
|
+
const reasons = []
|
|
99
|
+
if (calls.length !== 1) reasons.push(calls.length ? `The app calls ${AM.python.call}...) ${calls.length} times.` : `This tool did not find the ${AM.python.call}...) call.`)
|
|
100
|
+
const call = calls.length === 1 ? calls[0] : null
|
|
101
|
+
const statement = call ? pythonStatement(read(ws, call.file), call) : null
|
|
102
|
+
if (statement && !statement.ok) reasons.push(`This tool cannot add code after the ${AM.python.call}...) call in ${call.file} line ${call.line + 1}: ${statement.reason}.`)
|
|
103
|
+
const pylint = pylintConfig(view, call?.file ?? manifest)
|
|
104
|
+
if (pylint) reasons.push(`${pylint} configures pylint, which can reject the added code.`)
|
|
105
|
+
|
|
106
|
+
// The package, then the lock.
|
|
107
|
+
const text = read(ws, manifest)
|
|
108
|
+
const listed = dependencies(manifest, text ?? '').some((d) => d.name === exporterName)
|
|
109
|
+
const lockFile = /pyproject\.toml$/.test(manifest) ? lockFor(view, dirOf(manifest)) : null
|
|
110
|
+
const lockName = lockFile?.split('/').pop()
|
|
111
|
+
const member = pythonProjectName(view, dirOf(manifest))
|
|
112
|
+
const locked = lockFile && existing.installed?.packages.get(member ?? '')?.requires.some((r) => pyName(r.name) === pyName(exporterName))
|
|
113
|
+
const needsLock = lockFile && (!listed || !locked)
|
|
114
|
+
// A uv or Poetry project gets a lower limit: its resolver keeps the exporter at the SDK version, now and after a
|
|
115
|
+
// later Azure Monitor upgrade. An exact pin would hold the SDK at today's version (gate open issue 7).
|
|
116
|
+
const tool = lockName === 'uv.lock' ? 'uv lock' : lockName === 'poetry.lock' ? 'poetry lock' : null
|
|
117
|
+
if (tool && sdk?.version) requirement = `${exporterName}>=${sdk.version}`
|
|
118
|
+
if (needsLock && lockName === 'pdm.lock') return printed([`${manifest} is locked with PDM. Add the package, then run pdm lock.`])
|
|
119
|
+
// The tool on this computer writes the lock that the image reads: it must be there, and the version the project asks for.
|
|
120
|
+
const step = needsLock ? lockStep({ view, lockFile, tools: ctx.tools, dockerfile: service.dockerfile, docker, packages: exporterName }) : null
|
|
121
|
+
if (step?.block) {
|
|
122
|
+
out.manual.push(step.block)
|
|
123
|
+
out.stop = true
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
// A requirements file that pip-compile or uv writes, or that has hashes, takes no lines from this tool. Then the
|
|
127
|
+
// code block stays out too: its import would fail at start without the package.
|
|
128
|
+
const compiled = !listed && /\.txt$/.test(manifest) ? compiledRequirements(ws, manifest) : null
|
|
129
|
+
if (compiled) return printed([...compiled, 'This tool changed nothing.'])
|
|
130
|
+
if (!listed) {
|
|
131
|
+
const deps = parsePyproject(text ?? '')
|
|
132
|
+
const after = /\.txt$/.test(manifest) ? appendLines(text, [requirement])
|
|
133
|
+
: deps?.kind === 'poetry' ? editPyproject(text, deps, [sdk?.version ? `${exporterName}>=${sdk.version}` : `${exporterName}==*`]) : deps ? editPyproject(text, deps, [requirement]) : null
|
|
134
|
+
if (after === null) return printed([`This tool cannot change the dependency list in ${manifest}.`])
|
|
135
|
+
out.edits.push({ file: manifest, before: text, after, why: `Add ${exporterName}, so Azure Monitor traces also go to UKNOWS.` })
|
|
136
|
+
out.summary.push(`Add ${requirement} to ${manifest}${sdk?.version ? `, at the opentelemetry-sdk version in ${sdk.file}` : ''}.`)
|
|
137
|
+
if (sdk?.version) out.notes.push(fill(tool ? AM.plan.floor : AM.plan.pin, { file: manifest, package: exporterName, version: sdk.version, tool }))
|
|
138
|
+
}
|
|
139
|
+
if (step?.command) out.commands.push(step.command)
|
|
140
|
+
if (reasons.length) return printed(reasons)
|
|
141
|
+
const source = read(ws, call.file)
|
|
142
|
+
if (/\bdef\s+_send_traces_to_uknows\s*\(/.test(source)) return
|
|
143
|
+
const after = insertPythonBlock(source, statement, AM.python.code, quoteStyle(view, call.file))
|
|
144
|
+
out.edits.push({ file: call.file, before: source, after, why: 'Also send the Azure Monitor traces to UKNOWS.' })
|
|
145
|
+
const count = after.split('\n').length - source.split('\n').length
|
|
146
|
+
out.summary.push(fill(AM.plan.pythonCode, { count, call: AM.python.call, file: call.file, line: statement.end + 1 }))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A required step when the app serves HTTP with a framework that Azure Monitor does not trace (Starlette, FastMCP,
|
|
151
|
+
* aiohttp, Tornado, Falcon, Pyramid) and with none that it traces (Django, FastAPI, Flask). Without it, App Insights
|
|
152
|
+
* and UKNOWS get no request spans. Nothing when the app lists the instrumentation package already.
|
|
153
|
+
*/
|
|
154
|
+
function untracedServer(service, view, existing) {
|
|
155
|
+
const files = [...new Set([...service.manifests, ...(existing.members ?? []).flatMap((m) => m.manifests ?? []),
|
|
156
|
+
...(existing.azure ?? []).map((e) => e.file)])].filter((f) => /(?:^|\/)(pyproject\.toml|requirements[\w.-]*\.txt|Pipfile)$/.test(f))
|
|
157
|
+
const listed = files.flatMap((file) => dependencies(file, read(view, file) ?? '').map((d) => ({ ...d, name: pyName(d.name), file })))
|
|
158
|
+
const has = (name) => listed.find((d) => d.name === name)
|
|
159
|
+
if (AM.python.traced.some(has)) return []
|
|
160
|
+
const anchors = existing.anchors ?? []
|
|
161
|
+
const serves = (name) => (name === 'aiohttp' ? anchors.some((a) => a.kind === 'aiohttp-server') : name === 'mcp' ? anchors.some((a) => a.kind === 'mcp-server') : true)
|
|
162
|
+
const installed = existing.installed?.packages
|
|
163
|
+
const contrib = installed?.get('opentelemetry-instrumentation')?.version ?? contribOf(installed?.get('opentelemetry-sdk')?.version)
|
|
164
|
+
const T = AM.python.untraced
|
|
165
|
+
for (const server of AM.python.servers) {
|
|
166
|
+
if (has(server.package)) return []
|
|
167
|
+
const found = server.names.map(has).find((d) => d && serves(d.name))
|
|
168
|
+
if (!found) continue
|
|
169
|
+
const values = { ...server, name: found.name, file: found.file, line: found.line, manifest: found.file, version: contrib ? `, here ${contrib}` : '' }
|
|
170
|
+
return [{ title: fill(T.title, values), verify: true, lines: [fill(T.found, values), ...(found.name !== server.label.toLowerCase() ? [fill(T.builtOn, values)] : []),
|
|
171
|
+
T.why, fill(T.add, values), fill(T.call, values)],
|
|
172
|
+
snippets: [{ caption: 'Package to add', language: 'text', code: contrib ? `${server.package}==${contrib}` : server.package },
|
|
173
|
+
{ caption: T.caption, language: 'python', code: `from ${server.module} import ${server.instrumentor}\n\n${server.instrumentor}().instrument()` }] }]
|
|
174
|
+
}
|
|
175
|
+
return []
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The contrib release (opentelemetry-instrumentation-*) of an opentelemetry-sdk release: 1.36.0 -> 0.57b0. */
|
|
179
|
+
const contribOf = (core) => { const m = /^1\.(\d+)\.(\d+)$/.exec(core ?? ''); return m ? `0.${Number(m[1]) + 21}b${m[2]}` : null }
|
|
180
|
+
|
|
181
|
+
/** Real configure_azure_monitor( calls in the files where the anchor scan found the name. */
|
|
182
|
+
function pythonCallSites(existing, view) {
|
|
183
|
+
const files = [...new Set((existing.anchors ?? []).filter((a) => a.kind === 'azure-call' && a.file.endsWith('.py')).map((a) => a.file))]
|
|
184
|
+
return files.flatMap((file) => pythonCalls(read(view, file) ?? '', 'configure_azure_monitor').map((c) => ({ ...c, file })))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The image installs this manifest: uv sync, poetry install or pip install . for a pyproject.toml, pip install -r for
|
|
189
|
+
* a requirements file. uv sync --package <name> installs that member and the members it depends on, not the others.
|
|
190
|
+
*/
|
|
191
|
+
function imageInstalls(service, docker, manifest, view, existing) {
|
|
192
|
+
const installs = dockerInstalls(service, docker)
|
|
193
|
+
if (!/pyproject\.toml$/.test(manifest)) return installs.files.includes(manifest)
|
|
194
|
+
if (!installs.project || installs.packages === null) return installs.project
|
|
195
|
+
const name = pythonProjectName(view, dirOf(manifest))
|
|
196
|
+
return (name !== null && installs.packages.includes(name)) || (existing.members ?? []).some((m) => m.dir === dirOf(manifest))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The uv, Poetry or PDM lockfile that covers a folder. */
|
|
200
|
+
function lockFor(view, dir) {
|
|
201
|
+
for (const name of Object.keys(LOCK_CMD)) { const file = nearest(view, dir, name); if (file) return file }
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A pylint config next to the code or above it: .pylintrc, pylintrc, [tool.pylint] or [pylint] in setup.cfg or tox.ini. */
|
|
206
|
+
function pylintConfig(view, file) {
|
|
207
|
+
for (const dir of upFrom(dirOf(file), view.top ?? '.')) {
|
|
208
|
+
for (const name of ['.pylintrc', 'pylintrc']) if (read(view, at(dir, name)) !== null) return at(dir, name)
|
|
209
|
+
if (/^\s*\[tool\.pylint/m.test(read(view, at(dir, 'pyproject.toml')) ?? '')) return at(dir, 'pyproject.toml')
|
|
210
|
+
for (const name of ['setup.cfg', 'tox.ini']) if (/^\s*\[(?:tool:)?pylint/m.test(read(view, at(dir, name)) ?? '')) return at(dir, name)
|
|
211
|
+
}
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Single quotes when ruff format says so: [tool.ruff.format] quote-style, or [format] in ruff.toml. Else double, as black. */
|
|
216
|
+
function quoteStyle(view, file) {
|
|
217
|
+
for (const dir of upFrom(dirOf(file), view.top ?? '.')) {
|
|
218
|
+
for (const [name, table] of [['ruff.toml', 'format'], ['.ruff.toml', 'format'], ['pyproject.toml', 'tool.ruff.format']]) {
|
|
219
|
+
const style = tomlString(tomlTable(read(view, at(dir, name)) ?? '', table), 'quote-style')
|
|
220
|
+
if (style) return style === 'single' ? "'" : '"'
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return '"'
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- .NET ------------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
function dotnet(service, ctx, existing, out) {
|
|
229
|
+
const { ws } = ctx
|
|
230
|
+
const view = ctx.repo ?? ws
|
|
231
|
+
const { package: exporter } = PINS.dotnet.otlpExporter
|
|
232
|
+
const projects = service.manifests.filter((m) => m.endsWith('.csproj'))
|
|
233
|
+
const csproj = projects.find((p) => /Microsoft\.NET\.Sdk\.Web/.test(read(ws, p) ?? '')) ?? projects[0]
|
|
234
|
+
const resolved = existing.installed?.packages.get('opentelemetry')?.version
|
|
235
|
+
const listed = csproj ? dependencies(csproj, read(ws, csproj) ?? '') : []
|
|
236
|
+
const direct = listed.find((d) => /^OpenTelemetry(\.Extensions\.Hosting|\.Exporter\.[\w.]+)?$/i.test(d.name) && /^\d+\.\d+\.\d+$/.test(d.spec ?? ''))?.spec
|
|
237
|
+
const version = resolved ?? direct ?? PINS.dotnet.otlpExporter.version
|
|
238
|
+
const code = [AM.dotnet.using, '', AM.dotnet.after, AM.dotnet.line].join('\n')
|
|
239
|
+
const printed = (reasons) => out.manual.push(printedBlock('Add UKNOWS beside Azure Monitor', reasons, [
|
|
240
|
+
{ caption: 'Terminal', language: 'bash', code: `dotnet add package ${exporter} --version ${version}` },
|
|
241
|
+
{ caption: '.NET: Program.cs', language: 'csharp', code }], 'dotnet'))
|
|
242
|
+
if (!csproj) return printed(['This tool did not find the .csproj file of the web app.'])
|
|
243
|
+
|
|
244
|
+
// Program.cs: exactly 1 WebApplication.CreateBuilder in the project that gets the package.
|
|
245
|
+
const builders = projectSources(ws, csproj).map((file) => ({ file, text: read(ws, file) ?? '' })).filter((f) => /WebApplication\s*\.\s*Create(?:Slim)?Builder\s*\(/.test(maskCsharp(f.text)))
|
|
246
|
+
const done = builders.some((f) => /ConfigureOpenTelemetryTracerProvider\s*\([^;]*AddOtlpExporter/.test(maskCsharp(f.text)))
|
|
247
|
+
const program = builders.length === 1 ? builders[0] : null
|
|
248
|
+
const statement = program ? csharpBuilder(program.text) : null
|
|
249
|
+
const reasons = []
|
|
250
|
+
if (!done && builders.length !== 1) reasons.push(builders.length ? `${builders.length} files call WebApplication.CreateBuilder.` : 'This app does not call WebApplication.CreateBuilder, for example because it uses a Startup class.')
|
|
251
|
+
if (!done && statement && !statement.ok) reasons.push(`This tool cannot add the line in ${program.file}: ${statement.reason}.`)
|
|
252
|
+
|
|
253
|
+
// The package, at the OpenTelemetry version the app resolves now.
|
|
254
|
+
const text = read(ws, csproj)
|
|
255
|
+
if (!listed.some((d) => d.name.toLowerCase() === exporter.toLowerCase())) {
|
|
256
|
+
// A locked restore refreshes packages.lock.json after the edit. When it cannot run, nothing changes.
|
|
257
|
+
const docker = service.dockerfile ? read(ws, service.dockerfile) : null
|
|
258
|
+
const offline = docker === null ? null : offlineRestore(docker)
|
|
259
|
+
const framework = /<TargetFrameworks?>\s*net(\d+)\./i.exec(text ?? '')?.[1] ?? null
|
|
260
|
+
const restore = restoreStep(service, ctx, csproj, framework)
|
|
261
|
+
if (offline || restore?.block) {
|
|
262
|
+
const block = offline ? { title: RESOLVE.dotnet.offline.title, lines: [fill(RESOLVE.dotnet.offline.lines[0], { file: service.dockerfile, line: offline.line })] }
|
|
263
|
+
: restore.block
|
|
264
|
+
out.manual.push({ ...block, snippets: [{ caption: 'Terminal', language: 'bash', code: `dotnet add package ${exporter} --version ${version}` },
|
|
265
|
+
{ caption: '.NET: Program.cs', language: 'csharp', code }] })
|
|
266
|
+
out.stop = true
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
const central = upFrom(dirOf(csproj), view.top ?? '.').map((d) => at(d, 'Directory.Packages.props')).find((f) => read(view, f) !== null)
|
|
270
|
+
const centrally = central && /<ManagePackageVersionsCentrally>\s*true\s*</i.test(read(view, central))
|
|
271
|
+
if (centrally) return printed([`This repo sets package versions in ${central}. Add ${exporter} ${version} there and in ${csproj}.`, ...reasons])
|
|
272
|
+
const after = addItems(text, [`<PackageReference Include="${exporter}" Version="${version}" />`], /<PackageReference\s/)
|
|
273
|
+
if (after === null) return printed([`This tool cannot add a package to ${csproj}.`, ...reasons])
|
|
274
|
+
out.edits.push({ file: csproj, before: text, after, why: `Add the ${exporter} package, so Azure Monitor traces also go to UKNOWS.` })
|
|
275
|
+
out.summary.push(`Add ${exporter} ${version} to ${csproj}${resolved ? ', at the OpenTelemetry version the app resolves now' : ''}.`)
|
|
276
|
+
if (!resolved) out.notes.push(`${exporter} ${version} needs OpenTelemetry ${version} or later. If the app uses an older OpenTelemetry, this raises it to ${version}.`)
|
|
277
|
+
if (restore?.command) out.commands.push(...restore.before, restore.command)
|
|
278
|
+
if (restore?.command) out.notes.push(...restore.notes)
|
|
279
|
+
if (restore?.note) out.notes.push(restore.note)
|
|
280
|
+
}
|
|
281
|
+
if (done) return
|
|
282
|
+
if (reasons.length) return printed(reasons)
|
|
283
|
+
const withUsing = addUsing(program.text, AM.dotnet.using)
|
|
284
|
+
// The using line can move the builder line down by 1 or 2 lines.
|
|
285
|
+
const moved = csharpBuilder(withUsing)
|
|
286
|
+
const doc = new Doc(withUsing)
|
|
287
|
+
doc.insert(moved.end + 1, `${moved.indent}${AM.dotnet.line.replace(/\bbuilder\.Services\b/, `${moved.name}.Services`)}`)
|
|
288
|
+
out.edits.push({ file: program.file, before: program.text, after: doc.toString(), why: 'Also send the Azure Monitor traces to UKNOWS.' })
|
|
289
|
+
out.summary.push(`Also send the Azure Monitor traces to UKNOWS: add ${AM.dotnet.using} and 1 line after the builder in ${program.file}.`)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The .cs files that 1 project compiles: the files under its folder, without obj/ and bin/, without the folders of
|
|
294
|
+
* other projects below it, and without the <Compile Remove="..."> globs of the project.
|
|
295
|
+
*/
|
|
296
|
+
export function projectSources(ws, csproj) {
|
|
297
|
+
const dir = dirOf(csproj)
|
|
298
|
+
const under = (file) => dir === '.' || file.startsWith(`${dir}/`)
|
|
299
|
+
const rel = (file) => (dir === '.' ? file : file.slice(dir.length + 1))
|
|
300
|
+
const all = ws.files(dir).filter(under)
|
|
301
|
+
const nested = all.filter((f) => f.endsWith('.csproj') && dirOf(f) !== dir).map(dirOf)
|
|
302
|
+
const removed = [...(read(ws, csproj) ?? '').matchAll(/<Compile\s[^>]*?Remove\s*=\s*"([^"]+)"/gi)]
|
|
303
|
+
.flatMap((m) => m[1].split(';')).map((g) => g.trim().replace(/\\/g, '/').replace(/^\.\//, '')).filter(Boolean).map(globRegex)
|
|
304
|
+
return all.filter((f) => f.endsWith('.cs') && !/(?:^|\/)(?:obj|bin)\//.test(rel(f))
|
|
305
|
+
&& !nested.some((d) => f.startsWith(`${d}/`)) && !removed.some((re) => re.test(rel(f))))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** An MSBuild item glob as a regex on a path relative to the project: ** any folders, * and ? within 1 name. */
|
|
309
|
+
function globRegex(glob) {
|
|
310
|
+
let out = ''
|
|
311
|
+
for (let i = 0; i < glob.length; i++) {
|
|
312
|
+
const c = glob[i]
|
|
313
|
+
if (c === '*' && glob[i + 1] === '*') { out += glob[i + 2] === '/' ? '(?:.*/)?' : '.*'; i += glob[i + 2] === '/' ? 2 : 1 }
|
|
314
|
+
else if (c === '*') out += '[^/]*'
|
|
315
|
+
else if (c === '?') out += '[^/]'
|
|
316
|
+
else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
317
|
+
}
|
|
318
|
+
return new RegExp(`^${out}$`, 'i')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// --- Node.js ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
function node(service, ctx, existing, out) {
|
|
324
|
+
const view = ctx.repo ?? ctx.ws
|
|
325
|
+
const packages = existing.installed?.packages ?? new Map()
|
|
326
|
+
const versionOf = (name) => packages.get(packageKey('node', name))?.version ?? null
|
|
327
|
+
// The experimental packages share 1 version line: take the exporter version from one the distro already has.
|
|
328
|
+
const experimental = versionOf(AM.node.packages[0]) ?? versionOf('@opentelemetry/instrumentation') ?? versionOf('@opentelemetry/sdk-node')
|
|
329
|
+
const stable = versionOf(AM.node.packages[1])
|
|
330
|
+
const wanted = [[AM.node.packages[0], experimental], [AM.node.packages[1], stable]]
|
|
331
|
+
const pkg = (() => { try { return JSON.parse(read(view, at(service.dir, 'package.json')) ?? '') } catch { return null } })()
|
|
332
|
+
const has = (name) => ['dependencies', 'devDependencies'].some((g) => pkg?.[g]?.[name])
|
|
333
|
+
const call = (existing.anchors ?? []).find((a) => a.kind === 'azure-call' && /\.(m?[jt]sx?|c[jt]s)$/.test(a.file))
|
|
334
|
+
const shim = !call && (existing.azure ?? []).some((e) => e.what === 'applicationinsights')
|
|
335
|
+
const added = call && /OTLPTraceExporter/.test(read(view, call.file) ?? '')
|
|
336
|
+
if (added && wanted.every(([name]) => has(name))) return
|
|
337
|
+
const pm = nearest(view, service.dir, 'pnpm-lock.yaml') ? 'pnpm add' : nearest(view, service.dir, 'yarn.lock') ? 'yarn add' : 'npm install --save'
|
|
338
|
+
const install = `${pm} ${wanted.map(([name, v]) => (v ? `${name}@${v}` : name)).join(' ')}`
|
|
339
|
+
const where = call ? `Found ${AM.node.call}...) in ${call.file} line ${call.line}.` : null
|
|
340
|
+
out.manual.push(printedBlock('Add the UKNOWS exporter to Azure Monitor', [where, shim ? AM.node.shim : null,
|
|
341
|
+
'Node.js apps pass their Azure Monitor options in many ways, so this tool does not change the code.',
|
|
342
|
+
...AM.notes.filter((n) => /Node\.js/.test(n))].filter(Boolean), [
|
|
343
|
+
{ caption: 'Terminal', language: 'bash', code: install },
|
|
344
|
+
{ caption: `Node.js: where your app calls ${AM.node.call.slice(0, -1)}`, language: 'javascript', code: AM.node.code }], 'node'))
|
|
345
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The agent channel (--channel stable|early): for a Java, .NET or Node.js service that builds from a Dockerfile, the
|
|
2
|
+
// agent comes from the UKNOWS agent image at every build. The change is 1 COPY line and the start settings in the
|
|
3
|
+
// final stage, after the last RUN. Packages, lock files and the start command do not change.
|
|
4
|
+
//
|
|
5
|
+
// COPY --from=<registry>/agent-<lang>:<channel> /uknows/ /uknows/
|
|
6
|
+
// Java JAVA_TOOL_OPTIONS -javaagent:/uknows/java/uknows-loader.jar
|
|
7
|
+
// Node.js NODE_OPTIONS --require /uknows/node/register.cjs (ES modules: --import /uknows/node/register.mjs)
|
|
8
|
+
// .NET CORECLR_* (the OpenTelemetry profiler for glibc or musl), OTEL_DOTNET_AUTO_HOME, DOTNET_STARTUP_HOOKS
|
|
9
|
+
//
|
|
10
|
+
// Each language starts through a small UKNOWS loader in the image. It checks the agent files (size and checksum)
|
|
11
|
+
// and starts the agent, or prints 1 line and lets the app start without it. So an agent file that is missing or
|
|
12
|
+
// broken never stops the app.
|
|
13
|
+
import { AGENT_CHANNELS, AGENT_REGISTRY, STACKS } from '../contract.mjs'
|
|
14
|
+
import { addOption, dockerStart, insertBeforeStart, parseDockerfile, setEnv } from '../dockerfile.mjs'
|
|
15
|
+
import { entryOf } from './node.mjs'
|
|
16
|
+
import { targetFramework, webProject } from './dotnet.mjs'
|
|
17
|
+
|
|
18
|
+
export const CHANNELS = AGENT_CHANNELS
|
|
19
|
+
export const LANGS = ['java', 'dotnet', 'node']
|
|
20
|
+
const DOTNET_PROFILER = '{918728DD-259F-4A6A-AC2B-B85E1B658318}'
|
|
21
|
+
const WINDOWS = /windows|nanoserver|servercore/i
|
|
22
|
+
|
|
23
|
+
/** The registry and path that hold the agent images, for example registry.example.com/uknows. No scheme, no tag. */
|
|
24
|
+
export function validRegistry(value) {
|
|
25
|
+
return typeof value === 'string' && AGENT_REGISTRY.test(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const image = (registry, lang, channel) => `${registry}/agent-${lang}:${channel}`
|
|
29
|
+
export const copyLine = (registry, lang, channel) => `COPY --from=${image(registry, lang, channel)} /uknows/ /uknows/`
|
|
30
|
+
|
|
31
|
+
/** The settings that start the agent of 1 language. */
|
|
32
|
+
export function startSettings(lang, { esm = false, musl = false } = {}) {
|
|
33
|
+
if (lang === 'java') return { option: ['JAVA_TOOL_OPTIONS', '-javaagent:/uknows/java/uknows-loader.jar'] }
|
|
34
|
+
if (lang === 'node') return { option: ['NODE_OPTIONS', esm ? '--import /uknows/node/register.mjs' : '--require /uknows/node/register.cjs'] }
|
|
35
|
+
return { env: [
|
|
36
|
+
['CORECLR_ENABLE_PROFILING', '1'],
|
|
37
|
+
['CORECLR_PROFILER', DOTNET_PROFILER],
|
|
38
|
+
['CORECLR_PROFILER_PATH', `/uknows/dotnet/${musl ? 'musl' : 'glibc'}/OpenTelemetry.AutoInstrumentation.Native.so`],
|
|
39
|
+
['OTEL_DOTNET_AUTO_HOME', '/uknows/dotnet'],
|
|
40
|
+
['DOTNET_STARTUP_HOOKS', '/uknows/dotnet/net/OpenTelemetry.AutoInstrumentation.StartupHook.dll'],
|
|
41
|
+
] }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Does this service fit the channel? null when it does, else the reason (it then keeps the package route). */
|
|
45
|
+
export function channelMisfit(service) {
|
|
46
|
+
if (!LANGS.includes(service.lang)) return 'The agent channel has Java, .NET and Node.js.'
|
|
47
|
+
if (!service.dockerfile) return 'The agent channel needs a Dockerfile, and this service does not build from one.'
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const read = (ws, path) => { try { return ws.read(path) } catch { return null } }
|
|
52
|
+
|
|
53
|
+
/** @type {import('../types.mjs').Applier} */
|
|
54
|
+
export const channel = {
|
|
55
|
+
id: 'channel',
|
|
56
|
+
plan(service, ctx) {
|
|
57
|
+
const plan = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] }
|
|
58
|
+
const { registry, name: channelName } = ctx.channel ?? {}
|
|
59
|
+
if (!validRegistry(registry) || !CHANNELS.includes(channelName)) throw new Error('The agent channel needs its registry and its name.')
|
|
60
|
+
const lang = service.lang
|
|
61
|
+
const text = read(ctx.ws, service.dockerfile)
|
|
62
|
+
const info = text === null ? null : parseDockerfile(text)
|
|
63
|
+
const base = info?.image ?? ''
|
|
64
|
+
if (!info) {
|
|
65
|
+
plan.manual.push({ title: 'Add the UKNOWS agent', lines: [`This tool cannot read ${service.dockerfile}.`] })
|
|
66
|
+
return plan
|
|
67
|
+
}
|
|
68
|
+
if (WINDOWS.test(base)) {
|
|
69
|
+
plan.manual.push({ title: 'Add the UKNOWS agent', lines: [`The final image ${base} is a Windows image. The agent channel has Linux images only.`] })
|
|
70
|
+
return plan
|
|
71
|
+
}
|
|
72
|
+
const musl = /alpine/i.test(base)
|
|
73
|
+
let esm = false
|
|
74
|
+
if (lang === 'node') {
|
|
75
|
+
const v = /^(\d+)(?:\.(\d+))?/.exec(service.runtimeVersion ?? '')
|
|
76
|
+
const [major, minor] = v ? [Number(v[1]), v[2] === undefined ? 99 : Number(v[2])] : [99, 99]
|
|
77
|
+
if (major < 18 || major === 19 || (major === 18 && minor < 19) || (major === 20 && minor < 6)) {
|
|
78
|
+
plan.manual.push({ title: 'Use Node.js 18.19 or later', lines: [`This app uses Node.js ${service.runtimeVersion}.`,
|
|
79
|
+
'The UKNOWS agent needs Node.js 18.19 or later on version 18, or 20.6 or later.', 'Update the Node.js version of the app. Then run npx @uknows/setup again.'] })
|
|
80
|
+
return plan
|
|
81
|
+
}
|
|
82
|
+
let pkg = {}
|
|
83
|
+
try { pkg = JSON.parse(read(ctx.ws, service.dir === '.' ? 'package.json' : `${service.dir}/package.json`) ?? '{}') } catch { /* not JSON: CommonJS */ }
|
|
84
|
+
const entry = entryOf(dockerStart(info)?.argv, typeof pkg.scripts?.start === 'string' ? pkg.scripts.start : null)
|
|
85
|
+
esm = /\.m[jt]s$/.test(entry ?? '') || (pkg.type === 'module' && !/\.c[jt]s$/.test(entry ?? ''))
|
|
86
|
+
}
|
|
87
|
+
if (lang === 'dotnet') {
|
|
88
|
+
const csproj = webProject(service, ctx.ws)
|
|
89
|
+
const framework = csproj ? targetFramework(read(ctx.ws, csproj) ?? '', []) : null
|
|
90
|
+
const major = /^net(?:coreapp)?(\d+)\./.exec(framework ?? '')?.[1] ?? (/^net\d+$/.test(framework ?? '') ? '4' : null)
|
|
91
|
+
if (major !== null && Number(major) < 8) {
|
|
92
|
+
plan.manual.push({ title: 'Use .NET 8 or later', lines: [`${csproj} targets ${framework}.`, 'The UKNOWS agent needs .NET 8 or later.',
|
|
93
|
+
'Change the target framework to net8.0 or later. Then run npx @uknows/setup again.'] })
|
|
94
|
+
return plan
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const line = copyLine(registry, lang, channelName)
|
|
99
|
+
const has = (l) => l.trim().startsWith(`COPY --from=${registry}/agent-${lang}:`)
|
|
100
|
+
let after = text.split('\n').some(has) ? text : insertBeforeStart(text, [line])
|
|
101
|
+
const settings = startSettings(lang, { esm, musl })
|
|
102
|
+
if (settings.option) {
|
|
103
|
+
const [name, option] = settings.option
|
|
104
|
+
after = addOption(after, name, option)
|
|
105
|
+
if (info.env.has(name) || info.inherited.has(name)) plan.notes.push(`${service.dockerfile} already sets ${name}. A new ENV line before the start keeps that value and adds the agent.`)
|
|
106
|
+
} else {
|
|
107
|
+
const hooks = info.env.get('DOTNET_STARTUP_HOOKS') ?? info.inherited.get('DOTNET_STARTUP_HOOKS')
|
|
108
|
+
const pairs = settings.env.map(([n, v]) => (n === 'DOTNET_STARTUP_HOOKS' && hooks?.value && !hooks.value.includes(v) ? [n, `${v}:${hooks.value}`] : [n, v]))
|
|
109
|
+
after = setEnv(after, pairs)
|
|
110
|
+
// The docs settings for .NET add a log folder that the app can write.
|
|
111
|
+
for (const l of STACKS.find((s) => s.id === 'dotnet').extraSettings?.lines ?? []) plan.extraSettings.push({ name: l.slice(0, l.indexOf('=')), value: l.slice(l.indexOf('=') + 1) })
|
|
112
|
+
}
|
|
113
|
+
if (lang === 'node') {
|
|
114
|
+
for (const l of STACKS.find((s) => s.id === 'node').extraSettings?.lines ?? []) plan.extraSettings.push({ name: l.slice(0, l.indexOf('=')), value: l.slice(l.indexOf('=') + 1) })
|
|
115
|
+
}
|
|
116
|
+
if (after === text) return plan
|
|
117
|
+
plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Add the UKNOWS agent from the agent image, and start it.' })
|
|
118
|
+
plan.summary.push(`Copy the UKNOWS agent into the image at each build, from ${image(registry, lang, channelName)} (the ${channelName} channel).`,
|
|
119
|
+
`Start the agent with the app (${settings.option ? settings.option[0] : `the .NET profiler settings${musl ? ' for musl (Alpine)' : ''}`} in ${service.dockerfile}).`)
|
|
120
|
+
plan.notes.push(`Each build copies the current agent of the ${channelName} channel. A new agent reaches the app at its next build and deploy, with no change to this repo.`,
|
|
121
|
+
'If an agent file is missing or broken, the app starts without the agent and prints 1 line that starts with "UKNOWS agent:". UKNOWS_AGENT=off turns the agent off.')
|
|
122
|
+
if (lang === 'node' && esm) plan.notes.push('This app runs as ES modules, so node loads the agent with --import.')
|
|
123
|
+
return plan
|
|
124
|
+
},
|
|
125
|
+
}
|