@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.
Files changed (48) hide show
  1. package/README.md +99 -32
  2. package/lib/automate.mjs +1299 -0
  3. package/lib/check.mjs +149 -33
  4. package/lib/ci.mjs +21 -0
  5. package/lib/cli.mjs +114 -29
  6. package/lib/cloudid.mjs +456 -0
  7. package/lib/code.mjs +147 -0
  8. package/lib/context.mjs +126 -0
  9. package/lib/contract-docs.mjs +1780 -198
  10. package/lib/contract.mjs +93 -10
  11. package/lib/detect.mjs +96 -44
  12. package/lib/dockerfile.mjs +83 -0
  13. package/lib/endpoint.mjs +36 -5
  14. package/lib/engine.mjs +1272 -126
  15. package/lib/existing.mjs +448 -54
  16. package/lib/git.mjs +53 -0
  17. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  18. package/lib/hooks/pins.mjs +28 -0
  19. package/lib/hooks/uknows-otel.cjs +82 -0
  20. package/lib/hooks/uknows_otel.py +179 -0
  21. package/lib/hosts.mjs +521 -16
  22. package/lib/locks.mjs +353 -0
  23. package/lib/manifest.mjs +27 -7
  24. package/lib/members.mjs +269 -0
  25. package/lib/plan.mjs +130 -0
  26. package/lib/settings.mjs +216 -28
  27. package/lib/signin.mjs +259 -0
  28. package/lib/stacks/azure.mjs +345 -0
  29. package/lib/stacks/channel.mjs +125 -0
  30. package/lib/stacks/dotnet.mjs +336 -29
  31. package/lib/stacks/functions-hooks.mjs +228 -0
  32. package/lib/stacks/functions.mjs +148 -0
  33. package/lib/stacks/guided.mjs +153 -30
  34. package/lib/stacks/index.mjs +10 -2
  35. package/lib/stacks/java.mjs +14 -13
  36. package/lib/stacks/node.mjs +142 -19
  37. package/lib/stacks/otel.mjs +56 -4
  38. package/lib/stacks/python-bootstrap.mjs +27 -7
  39. package/lib/stacks/python-lock.mjs +105 -0
  40. package/lib/stacks/python-sets.mjs +143 -0
  41. package/lib/stacks/python.mjs +261 -63
  42. package/lib/toml.mjs +64 -0
  43. package/lib/types.mjs +52 -5
  44. package/lib/ui.mjs +139 -20
  45. package/lib/updates.mjs +398 -0
  46. package/lib/versions.mjs +115 -0
  47. package/lib/workspace.mjs +4 -1
  48. 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
+ }
@@ -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, and none when the SDK is already a dependency.
4
- import { GO_INIT, GO_MAIN, PINS, RUBY_INITIALIZER, STACKS } from '../contract.mjs'
5
- import { parseDockerfile } from '../dockerfile.mjs'
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
- if (/go\.opentelemetry\.io\/otel\/sdk\s/.test(ws.read(at(service.dir, 'go.mod')) ?? '')) return plan
18
- const { install, files, notes, label } = docs('go')
19
- // go get raises the go line of go.mod to what the modules need. An older Go builder then fails.
20
- const version = /^(\d+)\.(\d+)/.exec(service.runtimeVersion ?? '')
21
- const [major, minor] = PINS.go.minGo
22
- if (version && Number(version[1]) * 1000 + Number(version[2]) < major * 1000 + minor) {
23
- plan.manual.push({ title: `Use Go ${major}.${minor} or later`, lines: [`This app builds with Go ${version[0]}.`,
24
- `The OpenTelemetry Go packages that go get adds need Go ${major}.${minor} or later.`,
25
- `Update the go line in go.mod and the Go version of the build image to ${major}.${minor} or later. Do this first.`] })
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 = install.split('\n').reduce((all, line) => {
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 code = sections.filter((s, k) => k === 0 || service.frameworks.some((f) => s[0].toLowerCase().includes(f))).flat().join('\n')
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', lines: [runIn(service.dir)], snippets: [{ caption: 'go get', language: 'bash', code }] },
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: [files[1].summary, ...notes], snippets: [{ caption: files[1].path, language: 'go', code: GO_MAIN }] })
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
- if (composer.require?.['open-telemetry/sdk']) return plan
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 dockerText = (service.dockerfile && ws.read(service.dockerfile)) || ''
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
- if (/^\s*gem\s*\(?\s*["']opentelemetry-sdk["']/m.test(ws.read(at(service.dir, 'Gemfile')) ?? '')) return plan
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
  }