@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,228 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The UKNOWS worker hooks for Azure Functions: the route for a function app that sends to Application Insights (or
|
|
3
|
+
* where setup cannot tell). The Functions host stays in its Application Insights mode, so Application Insights keeps the
|
|
4
|
+
* same requests, names and fields; a small file in the worker opens a server span for each call as a child of the trace
|
|
5
|
+
* that the host passes on, and sends it and the logs over OTLP. Measured on the bench, 22 and 23 Oct 2026 (install
|
|
6
|
+
* proof A3): Application Insights the same as without the hooks (request names, the 11 fields, the traces per request),
|
|
7
|
+
* a server span for every request, the same trace id and InvocationId as Application Insights, error calls with status
|
|
8
|
+
* error and the exception type and frames, never the message.
|
|
9
|
+
*
|
|
10
|
+
* Node.js (v4 programming model): uknows-otel.cjs in the app folder, loaded through the "main" glob of package.json,
|
|
11
|
+
* and the OpenTelemetry packages at the tested versions. Python (v2 programming model): uknows_otel.py, 2 lines in
|
|
12
|
+
* function_app.py (the import, and instrument(app) at the end), the packages in requirements.txt, and the app setting
|
|
13
|
+
* PYTHON_ENABLE_WORKER_EXTENSIONS=1. .NET isolated: UknowsOpenTelemetry.cs, 2 lines in Program.cs, 2 PackageReferences.
|
|
14
|
+
* Where a part cannot go in by itself (another shape of Program.cs, a package manager that this tool does not run),
|
|
15
|
+
* the step prints. Where the hooks cannot apply (the v3 Node.js model, the v1 Python model, the .NET in-process model,
|
|
16
|
+
* an app that has OpenTelemetry or Azure Monitor in its code already), `unsupported` says why.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { FUNCTIONS } from '../contract.mjs'
|
|
20
|
+
import { Doc } from '../doc.mjs'
|
|
21
|
+
import { list } from '../ui.mjs'
|
|
22
|
+
import { addItems } from './dotnet.mjs'
|
|
23
|
+
import { packageManager } from './node.mjs'
|
|
24
|
+
import { appendLines, compiledRequirements } from './python.mjs'
|
|
25
|
+
|
|
26
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, k) => (k in values ? String(values[k]) : m))
|
|
27
|
+
const join = (dir, file) => (dir === '.' ? file : `${dir}/${file}`)
|
|
28
|
+
const dirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')
|
|
29
|
+
const template = (name) => readFileSync(new URL(`../hooks/${name}`, import.meta.url), 'utf8')
|
|
30
|
+
function read(ws, path) {
|
|
31
|
+
try { return path ? ws.read(path) : null } catch { return null }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { HOOKS, HOOK_FILES, TESTED_ON } from '../hooks/pins.mjs'
|
|
35
|
+
import { HOOK_FILES, HOOKS, TESTED_ON } from '../hooks/pins.mjs'
|
|
36
|
+
|
|
37
|
+
const plan0 = () => ({ summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [], unsupported: null, versions: [] })
|
|
38
|
+
const not = (out, reason) => ({ ...out, unsupported: reason })
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The hooks for this service: {edits, commands, summary, manual, notes, extraSettings, versions, unsupported}.
|
|
42
|
+
* versions: [{name, found, tested}] for the check of untested versions (versionCheck).
|
|
43
|
+
*/
|
|
44
|
+
export function hooksPlan(s, ctx) {
|
|
45
|
+
const existing = ctx.existing ?? {}
|
|
46
|
+
// OpenTelemetry or Azure Monitor of the app itself; the packages and the file of the hooks are not (a rerun).
|
|
47
|
+
const ours = new Set(Object.values(HOOKS).flatMap((h) => h.packages.map(([name]) => name.toLowerCase())))
|
|
48
|
+
const own = (list) => (list ?? []).filter((x) => !ours.has(String(x.what ?? '').toLowerCase()) && !HOOK_FILES.includes(String(x.file ?? '').split('/').pop()))
|
|
49
|
+
if (own(existing.otel).length || own(existing.azure).length) return not(plan0(), FUNCTIONS.hooks.unsupported.otel)
|
|
50
|
+
if (s.lang === 'node') return nodeHooks(s, ctx)
|
|
51
|
+
if (s.lang === 'python') return pythonHooks(s, ctx)
|
|
52
|
+
if (s.lang === 'dotnet') return dotnetHooks(s, ctx)
|
|
53
|
+
return not(plan0(), fill(FUNCTIONS.hooks.unsupported.language, { lang: s.lang }))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** major.minor of a version or range ("^4.16.5" is 4.16), or null. */
|
|
57
|
+
const minor = (v) => { const m = /(\d+)\.(\d+)/.exec(String(v ?? '')); return m ? `${m[1]}.${m[2]}` : null }
|
|
58
|
+
|
|
59
|
+
function nodeHooks(s, ctx) {
|
|
60
|
+
const out = plan0()
|
|
61
|
+
const ws = ctx.ws
|
|
62
|
+
const pkgFile = join(s.dir, 'package.json')
|
|
63
|
+
const text = read(ws, pkgFile)
|
|
64
|
+
let pkg = null
|
|
65
|
+
try { pkg = JSON.parse(text ?? '') } catch { /* below */ }
|
|
66
|
+
if (!pkg) return not(out, FUNCTIONS.hooks.unsupported.packageJson)
|
|
67
|
+
const fnVersion = pkg.dependencies?.['@azure/functions'] ?? pkg.devDependencies?.['@azure/functions'] ?? null
|
|
68
|
+
const major = Number(/(\d+)/.exec(String(fnVersion ?? ''))?.[1] ?? NaN)
|
|
69
|
+
if (!(major >= 4)) return not(out, FUNCTIONS.hooks.unsupported.nodeV3)
|
|
70
|
+
if (typeof pkg.main !== 'string' || !pkg.main.trim()) return not(out, FUNCTIONS.hooks.unsupported.nodeMain)
|
|
71
|
+
const H = HOOKS.node
|
|
72
|
+
out.versions.push({ name: '@azure/functions', found: String(fnVersion), tested: H.tested[0][1] })
|
|
73
|
+
// The hook file, and the main glob that loads it next to the functions (the worker loads every file it matches).
|
|
74
|
+
const file = join(s.dir, H.file)
|
|
75
|
+
if (read(ws, file) === null) out.edits.push({ file, before: null, after: template(H.file), why: 'Add the UKNOWS hooks for Azure Functions.' })
|
|
76
|
+
if (!pkg.main.includes(H.file)) {
|
|
77
|
+
const main = `{${H.file},${pkg.main}}`
|
|
78
|
+
const at = new RegExp(`("main"\\s*:\\s*)${JSON.stringify(pkg.main).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)
|
|
79
|
+
const after = text.replace(at, `$1${JSON.stringify(main)}`)
|
|
80
|
+
if (after === text) return not(plan0(), FUNCTIONS.hooks.unsupported.nodeMain)
|
|
81
|
+
out.edits.push({ file: pkgFile, before: text, after, why: 'Load the UKNOWS hooks with the functions.' })
|
|
82
|
+
out.summary.push(fill(FUNCTIONS.hooks.summary.node, { file: H.file, main }))
|
|
83
|
+
} else out.summary.push(fill(FUNCTIONS.hooks.summary.file, { file: H.file }))
|
|
84
|
+
// The packages, at the versions of the bench, with the package manager of the repo (as the Node.js stack adds them).
|
|
85
|
+
const wanted = H.packages.filter(([name]) => !pkg.dependencies?.[name])
|
|
86
|
+
if (!wanted.length) return out
|
|
87
|
+
const pm = packageManager(s.dir, pkg, ctx.repo ?? ws)
|
|
88
|
+
const specs = wanted.map(([name, v]) => `${name}@${v}`)
|
|
89
|
+
if (pm.outside || pm.cmd === 'bun' || pm.cmd === 'other' || pm.pnp) {
|
|
90
|
+
out.manual.push({ title: FUNCTIONS.hooks.packagesTitle, required: true, verify: true,
|
|
91
|
+
lines: [FUNCTIONS.hooks.packagesByHand], snippets: [{ caption: 'Packages', language: 'text', code: specs.join('\n') }] })
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
const flags = pm.cmd === 'npm' ? ['--save-exact', '--ignore-scripts', '--no-audit', '--no-fund', pm.lock ? '--package-lock-only' : '--no-package-lock']
|
|
95
|
+
: pm.cmd === 'pnpm' ? ['--save-exact', '--lockfile-only'] : ['--exact', pm.berry ? '--mode=skip-build' : '--ignore-scripts']
|
|
96
|
+
const lock = pm.lock ?? (pm.cmd === 'npm' ? null : join(s.dir, { pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock' }[pm.cmd]))
|
|
97
|
+
const why = `Add ${list(wanted.map(([name]) => name))} with ${pm.cmd}.`
|
|
98
|
+
const names = wanted.map(([name]) => name)
|
|
99
|
+
out.commands.push({ cwd: s.dir, cmd: pm.cmd, why, added: names, lockOnly: pm.cmd === 'pnpm' || (pm.cmd === 'npm' && Boolean(pm.lock)),
|
|
100
|
+
args: pm.cmd === 'npm' ? ['install', '--save', ...flags, ...specs] : ['add', ...flags, ...specs],
|
|
101
|
+
undo: { cmd: pm.cmd, args: [pm.cmd === 'npm' ? 'uninstall' : 'remove', ...flags.filter((f) => !/exact/.test(f)), ...names] },
|
|
102
|
+
touches: [pkgFile, ...(lock ? [lock] : [])] })
|
|
103
|
+
out.summary.push(why)
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The variable of the FunctionApp in function_app.py, and the line of the azure.functions import. */
|
|
108
|
+
export function pythonApp(text) {
|
|
109
|
+
const apps = [...text.matchAll(/^(\w+)\s*=\s*(?:[\w.]+\.)?FunctionApp\s*\(/gm)].map((m) => m[1])
|
|
110
|
+
const imports = new Doc(text).lines.map((line, i) => [line, i]).filter(([line]) => /^(?:import\s+azure\.functions\b|from\s+azure\.functions\s+import\b|from\s+azure\s+import\s+functions\b)/.test(line))
|
|
111
|
+
return { app: apps.length === 1 ? apps[0] : null, apps: apps.length, importAt: imports.length ? imports[imports.length - 1][1] : -1 }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function pythonHooks(s, ctx) {
|
|
115
|
+
const out = plan0()
|
|
116
|
+
const ws = ctx.ws
|
|
117
|
+
const H = HOOKS.python
|
|
118
|
+
const appFile = join(s.dir, 'function_app.py')
|
|
119
|
+
const text = read(ws, appFile)
|
|
120
|
+
if (text === null || !/FunctionApp\s*\(/.test(text)) return not(out, FUNCTIONS.hooks.unsupported.pythonV1)
|
|
121
|
+
const reqFile = join(s.dir, 'requirements.txt')
|
|
122
|
+
const req = read(ws, reqFile)
|
|
123
|
+
if (req === null) return not(out, FUNCTIONS.hooks.unsupported.pythonRequirements)
|
|
124
|
+
const fnVersion = /^azure-functions\s*(?:==|~=|>=)\s*([\d.]+)/m.exec(req)?.[1] ?? null
|
|
125
|
+
out.versions.push({ name: 'azure-functions', found: fnVersion, tested: H.tested[0][1] })
|
|
126
|
+
const file = join(s.dir, H.file)
|
|
127
|
+
if (read(ws, file) === null) out.edits.push({ file, before: null, after: template(H.file), why: 'Add the UKNOWS hooks for Azure Functions.' })
|
|
128
|
+
// 2 lines in function_app.py: the import after the azure.functions import, and instrument(app) at the end.
|
|
129
|
+
const found = pythonApp(text)
|
|
130
|
+
if (!/^import uknows_otel\b/m.test(text)) {
|
|
131
|
+
if (!found.app || found.importAt < 0) {
|
|
132
|
+
out.manual.push({ title: FUNCTIONS.hooks.pythonTitle, required: true, verify: true,
|
|
133
|
+
lines: [fill(FUNCTIONS.hooks.pythonByHand, { file: appFile })],
|
|
134
|
+
snippets: [{ caption: appFile, language: 'python', code: 'import uknows_otel\n\n# ... your functions ...\n\nuknows_otel.instrument(app)' }] })
|
|
135
|
+
} else {
|
|
136
|
+
const doc = new Doc(text)
|
|
137
|
+
doc.insert(found.importAt + 1, 'import uknows_otel # UKNOWS: a span for each call (npx @uknows/setup)')
|
|
138
|
+
const after = appendLines(doc.toString(), ['', '', `uknows_otel.instrument(${found.app}) # UKNOWS: after the last function`])
|
|
139
|
+
out.edits.push({ file: appFile, before: text, after, why: 'Load the UKNOWS hooks with the functions.' })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// The packages: lines in requirements.txt, which the remote build of Azure installs.
|
|
143
|
+
const names = new Set([...req.matchAll(/^\s*([A-Za-z0-9_.-]+)/gm)].map((m) => m[1].toLowerCase().replace(/_/g, '-')))
|
|
144
|
+
const missing = H.packages.filter(([name]) => !names.has(name)).map(([name, v]) => `${name}==${v}`)
|
|
145
|
+
if (missing.length) {
|
|
146
|
+
const compiled = compiledRequirements(ws, reqFile)
|
|
147
|
+
if (compiled) out.manual.push({ title: FUNCTIONS.hooks.packagesTitle, required: true, verify: true, lines: compiled,
|
|
148
|
+
snippets: [{ caption: 'Packages', language: 'text', code: missing.join('\n') }] })
|
|
149
|
+
else {
|
|
150
|
+
out.edits.push({ file: reqFile, before: req, after: appendLines(req, missing), why: 'Add the OpenTelemetry packages of the UKNOWS hooks.' })
|
|
151
|
+
out.summary.push(`Add ${missing.length} OpenTelemetry packages to ${reqFile}.`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
out.summary.unshift(fill(FUNCTIONS.hooks.summary.python, { file: H.file, app: appFile }))
|
|
155
|
+
out.extraSettings.push(...H.settings)
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The Program.cs edit: the middleware and the services, in the 3 shapes of the templates. null: another shape. */
|
|
160
|
+
export function programEdit(text) {
|
|
161
|
+
const doc = new Doc(text)
|
|
162
|
+
const lines = doc.lines
|
|
163
|
+
const T = 'Uknows.AzureFunctions.UknowsOpenTelemetry'
|
|
164
|
+
// var builder = FunctionsApplication.CreateBuilder(args);
|
|
165
|
+
const app = lines.findIndex((l) => /^\s*var\s+\w+\s*=\s*FunctionsApplication\.CreateBuilder\s*\(\s*args\s*\)\s*;\s*$/.test(l))
|
|
166
|
+
if (app >= 0) {
|
|
167
|
+
const name = /var\s+(\w+)/.exec(lines[app])[1]
|
|
168
|
+
const indent = /^\s*/.exec(lines[app])[0]
|
|
169
|
+
doc.insert(app + 1, `${indent}${name}.UseMiddleware<${T}>(); // UKNOWS (npx @uknows/setup)`, `${indent}${T}.AddTo(${name}.Services);`)
|
|
170
|
+
return doc.toString()
|
|
171
|
+
}
|
|
172
|
+
// new HostBuilder().ConfigureFunctionsWorkerDefaults() or .ConfigureFunctionsWebApplication(), with no arguments.
|
|
173
|
+
const at = lines.findIndex((l) => /\.ConfigureFunctions(?:WorkerDefaults|WebApplication)\(\s*\)/.test(l))
|
|
174
|
+
const build = lines.findIndex((l, i) => i >= at && /\.Build\(\)/.test(l))
|
|
175
|
+
if (at < 0 || build < 0 || lines.filter((l) => /\.ConfigureFunctions(?:WorkerDefaults|WebApplication)\(/.test(l)).length !== 1) return null
|
|
176
|
+
doc.set(at, lines[at].replace(/\.ConfigureFunctions(WorkerDefaults|WebApplication)\(\s*\)/, `.ConfigureFunctions$1(worker => worker.UseMiddleware<${T}>())`))
|
|
177
|
+
const line = doc.get(build)
|
|
178
|
+
if (/^\s*\.Build\(\)/.test(line)) doc.insert(build, `${/^\s*/.exec(line)[0]}.ConfigureServices(${T}.AddTo)`)
|
|
179
|
+
else doc.set(build, line.replace(/\.Build\(\)/, `.ConfigureServices(${T}.AddTo).Build()`))
|
|
180
|
+
return doc.toString()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function dotnetHooks(s, ctx) {
|
|
184
|
+
const out = plan0()
|
|
185
|
+
const ws = ctx.ws
|
|
186
|
+
const H = HOOKS.dotnet
|
|
187
|
+
const csproj = (s.manifests ?? []).find((f) => f.endsWith('.csproj') && /Microsoft\.Azure\.Functions\.Worker"/i.test(read(ws, f) ?? ''))
|
|
188
|
+
const projText = csproj ? read(ws, csproj) : null
|
|
189
|
+
if (!projText) return not(out, FUNCTIONS.hooks.unsupported.dotnetInProcess)
|
|
190
|
+
const workerVersion = /<PackageReference\s+Include="Microsoft\.Azure\.Functions\.Worker"\s+Version="([^"]+)"/i.exec(projText)?.[1] ?? null
|
|
191
|
+
out.versions.push({ name: 'Microsoft.Azure.Functions.Worker', found: workerVersion, tested: H.tested[0][1] })
|
|
192
|
+
const dir = dirOf(csproj)
|
|
193
|
+
const file = join(dir, H.file)
|
|
194
|
+
if (read(ws, file) === null) out.edits.push({ file, before: null, after: template(H.file), why: 'Add the UKNOWS hooks for Azure Functions.' })
|
|
195
|
+
const program = join(dir, 'Program.cs')
|
|
196
|
+
const ptext = read(ws, program)
|
|
197
|
+
if (ptext !== null && !ptext.includes('UknowsOpenTelemetry')) {
|
|
198
|
+
const after = programEdit(ptext)
|
|
199
|
+
if (after) out.edits.push({ file: program, before: ptext, after, why: 'Add the UKNOWS middleware and the OpenTelemetry services.' })
|
|
200
|
+
else out.manual.push({ title: FUNCTIONS.hooks.dotnetTitle, required: true, verify: true, lines: [fill(FUNCTIONS.hooks.dotnetByHand, { file: program })],
|
|
201
|
+
snippets: [{ caption: program, language: 'csharp', code: 'builder.UseMiddleware<Uknows.AzureFunctions.UknowsOpenTelemetry>();\nUknows.AzureFunctions.UknowsOpenTelemetry.AddTo(builder.Services);' }] })
|
|
202
|
+
}
|
|
203
|
+
const missing = H.packages.filter(([name]) => !new RegExp(`<PackageReference\\s+Include="${name.replace(/\./g, '\\.')}"`, 'i').test(projText))
|
|
204
|
+
const central = /<ManagePackageVersionsCentrally>\s*true\s*</i.test(projText) || [dir, '.'].some((d) => /<ManagePackageVersionsCentrally>\s*true\s*</i.test(read(ws, join(d, 'Directory.Packages.props')) ?? ''))
|
|
205
|
+
if (missing.length) {
|
|
206
|
+
const after = central ? null : addItems(projText, missing.map(([name, v]) => `<PackageReference Include="${name}" Version="${v}" />`), /<PackageReference\s/)
|
|
207
|
+
if (after) {
|
|
208
|
+
out.edits.push({ file: csproj, before: projText, after, why: 'Add the OpenTelemetry packages of the UKNOWS hooks.' })
|
|
209
|
+
out.summary.push(`Add ${missing.length} OpenTelemetry packages to ${csproj}.`)
|
|
210
|
+
}
|
|
211
|
+
else out.manual.push({ title: FUNCTIONS.hooks.packagesTitle, required: true, verify: true, lines: [FUNCTIONS.hooks.packagesByHand],
|
|
212
|
+
snippets: [{ caption: 'Packages', language: 'bash', code: missing.map(([name, v]) => `dotnet add package ${name} --version ${v}`).join('\n') }] })
|
|
213
|
+
}
|
|
214
|
+
out.summary.unshift(fill(FUNCTIONS.hooks.summary.dotnet, { file: H.file, program }))
|
|
215
|
+
return out
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The check of untested versions: an advice block when the app runs a version of the Functions library other than the
|
|
220
|
+
* one the bench ran (major.minor), or one that this tool cannot read. null when all match.
|
|
221
|
+
*/
|
|
222
|
+
export function versionCheck(versions) {
|
|
223
|
+
const off = versions.filter((v) => !v.found || minor(v.found) !== v.tested)
|
|
224
|
+
if (!off.length) return null
|
|
225
|
+
return { title: FUNCTIONS.hooks.untested.title, required: false, lines: [
|
|
226
|
+
...off.map((v) => fill(v.found ? FUNCTIONS.hooks.untested.other : FUNCTIONS.hooks.untested.unknown, { name: v.name, found: v.found ?? '', tested: v.tested })),
|
|
227
|
+
fill(FUNCTIONS.hooks.untested.on, { on: TESTED_ON }), ...FUNCTIONS.hooks.untested.lines] }
|
|
228
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Azure Functions (Node.js, Python and .NET isolated): the Functions host sends a server span for each call of a function,
|
|
3
|
+
* and the logs, over OTLP, when host.json has "telemetryMode": "OpenTelemetry" and the app has the OTLP app settings.
|
|
4
|
+
* Measured on the bench on 22 Oct 2026 (install proof A3): every request had its server span in all 3 languages, with
|
|
5
|
+
* no package and no code in the app. So the plan is 1 line in host.json. The settings and the key go on the function
|
|
6
|
+
* app as app settings (settings.mjs and the key steps), never in the repo.
|
|
7
|
+
*
|
|
8
|
+
* host.json keeps its own text: the line goes after "version", with the indent and the comma style of the file. A value
|
|
9
|
+
* of telemetryMode that the app has already is never changed: the step says what to set.
|
|
10
|
+
*
|
|
11
|
+
* Application Insights: with telemetryMode OpenTelemetry the host still sends to it, but names each request by its
|
|
12
|
+
* route instead of the function and leaves out InvocationId (measured 22 Oct 2026), so the portal's function views and
|
|
13
|
+
* the customer's queries change. So the line goes in only when setup knows the app does not send to Application
|
|
14
|
+
* Insights: the app settings of the function app have no connection string (az, names only, a run in a terminal) and
|
|
15
|
+
* no deploy file sets one. Else (or when setup cannot tell) it adds the UKNOWS worker hooks (functions-hooks.mjs),
|
|
16
|
+
* which keep Application Insights exactly as it is; where the hooks cannot apply, it stands down with a step.
|
|
17
|
+
* --functions-opentelemetry puts the line in anyway. The host and the worker send to Application Insights only with a
|
|
18
|
+
* connection string in the app settings, so packages and code that read it are signs to show, not proof: the
|
|
19
|
+
* templates of Microsoft have them too.
|
|
20
|
+
*/
|
|
21
|
+
import { FUNCTIONS } from '../contract.mjs'
|
|
22
|
+
import { Doc } from '../doc.mjs'
|
|
23
|
+
import { hooksPlan, versionCheck } from './functions-hooks.mjs'
|
|
24
|
+
|
|
25
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, k) => (k in values ? String(values[k]) : m))
|
|
26
|
+
/** App settings and packages that send to Application Insights. */
|
|
27
|
+
export const APP_INSIGHTS_SETTINGS = ['APPLICATIONINSIGHTS_CONNECTION_STRING', 'APPINSIGHTS_INSTRUMENTATIONKEY']
|
|
28
|
+
/** Source code: a connection string there is read from the app settings. In a deploy file, the deploy sets it. */
|
|
29
|
+
const CODE = /\.(?:[cm]?[jt]sx?|py|cs|fs|vb|java|go)$/i
|
|
30
|
+
const DOTNET_APP_INSIGHTS = /<PackageReference\s+Include\s*=\s*["'](Microsoft\.Azure\.Functions\.Worker\.ApplicationInsights|Microsoft\.ApplicationInsights\.WorkerService)["']/i
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The signs in the repo that the app sends to Application Insights, as [{file, what, deploy}]: App Insights or Azure
|
|
34
|
+
* Monitor packages and code (existing.mjs), the connection string setting in its code or deploy files (deploy: true,
|
|
35
|
+
* the next deploy sets it), and the .NET Functions App Insights packages. Not the applicationInsights part of host.json:
|
|
36
|
+
* every Functions template has it, and it does nothing without a connection string.
|
|
37
|
+
*/
|
|
38
|
+
export function appInsightsSigns(s, view, existing = {}) {
|
|
39
|
+
const signs = []
|
|
40
|
+
const add = (file, what, deploy = false) => { if (!signs.some((x) => x.file === file && x.what === what)) signs.push({ file, what, deploy }) }
|
|
41
|
+
for (const x of [...(existing.azure ?? []), ...(existing.classic ?? [])]) add(x.file, x.what)
|
|
42
|
+
for (const a of existing.anchors ?? []) {
|
|
43
|
+
if (a.kind === 'connection-string') add(a.file, 'APPLICATIONINSIGHTS_CONNECTION_STRING', !CODE.test(a.file))
|
|
44
|
+
if (a.kind === 'appinsights-call') add(a.file, 'Application Insights in the code')
|
|
45
|
+
}
|
|
46
|
+
for (const file of s.manifests ?? []) {
|
|
47
|
+
const m = file.endsWith('.csproj') ? DOTNET_APP_INSIGHTS.exec(view.read(file) ?? '') : null
|
|
48
|
+
if (m) add(file, m[1])
|
|
49
|
+
}
|
|
50
|
+
return signs
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What az shows of the function apps that the deploy files name, read only: for each {app, group}, the names of the app
|
|
55
|
+
* settings of the 1 function app of that name (never values). null when az is not installed or not signed in. A value
|
|
56
|
+
* of null for an app: not found, or found more than once.
|
|
57
|
+
*/
|
|
58
|
+
export async function functionAppSettingNames(cli, { root, env }, lookups) {
|
|
59
|
+
if (!lookups.length) return null
|
|
60
|
+
const az = (args) => cli('az', [...args, '--only-show-errors'], { cwd: root, env, timeoutMs: 60000 })
|
|
61
|
+
const accounts = await az(['account', 'list', '--query', "[?state=='Enabled'].{id:id, name:name}", '-o', 'json'])
|
|
62
|
+
if (accounts.error === 'missing' || accounts.code !== 0) return null
|
|
63
|
+
const subs = (parse(accounts.stdout) ?? []).filter((x) => typeof x?.id === 'string' && x.id)
|
|
64
|
+
if (!subs.length) return null
|
|
65
|
+
const out = new Map()
|
|
66
|
+
for (const { app, group } of lookups) {
|
|
67
|
+
const found = []
|
|
68
|
+
for (const sub of subs) {
|
|
69
|
+
const r = await az(['resource', 'list', '--subscription', sub.id, '--name', app, '--resource-type', 'Microsoft.Web/sites', '--query', '[].{name:name, resourceGroup:resourceGroup}', '-o', 'json'])
|
|
70
|
+
for (const row of (r.code === 0 ? parse(r.stdout) : null) ?? []) {
|
|
71
|
+
if (row?.name?.toLowerCase() === app.toLowerCase() && (!group || row.resourceGroup?.toLowerCase() === group.toLowerCase())) found.push({ sub: sub.id, group: row.resourceGroup, name: row.name })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (found.length !== 1) { out.set(app, null); continue }
|
|
75
|
+
const f = found[0]
|
|
76
|
+
const r = await az(['functionapp', 'config', 'appsettings', 'list', '--subscription', f.sub, '-g', f.group, '-n', f.name, '--query', '[].name', '-o', 'json'])
|
|
77
|
+
const names = r.code === 0 ? parse(r.stdout) : null
|
|
78
|
+
out.set(app, Array.isArray(names) ? names.map(String) : null)
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
const parse = (text) => { try { return JSON.parse(text) } catch { return null } }
|
|
83
|
+
|
|
84
|
+
/** The edit that adds telemetryMode to host.json, or {already} or {other: value} or {unreadable}. */
|
|
85
|
+
export function hostJsonEdit(text) {
|
|
86
|
+
const data = parse(text ?? '')
|
|
87
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return { unreadable: true }
|
|
88
|
+
if (data.telemetryMode === FUNCTIONS.telemetryMode) return { already: true }
|
|
89
|
+
if (data.telemetryMode !== undefined) return { other: JSON.stringify(data.telemetryMode) }
|
|
90
|
+
const doc = new Doc(text)
|
|
91
|
+
// The "version" line at the top level: the first line with that key and the smallest indent of the object.
|
|
92
|
+
const at = doc.find(/^\s*"version"\s*:/)
|
|
93
|
+
const pair = `"telemetryMode": "${FUNCTIONS.telemetryMode}"`
|
|
94
|
+
let after
|
|
95
|
+
if (at >= 0 && /^\s*"version"\s*:\s*"[^"]*"\s*,?\s*$/.test(doc.get(at))) {
|
|
96
|
+
const line = doc.get(at)
|
|
97
|
+
const indent = /^\s*/.exec(line)[0]
|
|
98
|
+
if (/,\s*$/.test(line)) doc.insert(at + 1, `${indent}${pair},`)
|
|
99
|
+
else {
|
|
100
|
+
doc.set(at, line.replace(/\s*$/, ','))
|
|
101
|
+
doc.insert(at + 1, `${indent}${pair}`)
|
|
102
|
+
}
|
|
103
|
+
after = doc.toString()
|
|
104
|
+
} else {
|
|
105
|
+
// A host.json on 1 line, or with "version" in another form: the same object, written again with 2 spaces.
|
|
106
|
+
after = `${JSON.stringify({ version: data.version, telemetryMode: FUNCTIONS.telemetryMode, ...data }, null, 2)}\n`
|
|
107
|
+
}
|
|
108
|
+
return parse(after)?.telemetryMode === FUNCTIONS.telemetryMode ? { after } : { unreadable: true }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const functions = {
|
|
112
|
+
id: 'functions',
|
|
113
|
+
/** @param {import('../types.mjs').Service} s */
|
|
114
|
+
plan(s, ctx) {
|
|
115
|
+
const plan = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [...FUNCTIONS.notes] }
|
|
116
|
+
const file = s.functions.hostJson
|
|
117
|
+
const before = ctx.ws.read(file)
|
|
118
|
+
const r = hostJsonEdit(before)
|
|
119
|
+
// ctx.functions: the decision of the engine ({route: 'host' or 'hooks', signs, unknown, predicted}). Without one,
|
|
120
|
+
// the line goes in (unit tests).
|
|
121
|
+
const decision = ctx.functions ?? { route: 'host', signs: [] }
|
|
122
|
+
if (r.after && decision.route === 'hooks') {
|
|
123
|
+
const hooks = hooksPlan(s, ctx)
|
|
124
|
+
if (!hooks.unsupported) {
|
|
125
|
+
const check = versionCheck(hooks.versions)
|
|
126
|
+
return { ...plan, summary: hooks.summary, edits: hooks.edits, commands: hooks.commands, extraSettings: hooks.extraSettings,
|
|
127
|
+
manual: [...hooks.manual, ...(check ? [check] : [])], notes: [...FUNCTIONS.hooks.notes] }
|
|
128
|
+
}
|
|
129
|
+
const S = FUNCTIONS.standDown
|
|
130
|
+
const first = decision.signs.length ? fill(S.signs, { signs: decision.signs.map((x) => `${x.what} in ${x.file}`).join(', ') }) : S.unknown
|
|
131
|
+
// Required and checked on each run: until OpenTelemetry is on, this app sends nothing to UKNOWS. stop: no
|
|
132
|
+
// settings, no record and no pull request for an app that this tool did not set up (engine.mjs).
|
|
133
|
+
plan.manual.push({ title: S.title, required: true, verify: true, lines: [fill(S.reason, { reason: hooks.unsupported }), first, ...S.lines] })
|
|
134
|
+
plan.notes = []
|
|
135
|
+
plan.stop = true
|
|
136
|
+
return plan
|
|
137
|
+
}
|
|
138
|
+
if (r.after && decision.predicted) plan.notes.push(FUNCTIONS.predicted)
|
|
139
|
+
if (r.after) {
|
|
140
|
+
plan.edits.push({ file, before, after: r.after, why: 'Turn on OpenTelemetry in the Functions host.' })
|
|
141
|
+
plan.summary.push(fill(FUNCTIONS.summary, { file }))
|
|
142
|
+
} else if (r.other || r.unreadable) {
|
|
143
|
+
const block = r.other ? FUNCTIONS.other : FUNCTIONS.unreadable
|
|
144
|
+
plan.manual.push({ title: block.title, required: true, verify: true, lines: block.lines.map((l) => fill(l, { file, value: r.other ?? '' })) })
|
|
145
|
+
}
|
|
146
|
+
return plan
|
|
147
|
+
},
|
|
148
|
+
}
|
package/lib/stacks/guided.mjs
CHANGED
|
@@ -1,44 +1,152 @@
|
|
|
1
1
|
// Go, PHP and Ruby: guided in v1. settings.mjs adds the settings as usual, but this tool does not edit
|
|
2
2
|
// go.mod, composer.json, Gemfile or code: without the lock or sum updates the build breaks. It prints
|
|
3
|
-
// the docs steps instead
|
|
4
|
-
|
|
5
|
-
import {
|
|
3
|
+
// the docs steps instead. Each step stays until the repo shows it is done (the SDK in go.mod, otel.go, the
|
|
4
|
+
// call in main, and so on), so a committed setup with a step undone stays at steps-left (exit 3).
|
|
5
|
+
import { GO_INIT, GO_LIBRARIES, GO_OLDER, GO_RELEASES, PINS, RUBY_INITIALIZER, STACKS, goInstall, goMain } from '../contract.mjs'
|
|
6
|
+
import { finalImage, finalStages, parseDockerfile } from '../dockerfile.mjs'
|
|
6
7
|
|
|
7
8
|
const docs = (id) => STACKS.find((s) => s.id === id)
|
|
8
9
|
const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
|
|
9
10
|
const runIn = (dir) => `Run these commands in ${dir === '.' ? 'the root folder of the repo' : dir}.`
|
|
10
11
|
const empty = () => ({ summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] })
|
|
12
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
13
|
+
const goNumber = ([major, minor]) => major * 1000 + minor
|
|
11
14
|
|
|
12
15
|
/** @type {import('../types.mjs').Applier} */
|
|
13
16
|
export const go = {
|
|
14
17
|
id: 'go',
|
|
15
18
|
plan(service, { ws }) {
|
|
16
19
|
const plan = empty()
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
const { files, notes, label } = docs('go')
|
|
21
|
+
// Each step is done when the repo shows it: the SDK in go.mod (go get writes it, also as // indirect), otel.go with
|
|
22
|
+
// setupOTel, and a call of setupOTel in the code. An app with its own NewTracerProvider set-up counts as done.
|
|
23
|
+
const done = goDone(service, ws)
|
|
24
|
+
if (done.packages && done.init && done.main) return plan
|
|
25
|
+
// go get raises the go line of go.mod to what the modules need, and an older Go builder then fails. So below
|
|
26
|
+
// Go 1.26 the go get lines name the versions for that Go (GO_RELEASES). Below the oldest row, Go is too old.
|
|
27
|
+
const version = goVersion(service, ws)
|
|
28
|
+
let release = null
|
|
29
|
+
if (version && goNumber(version) < goNumber(PINS.go.minGo)) {
|
|
30
|
+
release = GO_RELEASES.find((r) => r.go === version.join('.')) ?? null
|
|
31
|
+
// The Go note and the Go step go with the packages step: once go.mod has the SDK, Go was new enough for it.
|
|
32
|
+
if (release) { if (!done.packages) plan.notes.push(fill(GO_OLDER.plan, { version: release.go })) }
|
|
33
|
+
else {
|
|
34
|
+
release = GO_RELEASES.at(-1)
|
|
35
|
+
const oldest = PINS.go.oldestGo.join('.')
|
|
36
|
+
if (!done.packages) {
|
|
37
|
+
plan.manual.push({ title: `Use Go ${oldest} or later`, verify: true, lines: [`This app builds with Go ${version.join('.')}.`,
|
|
38
|
+
`The OpenTelemetry Go packages and otel.go need Go ${oldest} or later.`,
|
|
39
|
+
`Update the go line in go.mod and the Go version of the build image to ${oldest} or later. Do this first.`] })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
26
42
|
}
|
|
27
43
|
// The base go get, then only the '# Gin only' or '# Echo v4 only' part for a detected router.
|
|
28
|
-
const sections =
|
|
44
|
+
const sections = goInstall(release ?? undefined).split('\n').reduce((all, line) => {
|
|
29
45
|
if (line.startsWith('# ')) all.push([line]); else all.at(-1).push(line)
|
|
30
46
|
return all
|
|
31
47
|
}, [[]])
|
|
32
|
-
const
|
|
48
|
+
const lines = [runIn(service.dir)]
|
|
49
|
+
const code = [sections.filter((s, k) => k === 0 || service.frameworks.some((f) => s[0].toLowerCase().includes(f))).flat().join('\n')]
|
|
50
|
+
if (release && service.frameworks.includes('echo') && /otelecho/.test(code[0])) lines.push(GO_OLDER.echo)
|
|
51
|
+
// Spans for each query and each Redis command, for the libraries in go.mod.
|
|
52
|
+
// At versions that do not raise the gorm or go-redis of go.mod (a newer go-redis can send a handshake that the Redis server refuses).
|
|
53
|
+
const mod = ws.read(at(service.dir, 'go.mod')) ?? ''
|
|
54
|
+
const gorm = requireOf(mod, 'gorm.io/gorm')
|
|
55
|
+
if (gorm !== undefined) {
|
|
56
|
+
const fit = GO_LIBRARIES.gorm.versions.find((v) => gorm && semver(v.gorm) <= semver(gorm))
|
|
57
|
+
if (gorm && !fit) lines.push(fill(GO_LIBRARIES.gorm.tooOld, { gorm }))
|
|
58
|
+
else {
|
|
59
|
+
// An unknown gorm version gets the oldest plugin, which raises gorm to the gorm of that plugin.
|
|
60
|
+
const plugin = fit ?? GO_LIBRARIES.gorm.versions.at(-1)
|
|
61
|
+
lines.push(GO_LIBRARIES.gorm.note)
|
|
62
|
+
if (!gorm) lines.push(fill(GO_LIBRARIES.gorm.raises, { gorm: plugin.gorm }))
|
|
63
|
+
code.push(`go get ${GO_LIBRARIES.gorm.module}@${plugin.version}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const redis = requireOf(mod, 'github.com/redis/go-redis/v9')
|
|
67
|
+
if (redis !== undefined) {
|
|
68
|
+
const known = GO_LIBRARIES.redis.versions
|
|
69
|
+
const at9 = !redis ? null : semver(redis) > semver(known.at(-1)) ? redis : known.filter((v) => semver(v) <= semver(redis)).at(-1) ?? null
|
|
70
|
+
lines.push(GO_LIBRARIES.redis.note)
|
|
71
|
+
code.push(`go get ${GO_LIBRARIES.redis.module}${at9 ? `@${at9}` : ''}`)
|
|
72
|
+
}
|
|
73
|
+
// main.go with the middleware of the router this app uses, and the address it listens on. Each note once: the
|
|
74
|
+
// gorm and go-redis notes are in the packages step already, and the notes of other routers do not apply.
|
|
75
|
+
const routers = ['echo', 'gin', 'chi'].filter((f) => service.frameworks.includes(f)).map((f) => (f === 'echo' && release && goOlder(release) ? 'echoOlder' : f))
|
|
76
|
+
const older = Boolean(release) || (version && goNumber(version) < goNumber(PINS.go.minGo))
|
|
77
|
+
// The Echo v5 note only for an app on Echo v5. The net/http note only for an app with no router that this tool knows:
|
|
78
|
+
// main.go already has the middleware of a known router.
|
|
79
|
+
const echoV5 = requireOf(mod, 'github.com/labstack/echo/v5') !== undefined
|
|
80
|
+
const router = (n) => (/^For chi,/.test(n) ? routers.includes('chi') : /^For Echo v5,/.test(n) ? echoV5
|
|
81
|
+
: /^For another router,/.test(n) || /^Use the middleware for your router\. For net\/http,/.test(n) ? !routers.length : true)
|
|
82
|
+
const mainNotes = notes.filter((n) => !(older && /^Use Go 1\.\d+ or later/.test(n)) && n !== GO_LIBRARIES.gorm.note && n !== GO_LIBRARIES.redis.note && router(n))
|
|
83
|
+
const main = goMain(routers.length ? routers : ['nethttp'], listenAddress(service, ws))
|
|
33
84
|
plan.summary.push(`Show the steps to add OpenTelemetry to your ${label} code. You do these steps yourself.`)
|
|
34
|
-
plan.manual.push(
|
|
35
|
-
{ title: 'Add the OpenTelemetry Go packages',
|
|
36
|
-
{ title: `Add ${at(service.dir, files[0].path)}`, lines: [files[0].summary], snippets: [{ caption: files[0].path, language: 'go', code: GO_INIT }] },
|
|
37
|
-
{ title: 'Start OpenTelemetry in main', lines: [
|
|
85
|
+
plan.manual.push(...[
|
|
86
|
+
!done.packages && { title: 'Add the OpenTelemetry Go packages', verify: true, lines, snippets: [{ caption: 'go get', language: 'bash', code: code.join('\n') }] },
|
|
87
|
+
!done.init && { title: `Add ${at(service.dir, files[0].path)}`, verify: true, lines: [files[0].summary], snippets: [{ caption: files[0].path, language: 'go', code: GO_INIT }] },
|
|
88
|
+
!done.main && { title: 'Start OpenTelemetry in main', verify: true, lines: [routers.length === 1 ? 'Change main like this.' : files[1].summary, ...mainNotes],
|
|
89
|
+
snippets: [{ caption: files[1].path, language: 'go', code: main }] },
|
|
90
|
+
].filter(Boolean))
|
|
38
91
|
return plan
|
|
39
92
|
},
|
|
40
93
|
}
|
|
41
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Which Go steps the repo shows as done. packages: go.mod requires the SDK. init: a .go file of the service has
|
|
97
|
+
* func setupOTel (otel.go) or its own NewTracerProvider. main: a .go file calls setupOTel, or the app has its own
|
|
98
|
+
* NewTracerProvider and no setupOTel, which this tool cannot follow. Comments do not count. Test files do not count.
|
|
99
|
+
*/
|
|
100
|
+
function goDone(service, ws) {
|
|
101
|
+
const packages = /go\.opentelemetry\.io\/otel\/sdk\s/.test(ws.read(at(service.dir, 'go.mod')) ?? '')
|
|
102
|
+
const code = ws.files(service.dir).filter((f) => f.endsWith('.go') && !f.endsWith('_test.go'))
|
|
103
|
+
.map((f) => (ws.read(f) ?? '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''))
|
|
104
|
+
const defines = code.some((t) => /\bfunc\s+setupOTel\s*\(/.test(t))
|
|
105
|
+
const own = code.some((t) => /\bNewTracerProvider\s*\(/.test(t))
|
|
106
|
+
const calls = code.some((t) => /\bsetupOTel\s*\(/.test(t.replace(/\bfunc\s+setupOTel\s*\(/g, '')))
|
|
107
|
+
return { packages, init: defines || own, main: calls || (own && !defines) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Echo v4 with a Go below the Go of echo-otel uses the older contrib middleware otelecho, as goInstall does. */
|
|
111
|
+
const goOlder = (release) => Number(release.go.split('.')[1]) < 25
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The address the app listens on, for main.go: the port of the first EXPOSE in the Dockerfile, else the first
|
|
115
|
+
* ":port" that the Go code passes to a server, else :8080.
|
|
116
|
+
*/
|
|
117
|
+
function listenAddress(service, ws) {
|
|
118
|
+
const docker = service.dockerfile ? ws.read(service.dockerfile) : null
|
|
119
|
+
const exposed = docker ? /^\s*EXPOSE\s+(\d{2,5})\b/im.exec(docker)?.[1] : null
|
|
120
|
+
if (exposed) return `:${exposed}`
|
|
121
|
+
for (const file of ws.files(service.dir).filter((f) => f.endsWith('.go') && !f.endsWith('_test.go'))) {
|
|
122
|
+
const m = /(?:\.Start|\.Run|ListenAndServe|Addr\s*:)\s*\(?\s*"([\w.-]*:\d{2,5})"/.exec(ws.read(file) ?? '')
|
|
123
|
+
if (m) return m[1].slice(m[1].lastIndexOf(':'))
|
|
124
|
+
}
|
|
125
|
+
return ':8080'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The version that go.mod requires for a module: 'v1.2.3', null when the line has no plain version, undefined when absent. */
|
|
129
|
+
function requireOf(mod, module) {
|
|
130
|
+
const m = new RegExp(`^\\s*(?:require\\s+)?${module.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+(\\S+)`, 'm').exec(mod)
|
|
131
|
+
if (!m) return undefined
|
|
132
|
+
return /^v\d+\.\d+\.\d+$/.test(m[1]) ? m[1] : null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A plain vX.Y.Z as 1 number that sorts like the version. */
|
|
136
|
+
const semver = (v) => { const [a, b, c] = v.slice(1).split('.').map(Number); return (a * 1000 + b) * 1000 + c }
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The Go version that builds the app: the golang image of the Dockerfile (the last golang stage), else the go line
|
|
140
|
+
* of go.mod. [major, minor], or null when unknown.
|
|
141
|
+
*/
|
|
142
|
+
function goVersion(service, ws) {
|
|
143
|
+
const docker = service.dockerfile ? ws.read(service.dockerfile) : null
|
|
144
|
+
const images = docker ? parseDockerfile(docker).stages.map((stage) => stage.image) : []
|
|
145
|
+
const tag = images.map((image) => /(?:^|\/)golang:(\d+)\.(\d+)/.exec(image)).filter(Boolean).at(-1)
|
|
146
|
+
const m = tag ?? /^(\d+)\.(\d+)/.exec(service.runtimeVersion ?? '')
|
|
147
|
+
return m ? [Number(m[1]), Number(m[2])] : null
|
|
148
|
+
}
|
|
149
|
+
|
|
42
150
|
/** The docs RUN lines that install the PHP extension in official php images. */
|
|
43
151
|
const PHP_RUN = (() => {
|
|
44
152
|
const lines = docs('php').dockerfile.split('\n')
|
|
@@ -48,6 +156,9 @@ const PHP_RUN = (() => {
|
|
|
48
156
|
return lines.slice(start, end + 1).join('\n')
|
|
49
157
|
})()
|
|
50
158
|
|
|
159
|
+
/** A RUN that installs the OpenTelemetry PHP extension: pecl, install-php-extensions or pie. */
|
|
160
|
+
const PHP_EXTENSION = /\bpecl\s+install\b[^&;|\n]*\bopentelemetry\b|\binstall-php-extensions\b[^&;|\n]*\bopentelemetry\b|\bpie\s+install\b[^&;|\n]*\bopen-telemetry\/ext-opentelemetry\b/
|
|
161
|
+
|
|
51
162
|
/** @type {import('../types.mjs').Applier} */
|
|
52
163
|
export const php = {
|
|
53
164
|
id: 'php',
|
|
@@ -56,7 +167,14 @@ export const php = {
|
|
|
56
167
|
plan.extraSettings.push({ ...PINS.php.autoload })
|
|
57
168
|
let composer = {}
|
|
58
169
|
try { composer = JSON.parse(ws.read(at(service.dir, 'composer.json')) ?? '{}') } catch { /* not JSON: print the steps */ }
|
|
59
|
-
|
|
170
|
+
const packagesDone = Boolean(composer.require?.['open-telemetry/sdk'])
|
|
171
|
+
const dockerText = (service.dockerfile && ws.read(service.dockerfile)) || ''
|
|
172
|
+
const info = parseDockerfile(dockerText)
|
|
173
|
+
// The extension is done when a RUN of the final stage, or of a stage it builds on, installs it. Without a
|
|
174
|
+
// Dockerfile the repo cannot show it, so it goes with the Composer step.
|
|
175
|
+
const extensionDone = service.dockerfile ? finalStages(info).some((stage) => stage.instructions.some((x) => x.keyword === 'RUN' && PHP_EXTENSION.test(x.args)))
|
|
176
|
+
: packagesDone
|
|
177
|
+
if (packagesDone && extensionDone) return plan
|
|
60
178
|
const { install, files, notes, label } = docs('php')
|
|
61
179
|
const [pecl, allowPlugins, require] = install.split('\n')
|
|
62
180
|
const note = (re) => notes.filter((n) => re.test(n))
|
|
@@ -65,8 +183,7 @@ export const php = {
|
|
|
65
183
|
.find((m) => m && service.frameworks.includes(m[1].toLowerCase()))
|
|
66
184
|
const laravel = service.frameworks.includes('laravel')
|
|
67
185
|
const packages = laravel ? require : require.replace(/\S*opentelemetry-auto-laravel/, swap?.[2] ?? '').trimEnd()
|
|
68
|
-
const
|
|
69
|
-
const image = parseDockerfile(dockerText).image ?? ''
|
|
186
|
+
const image = finalImage(info) ?? ''
|
|
70
187
|
// composer install in a build stage without the extension stops on the platform check.
|
|
71
188
|
const platform = /composer\s+install\b/.test(dockerText) && !/ignore-platform-req/.test(dockerText) ? note(/--ignore-platform-req option/) : []
|
|
72
189
|
const extension = service.dockerfile
|
|
@@ -75,10 +192,10 @@ export const php = {
|
|
|
75
192
|
: { lines: ['Install the extension on each server.', files[0].summary],
|
|
76
193
|
snippets: [{ caption: 'Install', language: 'bash', code: pecl }, { caption: files[0].path, language: 'ini', code: files[0].content }] }
|
|
77
194
|
plan.summary.push(`Show the steps to add OpenTelemetry to your ${label} app. You do these steps yourself.`)
|
|
78
|
-
plan.manual.push({ title: 'Add the OpenTelemetry PHP extension', ...extension }, {
|
|
79
|
-
title: 'Add the OpenTelemetry Composer packages',
|
|
195
|
+
plan.manual.push(...[!extensionDone && { title: 'Add the OpenTelemetry PHP extension', verify: true, ...extension }, !packagesDone && {
|
|
196
|
+
title: 'Add the OpenTelemetry Composer packages', verify: true,
|
|
80
197
|
lines: [runIn(service.dir), 'Commit composer.json and composer.lock.', ...(laravel ? [] : note(/PSR-18/)), ...platform, ...(/fpm/i.test(image) ? note(/PHP-FPM/) : [])],
|
|
81
|
-
snippets: [{ caption: 'composer', language: 'bash', code: [allowPlugins, packages].join('\n') }] })
|
|
198
|
+
snippets: [{ caption: 'composer', language: 'bash', code: [allowPlugins, packages].join('\n') }] }].filter(Boolean))
|
|
82
199
|
return plan
|
|
83
200
|
},
|
|
84
201
|
}
|
|
@@ -88,17 +205,23 @@ export const ruby = {
|
|
|
88
205
|
id: 'ruby',
|
|
89
206
|
plan(service, { ws }) {
|
|
90
207
|
const plan = empty()
|
|
91
|
-
|
|
208
|
+
// The gems step is done when the Gemfile has opentelemetry-sdk. The start step is done when config.ru or a .rb file
|
|
209
|
+
// under config/ (the Rails initializer) configures the SDK. Comment lines do not count.
|
|
210
|
+
const gemsDone = /^\s*gem\s*\(?\s*["']opentelemetry-sdk["']/m.test(ws.read(at(service.dir, 'Gemfile')) ?? '')
|
|
211
|
+
const startFiles = [at(service.dir, 'config.ru'), ...ws.files(at(service.dir, 'config')).filter((f) => f.endsWith('.rb'))]
|
|
212
|
+
const startDone = startFiles.some((f) => /^[^#\n]*\bOpenTelemetry::SDK\.configure\b/m.test(ws.read(f) ?? ''))
|
|
213
|
+
if (gemsDone && startDone) return plan
|
|
92
214
|
const { install, files, notes, label } = docs('ruby')
|
|
93
215
|
const rails = service.frameworks.includes('rails') || ws.read(at(service.dir, 'config/application.rb')) !== null
|
|
94
216
|
plan.summary.push(`Show the steps to add OpenTelemetry to your ${label} app. You do these steps yourself.`)
|
|
95
|
-
plan.manual.push(
|
|
96
|
-
{ title: 'Add the OpenTelemetry gems', lines: [runIn(service.dir), notes.find((n) => /Gemfile\.lock/.test(n))],
|
|
217
|
+
plan.manual.push(...[
|
|
218
|
+
!gemsDone && { title: 'Add the OpenTelemetry gems', verify: true, lines: [runIn(service.dir), notes.find((n) => /Gemfile\.lock/.test(n))],
|
|
97
219
|
snippets: [{ caption: 'bundle', language: 'bash', code: install }] },
|
|
98
|
-
rails ? { title: `Add ${at(service.dir, files[0].path)}`, lines: [files[0].summary],
|
|
220
|
+
startDone ? null : rails ? { title: `Add ${at(service.dir, files[0].path)}`, verify: true, lines: [files[0].summary],
|
|
99
221
|
snippets: [{ caption: files[0].path, language: 'ruby', code: RUBY_INITIALIZER }] }
|
|
100
|
-
: { title: `Add OpenTelemetry to ${at(service.dir, 'config.ru')}`, lines: [notes.find((n) => /config\.ru/.test(n))],
|
|
101
|
-
snippets: [{ caption: 'config.ru', language: 'ruby', code: RUBY_INITIALIZER }] }
|
|
222
|
+
: { title: `Add OpenTelemetry to ${at(service.dir, 'config.ru')}`, verify: true, lines: [notes.find((n) => /config\.ru/.test(n))],
|
|
223
|
+
snippets: [{ caption: 'config.ru', language: 'ruby', code: RUBY_INITIALIZER }] },
|
|
224
|
+
].filter(Boolean))
|
|
102
225
|
return plan
|
|
103
226
|
},
|
|
104
227
|
}
|