@uknows/setup 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +209 -2
  3. package/bin/uknows-setup.mjs +14 -0
  4. package/lib/automate.mjs +1299 -0
  5. package/lib/check.mjs +327 -0
  6. package/lib/ci.mjs +21 -0
  7. package/lib/cli.mjs +251 -0
  8. package/lib/cloudid.mjs +456 -0
  9. package/lib/code.mjs +147 -0
  10. package/lib/context.mjs +126 -0
  11. package/lib/contract-docs.mjs +2736 -0
  12. package/lib/contract.mjs +149 -0
  13. package/lib/detect.mjs +627 -0
  14. package/lib/diff.mjs +147 -0
  15. package/lib/doc.mjs +98 -0
  16. package/lib/dockerfile.mjs +455 -0
  17. package/lib/endpoint.mjs +81 -0
  18. package/lib/engine.mjs +1753 -0
  19. package/lib/existing.mjs +562 -0
  20. package/lib/git.mjs +100 -0
  21. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  22. package/lib/hooks/pins.mjs +28 -0
  23. package/lib/hooks/uknows-otel.cjs +82 -0
  24. package/lib/hooks/uknows_otel.py +179 -0
  25. package/lib/hosts.mjs +650 -0
  26. package/lib/locks.mjs +353 -0
  27. package/lib/manifest.mjs +132 -0
  28. package/lib/members.mjs +269 -0
  29. package/lib/plan.mjs +130 -0
  30. package/lib/settings.mjs +468 -0
  31. package/lib/signin.mjs +259 -0
  32. package/lib/stacks/azure.mjs +345 -0
  33. package/lib/stacks/channel.mjs +125 -0
  34. package/lib/stacks/dotnet.mjs +472 -0
  35. package/lib/stacks/functions-hooks.mjs +228 -0
  36. package/lib/stacks/functions.mjs +148 -0
  37. package/lib/stacks/guided.mjs +227 -0
  38. package/lib/stacks/index.mjs +18 -0
  39. package/lib/stacks/java.mjs +57 -0
  40. package/lib/stacks/k8s.mjs +31 -0
  41. package/lib/stacks/node.mjs +358 -0
  42. package/lib/stacks/otel.mjs +63 -0
  43. package/lib/stacks/python-bootstrap.mjs +71 -0
  44. package/lib/stacks/python-lock.mjs +105 -0
  45. package/lib/stacks/python-sets.mjs +143 -0
  46. package/lib/stacks/python.mjs +632 -0
  47. package/lib/toml.mjs +64 -0
  48. package/lib/types.mjs +208 -0
  49. package/lib/ui.mjs +225 -0
  50. package/lib/updates.mjs +398 -0
  51. package/lib/versions.mjs +115 -0
  52. package/lib/workspace.mjs +201 -0
  53. package/package.json +37 -4
@@ -0,0 +1,562 @@
1
+ /*
2
+ * What 1 service already has for tracing, and the mode that follows from it. It reads the manifests of the
3
+ * service and of its first-party members (uv workspace members, path sources, file: packages, project
4
+ * references), the lock closure, the Dockerfile, deploy files, and the source of the service and its members.
5
+ * Sentry counts only when its tracing is on. Static reading only.
6
+ *
7
+ * classify() turns that into 1 mode:
8
+ * stop another APM agent, a classic App Insights SDK, or a package that brings its own OpenTelemetry
9
+ * azure-monitor Azure Monitor OpenTelemetry: keep it, add 1 exporter beside it
10
+ * otel OpenTelemetry SDK packages listed directly: settings only
11
+ * standard nothing yet
12
+ */
13
+ import { posix } from 'node:path'
14
+ import { AZURE_MONITOR, OTHER_EXPORTERS_NOTE, PHOENIX_KEY_STEP, PHOENIX_STEP, PINS, SETUP_TROUBLESHOOTING, TROUBLESHOOTING } from './contract.mjs'
15
+ import { installedPackages, noPackages, packageKey } from './locks.mjs'
16
+ import { findMembers } from './members.mjs'
17
+ import { pyName } from './toml.mjs'
18
+
19
+ const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
20
+ const inside = (dir, path) => dir === '.' ? !path.startsWith('../') : path === dir || path.startsWith(`${dir}/`)
21
+ const AGAIN = 'Then run npx @uknows/setup again.'
22
+ const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
23
+
24
+ /** Manifest name -> ecosystem. */
25
+ const ECOSYSTEM = [
26
+ [/^package\.json$/, 'node'], [/^(requirements[\w.-]*\.txt|pyproject\.toml|Pipfile)$/, 'python'], [/\.csproj$/, 'dotnet'],
27
+ [/^(pom\.xml|build\.gradle(\.kts)?)$/, 'java'], [/^go\.mod$/, 'go'], [/^composer\.json$/, 'php'], [/^Gemfile$/, 'ruby'],
28
+ ]
29
+
30
+ /**
31
+ * Per ecosystem: OpenTelemetry at SDK level (never the API package alone), other APM agents, Sentry, and the
32
+ * Azure Monitor families: azure (a distro to keep), appInsights (3.x keeps, older stops), classic (stops),
33
+ * browser (a note only) and tracers (allowed, with advice about the key).
34
+ */
35
+ const RULES = {
36
+ node: { otel: /^(@opentelemetry\/(sdk-node|sdk-trace-.+|auto-instrumentations-node|exporter-trace-otlp-.+)|@vercel\/otel)$/,
37
+ apm: { Datadog: /^dd-trace$/, 'New Relic': /^newrelic$/, 'Elastic APM': /^elastic-apm-node$/, Dynatrace: /^@dynatrace\//, Instana: /^@instana\/collector$/ },
38
+ sentry: /^@sentry\//, azure: /^@azure\/monitor-opentelemetry$/, appInsights: /^applicationinsights$/, browser: /^@microsoft\/applicationinsights-/,
39
+ tracers: /^(@arizeai\/.+|langfuse(-.+)?|@langfuse\/.+|@traceloop\/.+|logfire|@pydantic\/logfire.*)$/ },
40
+ python: { otel: /^opentelemetry-(sdk|distro|instrumentation.*)$/,
41
+ apm: { Datadog: /^ddtrace$/, 'New Relic': /^newrelic$/, 'Elastic APM': /^elastic-apm$/ },
42
+ sentry: /^sentry-sdk$/, azure: /^azure-monitor-opentelemetry$/, classic: /^opencensus-ext-azure$/,
43
+ tracers: /^(arize-phoenix(-otel)?|openinference-.+|langfuse|traceloop-sdk|logfire)$/ },
44
+ dotnet: { otel: /^OpenTelemetry(\.Extensions\.Hosting|\.AutoInstrumentation|\.Exporter\..+)?$/i,
45
+ apm: { Datadog: /^Datadog\.Trace/i, 'New Relic': /^NewRelic\./i, 'Elastic APM': /^Elastic\.Apm/i },
46
+ sentry: /^Sentry(\.|$)/, azure: /^Azure\.Monitor\.OpenTelemetry\.AspNetCore$/i, appInsights: /^Microsoft\.ApplicationInsights\.(AspNetCore|WorkerService)$/i,
47
+ classic: /^Microsoft\.ApplicationInsights(\.(Web|WindowsServer|DependencyCollector|PerfCounterCollector|EventCounterCollector))?$/i },
48
+ java: { otel: /^io\.opentelemetry[\w.]*:opentelemetry-[\w-]*(sdk|starter|exporter|javaagent)/,
49
+ apm: { Datadog: /^com\.datadoghq:dd-/, 'New Relic': /^com\.newrelic\./, 'Elastic APM': /^co\.elastic\.apm:/ },
50
+ sentry: /^io\.sentry:/, classic: /^(com\.microsoft\.azure:applicationinsights-|com\.azure:azure-monitor-opentelemetry)/ },
51
+ go: { otel: /^go\.opentelemetry\.io\/otel\/sdk(\/|$)/,
52
+ apm: { Datadog: /dd-trace-go/, 'New Relic': /^github\.com\/newrelic\/go-agent/, 'Elastic APM': /^go\.elastic\.co\/apm/ },
53
+ sentry: /^github\.com\/getsentry\/sentry-go/ },
54
+ php: { otel: /^open-telemetry\/sdk$/,
55
+ apm: { Datadog: /^datadog\//, 'New Relic': /newrelic/, 'Elastic APM': /^elastic\/apm-agent/ }, sentry: /^sentry\// },
56
+ ruby: { otel: /^opentelemetry-sdk$/,
57
+ apm: { Datadog: /^(ddtrace|datadog)$/, 'New Relic': /^newrelic_rpm$/, 'Elastic APM': /^elastic-apm$/,
58
+ 'Scout APM': /^scout_apm$/, Skylight: /^skylight$/ },
59
+ sentry: /^sentry-(ruby|rails|raven)$/ },
60
+ }
61
+
62
+ /**
63
+ * Packages a wrapper may bring OpenTelemetry through without a stop: the Azure Monitor distros, OpenTelemetry
64
+ * itself and the allowed tracers (Phoenix, openinference, langfuse, traceloop, logfire).
65
+ */
66
+ const KNOWN = {
67
+ python: /^(opentelemetry-.+|azure-monitor-opentelemetry(-exporter)?|azure-core-tracing-opentelemetry|arize-phoenix(-otel)?|openinference-.+|langfuse|traceloop-sdk|logfire)$/,
68
+ dotnet: /^(opentelemetry(\..+)?|azure\.monitor\.opentelemetry(\..+)?|microsoft\.applicationinsights(\..+)?)$/,
69
+ node: /^(@opentelemetry\/.+|@azure\/monitor-opentelemetry(-exporter)?|applicationinsights|@vercel\/otel|@arizeai\/.+|langfuse(-.+)?|@langfuse\/.+|@traceloop\/.+|logfire)$/,
70
+ }
71
+ /** The Phoenix packages, whose register() falls back to OTEL_EXPORTER_OTLP_ENDPOINT. */
72
+ const PHOENIX = /^(arize-phoenix(-otel)?|@arizeai\/phoenix-otel)$/
73
+ /** Names that say a package is about telemetry. With OpenTelemetry under it, it is a wrapper. */
74
+ const WRAPPER_NAME = /otel|opentelemetry|telemetry|monitoring|tracing|observability/i
75
+ /** Requirements on OpenTelemetry packages, per language. */
76
+ const OTEL_DEP = { python: /^opentelemetry-/, dotnet: /^opentelemetry(\.|$)/i, node: /^@opentelemetry\// }
77
+ const LOCAL_SOURCES = new Set(['path', 'directory', 'file', 'git', 'url', 'link'])
78
+
79
+ /** Agents that a Dockerfile loads (comment lines are ignored). The App Insights Java agent counts as classic App Insights. */
80
+ const DOCKER_APM = { Datadog: /\bdd-trace\b|\bddtrace(-run)?\b|dd-java-agent|datadog-dotnet-apm|datadog-setup\.php/,
81
+ 'New Relic': /\bnewrelic(-admin|\.jar|-php5?)?\b/i, 'Elastic APM': /elastic-apm-agent|elastic-apm-node|\belasticapm\b/i,
82
+ 'Azure Monitor': /applicationinsights-agent/i, Dynatrace: /\bdynatrace\b|\boneagent\b/i, Instana: /\binstana\b/i }
83
+ const DOCKER_OTEL = /-javaagent:\S*opentelemetry/
84
+ const TRACING = /tracesSampleRate|traces_sample_rate|TracesSampleRate|enableTracing|traces-sample-rate|tracesSampler|traces_sampler/
85
+ const SOURCE = /\.(m?[jt]sx?|c[jt]s|py|cs|rb|go|php|java|kt|properties|ya?ml|json|exs?)$/
86
+ const LOCKS = /(^|\/)(package-lock|npm-shrinkwrap|composer\.lock|packages\.lock|project\.assets)\.json$|(^|\/)(uv|poetry|pdm)\.lock$/
87
+ const CODE = /\.(py|cs|m?[jt]sx?|c[jt]s)$/
88
+ const JS = /\.(m?[jt]sx?|c[jt]s)$/
89
+ const MAX_SCAN = 5000
90
+ const MAX_BYTES = 1_000_000
91
+
92
+ /**
93
+ * Lines in source code that matter: where Azure Monitor starts, a tracer provider, OTLP metrics or logs, sampling
94
+ * arguments and service names. [kind, regex, files]. Each match is 1 anchor.
95
+ */
96
+ const ANCHORS = [
97
+ ['azure-call', /\bconfigure_azure_monitor\s*\(/g, /\.py$/],
98
+ ['azure-call', /\bUseAzureMonitor\s*\(/g, /\.cs$/],
99
+ ['appinsights-call', /\bAddApplicationInsightsTelemetry(?:WorkerService)?\s*\(/g, /\.cs$/],
100
+ ['azure-call', /\buseAzureMonitor\s*\(/g, JS],
101
+ ['appinsights-call', /require\(\s*["']applicationinsights["']\s*\)|from\s+["']applicationinsights["']|import\s+["']applicationinsights["']/g, JS],
102
+ ['phoenix', /\bphoenix\.otel\b/g, /\.py$/],
103
+ // Phoenix gets its own address: the setting, or an address passed to register() (endpoint= in Python, url: in JS).
104
+ ['phoenix-endpoint', /\bPHOENIX_COLLECTOR_ENDPOINT\b|\bregister\s*\([^)]*\bendpoint\s*=/g, /\.py$/],
105
+ ['phoenix-endpoint', /\bPHOENIX_COLLECTOR_ENDPOINT\b|\bregister\s*\(\s*\{[^}]*\burl\s*:/g, JS],
106
+ // Phoenix gets its own key: the setting, or headers passed to register(). Else its exporter reads the OTLP headers, the UKNOWS key.
107
+ ['phoenix-key', /\bPHOENIX_API_KEY\b|\bPHOENIX_CLIENT_HEADERS\b|\bregister\s*\([^)]*\bheaders\s*=/g, /\.py$/],
108
+ ['phoenix-key', /\bPHOENIX_API_KEY\b|\bPHOENIX_CLIENT_HEADERS\b|\bregister\s*\(\s*\{[^}]*\bheaders\s*:/g, JS],
109
+ // Python web servers that Azure Monitor does not trace, when the package alone does not show that the app serves HTTP.
110
+ ['aiohttp-server', /\bfrom\s+aiohttp\s+import\s+[^\n]*\bweb\b|\baiohttp\.web\b/g, /\.py$/],
111
+ ['mcp-server', /\bFastMCP\s*\(|\bfrom\s+mcp\.server\b/g, /\.py$/],
112
+ ['provider', /\bset_tracer_provider\s*\(/g, /\.py$/],
113
+ ['otlp-signal', /\bOTLP(?:Metric|Log)Exporter\b|WriteTo\.OpenTelemetry\s*\(|\.With(?:Metrics|Logging)\s*\([^;]{0,400}?AddOtlpExporter|Logging\.AddOpenTelemetry\s*\([^;]{0,400}?AddOtlpExporter/g, CODE],
114
+ ['sampling', /\b(?:sampling_ratio|traces_per_second)\s*=\s*[^,)\n]+|\b(?:SamplingRatio|TracesPerSecond)\s*=\s*[^,;}\n]+|\b(?:samplingRatio|tracesPerSecond)\s*:\s*[^,}\n]+/g, CODE],
115
+ // The app sets the service name in code: it sets OTEL_SERVICE_NAME, or names service.name on a resource. A
116
+ // resource counts only in a file that also starts the global provider or Azure Monitor (resourceName below), so the
117
+ // resource of a second, private provider (Phoenix) does not.
118
+ ['service-name', /\benviron\s*\[\s*["']OTEL_SERVICE_NAME["']\s*\]\s*=(?!=)|\b(?:environ\.setdefault|putenv)\s*\(\s*["']OTEL_SERVICE_NAME["']|\bprocess\.env(?:\.OTEL_SERVICE_NAME|\[\s*["']OTEL_SERVICE_NAME["']\s*\])\s*=(?!=)|\bSetEnvironmentVariable\s*\(\s*"OTEL_SERVICE_NAME"/g, CODE],
119
+ ['service-resource', /["']service\.name["']|\bSERVICE_NAME\s*:|\.AddService\s*\(/g, CODE],
120
+ ['connection-string', /\bAPPLICATIONINSIGHTS_CONNECTION_STRING\b/g, CODE],
121
+ // .NET: AddOtlpExporter() reads only the general OTLP settings. UseOtlpExporter() reads the traces-only ones too.
122
+ ['add-otlp-exporter', /\.AddOtlpExporter\s*\(/g, /\.cs$/],
123
+ ['use-otlp-exporter', /\.UseOtlpExporter\s*\(/g, /\.cs$/],
124
+ ['traces-endpoint', /\bOTEL_EXPORTER_OTLP_TRACES_ENDPOINT\b/g, /\.cs$/],
125
+ ['traces-headers', /\bOTEL_EXPORTER_OTLP_TRACES_HEADERS\b/g, /\.cs$/],
126
+ ]
127
+ /** A file that starts the global tracer provider or Azure Monitor: a service.name on a resource in it names the app. */
128
+ const GLOBAL_PROVIDER = [
129
+ [/\.py$/, /\bset_tracer_provider\s*\(|\bconfigure_azure_monitor\s*\(/],
130
+ [/\.cs$/, /\bUseAzureMonitor\s*\(|\bAddOpenTelemetry\s*\(|\bSdk\.CreateTracerProviderBuilder\s*\(/],
131
+ [JS, /\buseAzureMonitor\s*\(|\bnew\s+NodeSDK\s*\(|\.register\s*\(\s*\)|\btrace\.setGlobalTracerProvider\s*\(/],
132
+ ]
133
+ /** Deploy files that can set environment variables of the app. */
134
+ const DEPLOY = /(?:^|\/)(?:Dockerfile(?:\.[^/]+)?|[^/]+\.[Dd]ockerfile|[^/]+\.(?:bicep|tf|ya?ml|env|sh)|\.env[^/]*|[^/]*task[-_.]?def[^/]*\.json)$/
135
+ const ENV_ANCHORS = [
136
+ ['service-name', /\bOTEL_SERVICE_NAME\b/g],
137
+ ['connection-string', /\bAPPLICATIONINSIGHTS_CONNECTION_STRING\b/g],
138
+ ['auto-attach', /\bAZURE_MONITOR_AUTO_ATTACH\b["']?\s*[=:,]?\s*["']?\s*(?:value\s*[:=]\s*["']?)?(true|1)\b/gi],
139
+ ['sampler-env', /\bOTEL_TRACES_SAMPLER\b["']?\s*[=:]\s*["']?([\w.]+)/g],
140
+ ['phoenix-endpoint', /\bPHOENIX_COLLECTOR_ENDPOINT\b/g],
141
+ ['phoenix-key', /\bPHOENIX_API_KEY\b|\bPHOENIX_CLIENT_HEADERS\b/g],
142
+ ]
143
+
144
+ /**
145
+ * @param {import('./types.mjs').Service} service
146
+ * @param {import('./workspace.mjs').RepoView|import('./workspace.mjs').Workspace} view The repo view (ctx.repo). A Workspace works too.
147
+ * @returns {import('./types.mjs').Existing}
148
+ */
149
+ export function findExisting(service, view) {
150
+ const lang = service.lang
151
+ const found = { otel: [], apm: new Map(), sentry: [], azure: [], classic: [], browser: [], tracers: [] }
152
+ const add = (vendor, evidence) => {
153
+ const list = found.apm.get(vendor) ?? found.apm.set(vendor, []).get(vendor)
154
+ if (!list.some((e) => e.file === evidence.file && e.what === evidence.what)) list.push(evidence)
155
+ }
156
+ const { members, local } = lang ? findMembers(service, view) : { members: [], local: [] }
157
+ const installed = lang ? installedPackages(service, view, members) : noPackages()
158
+ const version = (eco, name, spec) => installed.packages.get(packageKey(eco, name))?.version ?? specVersion(spec)
159
+ const manifests = new Set(service.manifests)
160
+ for (const name of listDir(view, service.dir)) if (ECOSYSTEM.some(([re]) => re.test(name))) manifests.add(at(service.dir, name))
161
+ for (const m of members) for (const file of m.manifests) manifests.add(file)
162
+ for (const file of [...manifests].sort()) {
163
+ const text = read(view, file)
164
+ const eco = ECOSYSTEM.find(([re]) => re.test(file.split('/').pop()))?.[1]
165
+ if (text === null || !eco) continue
166
+ const rules = RULES[eco]
167
+ for (const { name, line, spec } of dependencies(file, text)) {
168
+ const evidence = { file, line, what: name }
169
+ if (rules.otel.test(name)) found.otel.push(evidence)
170
+ for (const [vendor, re] of Object.entries(rules.apm)) if (re.test(name)) add(vendor, evidence)
171
+ if (rules.sentry.test(name)) found.sentry.push(evidence)
172
+ if (rules.azure?.test(name)) found.azure.push({ ...evidence, version: version(eco, name, spec) })
173
+ if (rules.browser?.test(name)) found.browser.push(evidence)
174
+ if (rules.tracers?.test(name)) found.tracers.push(evidence)
175
+ if (rules.appInsights?.test(name) || rules.classic?.test(name)) {
176
+ const v = version(eco, name, spec)
177
+ const major = Number(/^\D*(\d+)/.exec(v ?? '')?.[1] ?? NaN)
178
+ // 3.x of the .NET and Node.js App Insights packages runs on OpenTelemetry: keep it. Older ones, or an unknown
179
+ // version, stop. The .NET helper packages at 3.x come with Microsoft.ApplicationInsights.AspNetCore 3.x.
180
+ if (rules.appInsights?.test(name) && major >= 3) found.azure.push({ ...evidence, version: v })
181
+ else if (!(eco === 'dotnet' && rules.classic?.test(name) && major >= 3)) found.classic.push({ ...evidence, version: v, what: `${name}${v ? ` ${v}` : ''}` })
182
+ }
183
+ }
184
+ }
185
+ const dockerfile = (service.dockerfile && read(view, service.dockerfile)) || ''
186
+ dockerfile.split('\n').forEach((text, i) => {
187
+ const hit = (re) => (/^\s*#/.test(text) || !re.test(text) ? null : { file: service.dockerfile, line: i + 1, what: re.exec(text)[0] })
188
+ for (const [vendor, re] of Object.entries(DOCKER_APM)) if (hit(re)) (vendor === 'Azure Monitor' ? found.classic.push(hit(re)) : add(vendor, hit(re)))
189
+ if (hit(DOCKER_OTEL)) found.otel.push(hit(DOCKER_OTEL))
190
+ })
191
+ const dirs = [service.dir, ...members.map((m) => m.dir)]
192
+ let all = []
193
+ try { all = view.files() } catch { /* unreadable */ }
194
+ const sources = sourceFiles(view, all, dirs)
195
+ if (found.sentry.length) {
196
+ const hit = tracingIn(view, sources)
197
+ if (hit) for (const evidence of [...found.sentry, hit]) add('Sentry', evidence)
198
+ }
199
+ const own = new Set([service.dockerfile, ...(service.k8s ?? []).map((k) => k.file)].filter(Boolean))
200
+ // A deploy file names the service when it is its Dockerfile or Kubernetes file, or sits in the service folder.
201
+ const ofService = (a) => a.kind !== 'service-name' || service.dir === '.' || own.has(a.file) || inside(service.dir, a.file)
202
+ const anchors = [...scan(view, sources, ANCHORS).flatMap((a) => resourceName(view, a)), ...scan(view, deployFiles(view, all, service), ENV_ANCHORS).filter(ofService)]
203
+ const goLocal = lang === 'go' ? goModules(view, service, sources) : []
204
+ return { otel: found.otel, apm: [...found.apm].map(([vendor, evidence]) => ({ vendor, evidence })), azure: found.azure, classic: found.classic,
205
+ browser: found.browser, tracers: found.tracers, anchors, local, members, installed, goLocal }
206
+ }
207
+
208
+ /** A service.name on a resource counts as the service name only in a file that starts the global provider or Azure Monitor. */
209
+ function resourceName(view, anchor) {
210
+ if (anchor.kind !== 'service-resource') return [anchor]
211
+ const rule = GLOBAL_PROVIDER.find(([files]) => files.test(anchor.file))
212
+ return rule && rule[1].test(read(view, anchor.file) ?? '') ? [{ ...anchor, kind: 'service-name' }] : []
213
+ }
214
+
215
+ /**
216
+ * Go: the modules that go.mod replaces with a local folder, and whether that folder's go.mod requires OpenTelemetry.
217
+ * Such a module is a kit that brings its own OpenTelemetry (the wrapper stop), with the source file that imports it.
218
+ */
219
+ function goModules(view, service, sources) {
220
+ const file = at(service.dir, 'go.mod'), text = read(view, file)
221
+ if (!text) return []
222
+ const out = []
223
+ const lines = text.split('\n')
224
+ let block = false
225
+ lines.forEach((raw, i) => {
226
+ const l = raw.replace(/\/\/.*$/, '').trim()
227
+ if (/^replace\s*\($/.test(l)) { block = true; return }
228
+ if (block && l === ')') { block = false; return }
229
+ const m = /^(?:replace\s+)?(\S+)(?:\s+v\S+)?\s*=>\s*(\.{1,2}\/\S*)\s*$/.exec(block ? l : /^replace\s/.test(l) ? l : '')
230
+ if (!m) return
231
+ const dir = posix.normalize(at(service.dir, m[2])).replace(/\/+$/, '')
232
+ const mod = read(view, at(dir, 'go.mod')) ?? ''
233
+ const otel = /^\s*(?:require\s+)?(go\.opentelemetry\.io\/otel\S*)\s+(v\S+)/m.exec(mod)
234
+ const importer = sources.filter((f) => f.endsWith('.go') && !inside(dir, f)).map((f) => {
235
+ const code = read(view, f) ?? ''
236
+ const pos = code.indexOf(`"${m[1]}`)
237
+ return pos < 0 ? null : { file: f, line: code.slice(0, pos).split('\n').length }
238
+ }).find(Boolean) ?? null
239
+ out.push({ module: m[1], path: dir, file, line: i + 1, otel: otel ? { name: otel[1], version: otel[2] } : null, importer })
240
+ })
241
+ return out
242
+ }
243
+
244
+ /**
245
+ * The mode for 1 service, and why. Stops come first: another APM agent, a classic App Insights SDK (or codeless
246
+ * attach on App Service), a package that brings its own OpenTelemetry, or a local telemetry package with no lock.
247
+ * @param {Partial<import('./types.mjs').Existing>} existing
248
+ * @param {{lang?: string|null, hosts?: import('./types.mjs').Host[]}} [opts]
249
+ * @returns {import('./types.mjs').Classification}
250
+ */
251
+ export function classify(existing, { lang = null, hosts = [] } = {}) {
252
+ if (existing.apm?.length) return { mode: 'stop', reason: 'apm' }
253
+ const appService = hosts.some((h) => h.id === 'azure-app-service')
254
+ const attach = (existing.anchors ?? []).filter((a) => a.kind === 'auto-attach')
255
+ const classic = [...(existing.classic ?? []), ...(appService ? attach.map((a) => ({ ...a, what: 'AZURE_MONITOR_AUTO_ATTACH=true' })) : [])]
256
+ if (classic.length) return { mode: 'stop', reason: 'classic', classic }
257
+ // Also when OpenTelemetry is listed directly: a kit beside it can limit its version, or be the only code that starts it.
258
+ const wrapper = findWrapper(existing, lang)
259
+ if (wrapper) return { mode: 'stop', reason: 'wrapper', wrapper }
260
+ const local = suspectLocal(existing, lang)
261
+ if (local) return { mode: 'stop', reason: 'local', local }
262
+ if (existing.azure?.length) return { mode: 'azure-monitor', reason: null }
263
+ if (existing.otel?.length) return { mode: 'otel', reason: null }
264
+ return { mode: 'standard', reason: null }
265
+ }
266
+
267
+ /**
268
+ * The package that brings the OpenTelemetry SDK and is not standard OpenTelemetry: a local package, a telemetry name,
269
+ * or a version limit below what this tool needs. When the app lists the SDK itself, a package beside it still counts
270
+ * when it is local and needs OpenTelemetry, or has a telemetry name and needs the SDK itself (a library that only
271
+ * needs the API, such as Npgsql.OpenTelemetry, traces itself and is fine). Go: a module that go.mod replaces with a
272
+ * local folder whose go.mod requires OpenTelemetry.
273
+ */
274
+ function findWrapper(existing, lang) {
275
+ if (lang === 'go') {
276
+ const kit = (existing.goLocal ?? []).find((m) => m.otel)
277
+ return kit ? { package: { name: kit.module, version: null, file: kit.file, line: kit.line }, sdk: kit.otel, limit: null, need: null,
278
+ local: { path: kit.path, file: kit.file, line: kit.line, replace: `replace ${kit.module} => ./${posix.relative(posix.dirname(kit.file), kit.path) || '.'}` },
279
+ importer: kit.importer } : null
280
+ }
281
+ const sdk = PINS.sdk[lang]
282
+ const packages = existing.installed?.packages
283
+ if (!sdk || !packages) return null
284
+ const sdkKey = packageKey(lang, sdk.package)
285
+ const item = packages.get(sdkKey)
286
+ if (!item) return null
287
+ const installed = (p) => p.direct || p.via.length > 0
288
+ // Listed directly: in the lock, or in a manifest of the service (a lock can lag behind its manifest).
289
+ const direct = item.direct || (existing.otel ?? []).length > 0
290
+ const candidates = direct
291
+ ? [...packages].filter(([key, p]) => key !== sdkKey && installed(p) && (p.requires ?? []).some((r) => OTEL_DEP[lang].test(r.name))).map(([key]) => key)
292
+ : item.via
293
+ for (const via of candidates) {
294
+ const wrapper = packages.get(via)
295
+ const name = wrapper?.name ?? via
296
+ if (KNOWN[lang].test(lang === 'dotnet' ? name.toLowerCase() : lang === 'python' ? pyName(name) : name)) continue
297
+ const local = (existing.local ?? []).find((l) => packageKey(lang, l.name) === via) ?? null
298
+ const isLocal = LOCAL_SOURCES.has(wrapper?.source ?? '') || Boolean(local)
299
+ const limit = (wrapper?.requires ?? []).find((r) => OTEL_DEP[lang].test(r.name) && upperBound(lang, r.spec) !== null) ?? null
300
+ const below = Boolean(limit && sdk.need && compareVersions(upperBound(lang, limit.spec), sdk.need) <= 0)
301
+ const needsSdk = (wrapper?.requires ?? []).some((r) => packageKey(lang, r.name) === sdkKey)
302
+ const hit = direct ? isLocal || (WRAPPER_NAME.test(name) && needsSdk) : isLocal || WRAPPER_NAME.test(name) || below
303
+ if (hit) return { package: wrapper ?? { name, version: null, file: item.file }, sdk: item, limit, need: below && !direct ? sdk.need : null, local, direct }
304
+ }
305
+ return null
306
+ }
307
+
308
+ /** A local package file with a telemetry name that no lockfile explains. */
309
+ function suspectLocal(existing, lang) {
310
+ const packages = existing.installed?.packages ?? new Map()
311
+ return (existing.local ?? []).find((l) => l.kind !== 'source' && l.kind !== 'folder' && /otel|telemetry|monitoring/i.test(l.name)
312
+ && !packages.has(packageKey(lang ?? '', l.name))) ?? null
313
+ }
314
+
315
+ /**
316
+ * The block that says why the run stops for 1 service. reason apm uses explainApm.
317
+ * @param {import('./types.mjs').Service} service
318
+ * @param {Partial<import('./types.mjs').Existing>} existing
319
+ * @param {import('./types.mjs').Classification} cls
320
+ * @returns {import('./types.mjs').Block}
321
+ */
322
+ export function explainStop(service, existing, cls) {
323
+ const who = service.dir === '.' ? 'This app' : service.dir
324
+ const where = (e) => `${e.file}${e.line ? ` line ${e.line}` : ''}`
325
+ if (cls.reason === 'apm') return explainApm(service, existing.apm ?? [])
326
+ if (cls.reason === 'classic') {
327
+ const entry = SETUP_TROUBLESHOOTING.find((t) => t.id === 'app-insights-classic')
328
+ return { title: `${who} uses Application Insights in a way UKNOWS cannot join`,
329
+ lines: [...cls.classic.map((e) => `Found ${e.what} in ${where(e)}.`), entry.cause, ...entry.steps, AGAIN] }
330
+ }
331
+ const entry = SETUP_TROUBLESHOOTING.find((t) => t.id === 'otel-wrapper')
332
+ if (cls.reason === 'local') {
333
+ const l = cls.local
334
+ return { title: `Check ${l.name} before you add UKNOWS`, lines: [`${where(l)} uses the local package file ${l.path}.`,
335
+ 'Its name says that it can bring OpenTelemetry. There is no lockfile that shows what it brings.', entry.cause, ...entry.steps, AGAIN] }
336
+ }
337
+ const { package: pkg, sdk, limit, need, local, importer, direct } = cls.wrapper
338
+ const sdkName = PINS.sdk[service.lang]?.package ?? sdk.name
339
+ const lines = [`${pkg.name}${pkg.version ? ` ${pkg.version}` : ''} brings ${sdkName} ${sdk.version ?? ''}`.trimEnd() + ` (${pkg.file}${pkg.line ? ` line ${pkg.line}` : ''}).`]
340
+ if (local?.replace) lines.push(`It is a local module: ${local.replace} (${where(local)}).`)
341
+ else if (local) lines.push(`It is a local package: ${local.path}, named in ${where(local)}.`)
342
+ if (importer) lines.push(`${where(importer)} imports it.`)
343
+ if (limit) lines.push(`It needs ${limit.name} ${limit.spec} (${pkg.file}${limit.line ? ` line ${limit.line}` : ''}).`)
344
+ if (need) lines.push(`This tool's packages need ${sdkName} ${need} or later.`)
345
+ if (direct) lines.push(`The app also lists ${sdkName} itself. This tool still stops, because ${pkg.name} can limit the OpenTelemetry version and can be the code that starts OpenTelemetry.`)
346
+ return { title: `${who} gets OpenTelemetry from ${pkg.name}`, lines: [...lines, entry.cause, ...entry.steps, AGAIN] }
347
+ }
348
+
349
+ /**
350
+ * Why the run stops for a service with another APM agent, and the options.
351
+ * @param {import('./types.mjs').Service} service
352
+ * @param {import('./types.mjs').Apm[]} apm
353
+ * @returns {import('./types.mjs').Block}
354
+ */
355
+ export function explainApm(service, apm) {
356
+ const vendors = [...new Set(apm.map((a) => a.vendor))]
357
+ const names = vendors.length > 1 ? `${vendors.slice(0, -1).join(', ')} and ${vendors.at(-1)}` : vendors[0]
358
+ const steps = TROUBLESHOOTING.find((t) => t.id === 'other-apm')?.steps ?? []
359
+ return {
360
+ title: `${service.dir === '.' ? 'This app' : service.dir} already uses ${names}`,
361
+ lines: [...apm.flatMap((a) => a.evidence.map((e) => `Found ${e.what} in ${e.file}${e.line ? ` line ${e.line}` : ''}.`)),
362
+ ...steps, AGAIN],
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Notes and advice that never stop the run: the browser App Insights SDK, a connection string or codeless attach
368
+ * with no SDK, and tracers such as Phoenix that also read the key. Phoenix with no address of its own is a required
369
+ * step: it would send its traces to the UKNOWS address.
370
+ * @param {Partial<import('./types.mjs').Existing>} existing
371
+ * @param {{hosts?: import('./types.mjs').Host[], mode?: string}} [opts]
372
+ * @returns {{notes: string[], advice: import('./types.mjs').Block[]}}
373
+ */
374
+ export function existingNotes(existing, { hosts = [], mode = 'standard' } = {}) {
375
+ const notes = [], advice = []
376
+ const anchors = existing.anchors ?? []
377
+ for (const e of existing.browser ?? []) notes.push(fill(AZURE_MONITOR.plan.browser, { file: e.file, value: e.what }))
378
+ const sdk = existing.azure?.length || existing.classic?.length
379
+ const connection = anchors.find((a) => a.kind === 'connection-string')
380
+ if (connection && !sdk) notes.push(fill(AZURE_MONITOR.plan.connectionString, { file: connection.file }))
381
+ const attach = anchors.find((a) => a.kind === 'auto-attach')
382
+ if (attach && !hosts.some((h) => h.id === 'azure-app-service')) notes.push(fill(AZURE_MONITOR.plan.autoAttach, { file: attach.file }))
383
+ const tracer = existing.tracers?.[0] ?? anchors.find((a) => a.kind === 'phoenix')
384
+ const found = (e) => `Found ${e.what} in ${e.file}${e.line ? ` line ${e.line}` : ''}.`
385
+ // Phoenix with no address of its own sends to OTEL_EXPORTER_OTLP_ENDPOINT, which is UKNOWS after setup: a required step.
386
+ const phoenix = (existing.tracers ?? []).find((e) => PHOENIX.test(e.what)) ?? anchors.find((a) => a.kind === 'phoenix')
387
+ if (phoenix && mode !== 'stop' && !anchors.some((a) => a.kind === 'phoenix-endpoint')) {
388
+ advice.push({ title: PHOENIX_STEP.title, required: true, lines: [found(phoenix), ...PHOENIX_STEP.lines] })
389
+ }
390
+ // Phoenix with no key of its own sends the UKNOWS key to the Phoenix server: a required step too.
391
+ if (phoenix && mode !== 'stop' && !anchors.some((a) => a.kind === 'phoenix-key')) {
392
+ advice.push({ title: PHOENIX_KEY_STEP.title, required: true, lines: [found(phoenix), ...PHOENIX_KEY_STEP.lines] })
393
+ }
394
+ if (tracer && mode !== 'stop') {
395
+ advice.push({ title: 'Keep your UKNOWS key away from other exporters', required: false, lines: [found(tracer), OTHER_EXPORTERS_NOTE] })
396
+ }
397
+ return { notes, advice }
398
+ }
399
+
400
+ /** Package names a manifest lists, with 1-based lines and the version text (spec) when the manifest has one. */
401
+ export function dependencies(file, text) {
402
+ const base = file.split('/').pop()
403
+ const lines = text.split('\n')
404
+ const lineOf = (needle) => lines.findIndex((l) => l.includes(needle)) + 1 || undefined
405
+ const out = []
406
+ const push = (name, line, spec = null) => { if (name && !out.some((d) => d.name === name)) out.push({ name, line, spec }) }
407
+ if (base === 'package.json' || base === 'composer.json') {
408
+ let json
409
+ try { json = JSON.parse(text) } catch { return out }
410
+ const groups = base === 'package.json' ? ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] : ['require', 'require-dev']
411
+ for (const group of groups) {
412
+ for (const [name, spec] of Object.entries(json?.[group] ?? {})) push(name, lineOf(`${JSON.stringify(name)}`), typeof spec === 'string' ? spec : null)
413
+ }
414
+ } else if (/\.csproj$/.test(base)) {
415
+ for (const m of text.matchAll(/<PackageReference\s[^>]*?Include\s*=\s*"([^"]+)"[^>]*?(?:\/>|>([\s\S]*?)<\/PackageReference>)/gi)) {
416
+ const spec = /Version\s*=\s*"([^"]+)"/i.exec(m[0])?.[1] ?? /<Version>\s*([^<]+?)\s*</i.exec(m[2] ?? '')?.[1] ?? null
417
+ push(m[1], lineOf(m[0].split('\n')[0]), spec)
418
+ }
419
+ } else if (base === 'pom.xml') {
420
+ for (const m of text.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
421
+ const group = /<groupId>\s*([^<\s]+)/.exec(m[1])?.[1], artifact = /<artifactId>\s*([^<\s]+)/.exec(m[1])?.[1]
422
+ if (group && artifact) push(`${group}:${artifact}`, lineOf(`<artifactId>${artifact}`), /<version>\s*([^<\s]+)/.exec(m[1])?.[1] ?? null)
423
+ }
424
+ } else if (/^build\.gradle/.test(base)) {
425
+ for (const m of text.matchAll(/['"]([\w.-]+):([\w.-]+)(?::([^'"]*))?['"]/g)) push(`${m[1]}:${m[2]}`, lineOf(m[0]), m[3] ?? null)
426
+ } else if (base === 'go.mod') {
427
+ lines.forEach((l, i) => { const m = /^\s*(?:require\s+)?([\w.-]+\.[\w-]+\/\S+)\s+(v\d\S*)/.exec(l); if (m) push(m[1], i + 1, m[2]) })
428
+ } else if (base === 'Gemfile') {
429
+ lines.forEach((l, i) => { const m = /^\s*gem\s*\(?\s*["']([^"']+)["'](?:\s*,\s*["']([^"']+)["'])?/.exec(l); if (m) push(m[1], i + 1, m[2] ?? null) })
430
+ } else if (/\.txt$/.test(base)) {
431
+ lines.forEach((l, i) => {
432
+ const m = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*([^;#]*)/.exec(l.replace(/#.*/, ''))
433
+ if (m) push(pyName(m[1]), i + 1, m[2].trim() || null)
434
+ })
435
+ } else {
436
+ // pyproject.toml and Pipfile: dependency arrays and dependency tables.
437
+ let table = '', array = false
438
+ lines.forEach((raw, i) => {
439
+ const l = raw.replace(/\s#.*$/, '')
440
+ const closes = () => l.replace(/"[^"]*"|'[^']*'/g, '').includes(']')
441
+ const quoted = () => {
442
+ for (const m of l.matchAll(/["']([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]"']*\])?\s*([^"';]*)[^"']*["']/g)) push(pyName(m[1]), i + 1, m[2].trim() || null)
443
+ }
444
+ if (array) { quoted(); array = !closes(); return }
445
+ const header = /^\s*\[+([^\]]+)\]/.exec(l)
446
+ if (header) { table = header[1]; return }
447
+ const kv = /^\s*["']?([A-Za-z0-9_.-]+)["']?\s*=\s*(.*)$/.exec(l)
448
+ if (!kv) return
449
+ if (kv[2].startsWith('[')) {
450
+ if (/dependencies|requires/.test(kv[1]) || /dependencies|dependency-groups/.test(table)) { quoted(); array = !closes() }
451
+ } else if (/dependencies|packages/.test(table) && kv[1] !== 'python') {
452
+ push(pyName(kv[1]), i + 1, /^["']([^"']*)["']/.exec(kv[2])?.[1] ?? /version\s*=\s*["']([^"']*)["']/.exec(kv[2])?.[1] ?? null)
453
+ }
454
+ })
455
+ }
456
+ return out
457
+ }
458
+
459
+ /** The first version number in a version text: '^3.3.0' -> '3.3.0', '[2.22.0, )' -> '2.22.0'. null when none. */
460
+ export function specVersion(spec) {
461
+ return /(\d+(?:\.\d+){0,3})/.exec(spec ?? '')?.[1] ?? null
462
+ }
463
+
464
+ /**
465
+ * The upper bound of a version limit, or null when there is none.
466
+ * Python: <1.45.0, <=1.44, ==1.44.0, ~=1.44.0. NuGet: [1.17.0, 1.19.0), [1.18.0]. npm: <2, ^1.25.0, ~1.25.0.
467
+ */
468
+ export function upperBound(lang, spec) {
469
+ if (!spec) return null
470
+ if (lang === 'dotnet') {
471
+ const range = /^\s*[[(]\s*([^,\])]*)\s*(?:,\s*([^\])]*))?\s*[\])]\s*$/.exec(spec)
472
+ if (!range) return null
473
+ return range[2] === undefined ? range[1] || null : range[2].trim() || null
474
+ }
475
+ if (lang === 'node') {
476
+ const caret = /^\s*\^(\d+)(?:\.(\d+))?/.exec(spec)
477
+ if (caret) return Number(caret[1]) > 0 ? `${Number(caret[1]) + 1}.0.0` : `0.${Number(caret[2] ?? 0) + 1}.0`
478
+ const tilde = /^\s*~(\d+)\.(\d+)/.exec(spec)
479
+ if (tilde) return `${tilde[1]}.${Number(tilde[2]) + 1}.0`
480
+ return /<=?\s*(\d[\w.]*)/.exec(spec)?.[1] ?? null
481
+ }
482
+ const lt = /<=?\s*(\d[\w.]*)/.exec(spec)
483
+ if (lt) return lt[1]
484
+ const exact = /===?\s*(\d[\w.]*)/.exec(spec)
485
+ if (exact) return exact[1]
486
+ const compatible = /~=\s*(\d+(?:\.\d+)*)/.exec(spec)
487
+ if (compatible) { const parts = compatible[1].split('.'); parts.pop(); parts[parts.length - 1] = String(Number(parts.at(-1)) + 1); return parts.join('.') }
488
+ return null
489
+ }
490
+
491
+ /** Compare 2 dotted versions by their numbers. Pre-release tags are ignored. */
492
+ export function compareVersions(a, b) {
493
+ const pa = String(a).split(/[.+-]/).map((x) => Number.parseInt(x, 10)), pb = String(b).split(/[.+-]/).map((x) => Number.parseInt(x, 10))
494
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
495
+ const x = Number.isNaN(pa[i] ?? 0) ? 0 : pa[i] ?? 0, y = Number.isNaN(pb[i] ?? 0) ? 0 : pb[i] ?? 0
496
+ if (x !== y) return x < y ? -1 : 1
497
+ }
498
+ return 0
499
+ }
500
+
501
+ function read(view, path) {
502
+ try { return (view.stat?.(path)?.size ?? 0) > MAX_BYTES * 16 ? null : view.read(path) } catch { return null }
503
+ }
504
+
505
+ function listDir(view, dir) {
506
+ try {
507
+ if (typeof view.list === 'function') return view.list(dir)
508
+ return view.files().filter((f) => posix.dirname(f) === dir).map((f) => posix.basename(f))
509
+ } catch { return [] }
510
+ }
511
+
512
+ /** Source files of the service and its members, capped. */
513
+ function sourceFiles(view, all, dirs) {
514
+ const out = []
515
+ for (const file of all) {
516
+ if (out.length >= MAX_SCAN) break
517
+ if (!SOURCE.test(file) || LOCKS.test(file) || !dirs.some((d) => inside(d, file))) continue
518
+ if ((view.stat?.(file)?.size ?? 0) > MAX_BYTES) continue
519
+ out.push(file)
520
+ }
521
+ return out
522
+ }
523
+
524
+ /** Deploy files in the repo that can set the env of this service: its Dockerfile, templates, workflows, compose and .env files. */
525
+ function deployFiles(view, all, service) {
526
+ const out = service.dockerfile ? [service.dockerfile] : []
527
+ for (const file of all) {
528
+ if (out.length >= MAX_SCAN) break
529
+ if (!DEPLOY.test(file) || LOCKS.test(file) || out.includes(file) || /(?:^|\/)pnpm-lock\.ya?ml$/.test(file)) continue
530
+ if ((view.stat?.(file)?.size ?? 0) > 262144) continue
531
+ out.push(file)
532
+ }
533
+ return out
534
+ }
535
+
536
+ /** Every match of the anchor rules in these files, as evidence with a kind. Comment-only lines still count. */
537
+ function scan(view, files, rules) {
538
+ const out = []
539
+ for (const file of files) {
540
+ const applies = rules.filter((r) => !r[2] || r[2].test(file))
541
+ if (!applies.length) continue
542
+ const text = read(view, file)
543
+ if (!text) continue
544
+ for (const [kind, re] of applies) {
545
+ for (const m of text.matchAll(new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`))) {
546
+ const line = text.slice(0, m.index).split('\n').length
547
+ out.push({ file, line, what: m[0].trim(), kind, ...(m[1] ? { value: m[1] } : {}) })
548
+ }
549
+ }
550
+ }
551
+ return out
552
+ }
553
+
554
+ /** The first source line that turns Sentry tracing on, as evidence. */
555
+ function tracingIn(view, files) {
556
+ for (const file of files) {
557
+ const text = read(view, file) ?? ''
558
+ const m = TRACING.exec(text)
559
+ if (m) return { file, line: text.slice(0, m.index).split('\n').length, what: `Sentry tracing (${m[0]})` }
560
+ }
561
+ return null
562
+ }