@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,2736 @@
1
+ // GENERATED from src/docs/install.ts by tools/sync-contract.mjs. Do not edit.
2
+ // Change the docs file, then run: node tools/sync-contract.mjs
3
+ /*
4
+ * UKNOWS install content: the single source for the /docs page and for
5
+ * /llms.txt (built by tools/build-installation-docs.mjs).
6
+ *
7
+ * Keep this file data only. It has no imports, so the docs build can load it
8
+ * with a plain TypeScript transpile.
9
+ *
10
+ * Copy rules: plain simple English, one instruction per sentence, active
11
+ * voice, sentence case, digits for numbers, UKNOWS brand only, no em dashes.
12
+ */
13
+
14
+ /**
15
+ * The site, app and ingest addresses are build settings. A test build sets
16
+ * VITE_UKNOWS_SITE_URL (for example https://test.example.com),
17
+ * VITE_UKNOWS_APP_URL (for example https://app-test.example.com) and
18
+ * VITE_UKNOWS_INGEST_HOST (for example ingest-test.example.com). Without them,
19
+ * the production names below apply. Vite reads import.meta.env. The Node tools
20
+ * that build llms.txt and the setup CLI contract read process.env.
21
+ *
22
+ * The site has the docs, llms.txt and the key check. The app is where people
23
+ * sign in and see their data. The ingest address gets the data.
24
+ */
25
+ export const DEFAULT_SITE_URL = 'https://uknows.my'
26
+ export const DEFAULT_APP_URL = 'https://app.uknows.my'
27
+ export const DEFAULT_INGEST_HOST = 'ingest.uknows.my'
28
+ export const DEFAULT_DOCS_URL = `${DEFAULT_SITE_URL}/docs`
29
+
30
+ // Written as import.meta.env exactly, so Vite and Vitest replace it. Node has no import.meta.env.
31
+ export const BUILD_ENV = {}
32
+
33
+ /** A build setting without spaces around it or a trailing slash, or the default. A bad value stops the build. */
34
+ export function buildSetting(name, fallback, valid, env = BUILD_ENV) {
35
+ const raw = env[name]
36
+ const value = typeof raw === 'string' ? raw.trim().replace(/\/+$/, '') : ''
37
+ if (!value) return fallback
38
+ if (!valid.test(value)) throw new Error(`${name} is not a valid address: ${value}`)
39
+ return value
40
+ }
41
+
42
+ /** https with a host name, or http on this computer for a local test. No path. */
43
+ export const ORIGIN = /^(?:https:\/\/[a-z0-9]([a-z0-9.-]*[a-z0-9])?|http:\/\/(?:localhost|127\.0\.0\.1))(?::\d{1,5})?$/i
44
+ export const SITE_URL = buildSetting('VITE_UKNOWS_SITE_URL', DEFAULT_SITE_URL, ORIGIN)
45
+ /**
46
+ * Where people sign in and see their data: the app. A test build sets
47
+ * VITE_UKNOWS_APP_URL (for example https://app-test.example.com), the same rule
48
+ * as the site address. Without it: the production app with the production
49
+ * site, or the site address itself with any other site (a copy on 1 address).
50
+ */
51
+ export const APP_URL = buildSetting('VITE_UKNOWS_APP_URL', SITE_URL === DEFAULT_SITE_URL ? DEFAULT_APP_URL : SITE_URL, ORIGIN)
52
+ /** A host name only, with an optional port. The apps always send over https. */
53
+ export const HOST = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(?::\d{1,5})?$/i
54
+ export const INGEST_HOST = buildSetting('VITE_UKNOWS_INGEST_HOST', DEFAULT_INGEST_HOST, HOST)
55
+ /** Sign-in and the Station. */
56
+ export const LIVE_URL = `${APP_URL}/live`
57
+ /** The Connect page: open it with ?app=<the app name> to watch that app send its first data. */
58
+ export const CONNECT_URL = `${APP_URL}/live/connect`
59
+ /**
60
+ * The owner's yes to the scan and revoked-key P1 alerts: the same values the
61
+ * server reads (UKNOWS_ALERT_SCAN_P1 and UKNOWS_ALERT_REVOKED_KEY_P1). The
62
+ * release passes both. "on" turns a switch on; unset, empty or "off" leaves it
63
+ * off. Anything else stops the build. These comments go into the setup package
64
+ * through its contract sync, so they name no internal code or file.
65
+ */
66
+ export function buildSwitch(name) {
67
+ const raw = BUILD_ENV[name]
68
+ const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
69
+ if (value === 'on') return true
70
+ if (value === '' || value === 'off') return false
71
+ throw new Error(`${name} must be on or off`)
72
+ }
73
+ /** A scan is a P1 alert. Off: probing is a count for the last hour. */
74
+ export const SCAN_ALERT = buildSwitch('VITE_UKNOWS_ALERT_SCAN_P1')
75
+ /** Uploads with a revoked or retired key are a P1 alert. Off: they are a count, a fact. */
76
+ export const REVOKED_KEY_ALERT = buildSwitch('VITE_UKNOWS_ALERT_REVOKED_KEY_P1')
77
+ export const DOCS_URL = `${SITE_URL}/docs`
78
+ export const LLMS_URL = DOCS_URL.replace(/\/docs$/, '/llms.txt')
79
+ export const INGEST_ENDPOINT = `https://${INGEST_HOST}/otlp`
80
+ /** The route that tells whether a key works. The site serves it, not the ingest host. */
81
+ export const PING_URL = `${SITE_URL}/api/v1/ingest/ping`
82
+ export const EXAMPLE_SERVICE = 'orders-api'
83
+ /**
84
+ * The example names in the key scripts and the GitHub Actions steps. The setup tool replaces each one with the
85
+ * name that it finds in the deploy files of the repo, and says plainly when a name is still an example.
86
+ */
87
+ export const EXAMPLE_NAMES = {
88
+ resourceGroup: 'orders-prod',
89
+ apps: `${EXAMPLE_SERVICE} payments`,
90
+ repo: 'my-org/orders-api',
91
+ org: 'my-org',
92
+ environment: 'production',
93
+ }
94
+ /**
95
+ * "See your data" and the last line of the setup tool say this. Applications lists an app a few minutes after its
96
+ * first data (Settings › Applications shows it go from Awaiting first data to Sending). The setup tool passes the app
97
+ * address of its run (UKNOWS_APP_URL, else the one of this build), so a run against a test copy never names the
98
+ * production app.
99
+ */
100
+ export function openLive(appUrl) {
101
+ return `Sign in at ${appUrl.replace(/^https?:\/\//, '')} and go to Applications. Your app shows there a few minutes after it sends its first data.`
102
+ }
103
+ export const OPEN_LIVE = openLive(APP_URL)
104
+ /** The titles of the 4 steps. Texts name a step by its title, never by its number. */
105
+ export const STEP_TITLES = { key: 'Get your key', install: 'Install', secret: 'Set your key once', data: 'See your data' }
106
+
107
+ /** The 5 plain settings. Commit them in the repo of each app. */
108
+ export const SETTINGS = [
109
+ { name: 'OTEL_SERVICE_NAME', value: EXAMPLE_SERVICE, summary: 'The name of this app in UKNOWS. Use a different name for each app.' },
110
+ { name: 'OTEL_EXPORTER_OTLP_ENDPOINT', value: INGEST_ENDPOINT, summary: 'The UKNOWS address that gets your data.' },
111
+ { name: 'OTEL_EXPORTER_OTLP_PROTOCOL', value: 'http/protobuf', summary: 'Send data in the OTLP format over HTTPS.' },
112
+ { name: 'OTEL_METRICS_EXPORTER', value: 'none', summary: 'Send no metrics. UKNOWS takes traces and logs.', standardOnly: true },
113
+ { name: 'OTEL_LOGS_EXPORTER', value: 'otlp', summary: 'Send the logs of the app too. A log line written during a request carries the trace id of that request.', standardOnly: true },
114
+ ]
115
+
116
+ /**
117
+ * The 3 settings for an app that already uses OpenTelemetry or Azure Monitor.
118
+ * They leave its metrics and logs where they go now.
119
+ */
120
+ export const SHARED_SETTINGS = SETTINGS.filter((setting) => !setting.standardOnly)
121
+
122
+ /** The 1 secret. Set it once for each environment on the platform, never in the repo. */
123
+ export const SECRET = {
124
+ name: 'OTEL_EXPORTER_OTLP_HEADERS',
125
+ header: 'x-uknows-key',
126
+ placeholder: '<key>',
127
+ value: 'x-uknows-key=<key>',
128
+ summary: 'Set your key as a secret on your platform, once for each environment. Keep it out of your repo.',
129
+ }
130
+
131
+ export const SETTINGS_ENV = SETTINGS.map((setting) => `${setting.name}=${setting.value}`).join('\n')
132
+ export const SECRET_ENV = `${SECRET.name}=${SECRET.value}`
133
+
134
+ export function dockerEnv(extra = [], settings = SETTINGS) {
135
+ const pairs = [...settings.map((setting) => `${setting.name}=${setting.value}`), ...extra]
136
+ return `ENV ${pairs.join(' \\\n ')}`
137
+ }
138
+
139
+ /** The ENV lines for the final stage of a Dockerfile. */
140
+ export const SETTINGS_DOCKERFILE = dockerEnv()
141
+ /** The ENV lines for an app that already uses OpenTelemetry or Azure Monitor. */
142
+ export const SHARED_SETTINGS_DOCKERFILE = dockerEnv([], SHARED_SETTINGS)
143
+
144
+ /**
145
+ * The 5 settings as an env file, for apps that do not run from a Dockerfile.
146
+ * It has no key, so it is safe to commit. SETTINGS_FILE_LOADERS shows how to
147
+ * load it.
148
+ */
149
+ export const SETTINGS_FILE = {
150
+ path: 'otel.env',
151
+ language: 'sh',
152
+ summary: 'Use this file when your app does not start from a Dockerfile. It has no key, so it is safe to commit.',
153
+ content: SETTINGS_ENV,
154
+ }
155
+
156
+ /**
157
+ * The setup tool, npx @uknows/setup (packages/uknows-setup). A build with
158
+ * test addresses puts them in front of the command, so the test copy of the
159
+ * page gives a command that writes and checks the test addresses, and signs in
160
+ * at the app of that copy. Production gives the plain command.
161
+ */
162
+ /** The version of the setup tool that these pages describe, the newest on npm. */
163
+ export const SETUP_VERSION = '0.2.0'
164
+ /**
165
+ * Setup 0.2.0 is the first version that knows the production addresses. An older one writes and checks addresses that
166
+ * UKNOWS no longer uses, so with it every setup command gives the production addresses in front too.
167
+ */
168
+ export const PRODUCTION_NAMES_SINCE = '0.2.0'
169
+ /** major, minor and patch of a version, as numbers */
170
+ export function versionParts(version) {
171
+ return version.split('.').map((part) => Number.parseInt(part, 10) || 0)
172
+ }
173
+ /** True when version a is version b or later (major.minor.patch). */
174
+ export function versionAtLeast(a, b) {
175
+ const [x, y] = [versionParts(a), versionParts(b)]
176
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0)
177
+ return true
178
+ }
179
+ /** The site and ingest addresses in front of a setup command: none for the production names, from 0.2.0 on. */
180
+ export function frontAddresses(site, ingestHost, known) {
181
+ return [
182
+ ...(site === DEFAULT_SITE_URL && known ? [] : [`UKNOWS_SITE_URL=${site}`]),
183
+ ...(ingestHost === DEFAULT_INGEST_HOST && known ? [] : [`UKNOWS_INGEST_ENDPOINT=https://${ingestHost}/otlp`]),
184
+ ]
185
+ }
186
+ /**
187
+ * The key check needs only the site and the ingest address. Setup also signs in at the app. With a test address, it
188
+ * signs in only when UKNOWS_APP_URL names the app of that copy, so the command names the app then too. A version
189
+ * before 0.2.0 has no sign-in.
190
+ */
191
+ export function commandAddresses(site, app, ingestHost, known) {
192
+ const front = frontAddresses(site, ingestHost, known)
193
+ return [...front, ...(known && (front.length || app !== DEFAULT_APP_URL) ? [`UKNOWS_APP_URL=${app}`] : [])]
194
+ }
195
+ export const NAMES_KNOWN = versionAtLeast(SETUP_VERSION, PRODUCTION_NAMES_SINCE)
196
+ export const SETUP_ADDRESSES = frontAddresses(SITE_URL, INGEST_HOST, NAMES_KNOWN)
197
+ export const SETUP_COMMAND_ADDRESSES = commandAddresses(SITE_URL, APP_URL, INGEST_HOST, NAMES_KNOWN)
198
+ const setupCommand = (args = '') => [...SETUP_COMMAND_ADDRESSES, `npx @uknows/setup${args}`].join(' ')
199
+ /**
200
+ * The setup command of a build with these build settings, for a version of the setup tool: the command that the pages
201
+ * of that build give. The plain command with the production names from 0.2.0 on, and the addresses in front for a
202
+ * test copy or an older version. The static pages of the site take it at build time, so they give the same command.
203
+ */
204
+ export function setupCommandFor(env, version = SETUP_VERSION) {
205
+ const site = buildSetting('VITE_UKNOWS_SITE_URL', DEFAULT_SITE_URL, ORIGIN, env)
206
+ const app = buildSetting('VITE_UKNOWS_APP_URL', site === DEFAULT_SITE_URL ? DEFAULT_APP_URL : site, ORIGIN, env)
207
+ const ingestHost = buildSetting('VITE_UKNOWS_INGEST_HOST', DEFAULT_INGEST_HOST, HOST, env)
208
+ return [...commandAddresses(site, app, ingestHost, versionAtLeast(version, PRODUCTION_NAMES_SINCE)), 'npx @uknows/setup'].join(' ')
209
+ }
210
+
211
+ /** The prompt line, exactly. bash only: zsh reads -p as a coprocess. */
212
+ export const KEY_PROMPT = "read -rsp 'Paste your UKNOWS key: ' UKNOWS_KEY; echo"
213
+ /** Stops the script, not the terminal, when no key was pasted. */
214
+ export const KEY_EMPTY_GUARD = '[ -n "$UKNOWS_KEY" ] || { echo "No key entered. Nothing changed."; exit 1; }'
215
+
216
+ /**
217
+ * The key check of the setup tool as 1 line, so it is safe to paste at once: read takes the key from the next line
218
+ * you type, never from a pasted line. The key goes to the check only, as a setting of that 1 command, and the shell
219
+ * forgets it at the end. Nothing stays exported. vars: the test addresses of this run, if any.
220
+ */
221
+ export function keyCheckLine(vars = SETUP_ADDRESSES) {
222
+ return `${KEY_PROMPT}; ${[...vars, `${SECRET.name}="${SECRET.header}=$UKNOWS_KEY"`, 'npx @uknows/setup check'].join(' ')}; unset UKNOWS_KEY`
223
+ }
224
+ export const KEY_CHECK_LINE = keyCheckLine()
225
+
226
+ /** Said wherever the docs say that no code from your project runs. */
227
+ export const RESOLVER_NOTE = "uv lock, poetry lock and dotnet restore can run your own project's build code to read its metadata."
228
+
229
+ /**
230
+ * What the setup tool says when it runs the resolver of a project (uv lock,
231
+ * poetry lock, dotnet restore), when it cannot, and when it picks older
232
+ * OpenTelemetry versions for Python. The tool fills in each {name}. The page
233
+ * does not show these.
234
+ */
235
+ export const RESOLVE = {
236
+ /** Plan sentence for a lock refresh. */
237
+ update: 'Update {lock} with {command}.',
238
+ /** RESOLVER_NOTE for the lock commands of 1 run only. The tool fills in {commands}, such as "dotnet restore". */
239
+ buildCode: "{commands} can run your own project's build code to read its metadata.",
240
+ /** What a lock refresh writes, said in the plan before it runs. */
241
+ writes: '{command} writes only {files} in your repo.',
242
+ restoreWrites: 'dotnet restore updates {files}. It also downloads the packages to the NuGet folder on this computer, and it writes the obj/ folders of the projects. remove gives back {files}, not the obj/ folders.',
243
+ again: 'Then run npx @uknows/setup again.',
244
+ tool: {
245
+ title: 'Install {tool}{version}, then run setup again',
246
+ why: '{lock} needs {tool} to add {packages}.',
247
+ missing: 'This tool did not find {tool} on this computer, so it changed nothing.',
248
+ version: '{file} needs {tool} {need}. This computer has {tool} {have}, so this tool changed nothing.',
249
+ image: '{file} line {line} uses {tool} {need} in the image. The {tool} on this computer must be the same version, so the image reads the lock it writes. This computer has {tool} {have}, so this tool changed nothing.',
250
+ install: 'Install {tool}{version} on this computer.',
251
+ },
252
+ outside: {
253
+ title: 'Run setup from the repo root',
254
+ lines: [
255
+ '{lock} is above this folder. {command} must run in {dir}, so this tool changed nothing here.',
256
+ 'Run npx @uknows/setup in the repo root without --path, and pick this service.',
257
+ ],
258
+ },
259
+ failed: {
260
+ title: '{tool} found no versions that fit',
261
+ lines: [
262
+ '{command} found no versions of the packages that fit the limits of your app. This tool put every file back. Nothing changed.',
263
+ 'It names {package}. Change or remove the limit on it, then run npx @uknows/setup again.',
264
+ ],
265
+ },
266
+ python: {
267
+ /** Why the plan uses an older OpenTelemetry set than the newest. */
268
+ olderPython: 'The image uses Python {python}. OpenTelemetry {newest} needs Python {need} or later, so this tool uses OpenTelemetry {core} and {contrib}.',
269
+ olderLimit: '{file} line {line} has {value}. So this tool uses OpenTelemetry {core} and {contrib}, the newest versions that fit.',
270
+ /** For uv and Poetry: the resolver picks the versions, and the set decides which instrumentation packages exist. */
271
+ floorPython: 'The project supports Python {python}. OpenTelemetry {newest} needs Python {need} or later, so this tool adds only the packages that OpenTelemetry {contrib} has. {tool} picks the versions.',
272
+ floorLimit: '{file} line {line} has {value}. So this tool adds only the packages that OpenTelemetry {contrib} has. {tool} picks the versions.',
273
+ noSet: {
274
+ title: 'Pick the OpenTelemetry versions by hand',
275
+ lines: [
276
+ '{file} line {line} has {value}. No OpenTelemetry versions that this tool knows fit it, so this tool changed nothing.',
277
+ 'Add opentelemetry-distro, opentelemetry-exporter-otlp-proto-http and the instrumentation packages at versions that fit. pip install, then pip freeze, shows the versions.',
278
+ ],
279
+ },
280
+ procfile: {
281
+ title: 'Start the app with opentelemetry-instrument',
282
+ lines: [
283
+ '{file} starts the app on its web line. This tool does not change a Procfile, because the hosts it supports do not read it.',
284
+ 'If your platform starts the app from {file}, change the web line to the line below.',
285
+ ],
286
+ },
287
+ },
288
+ dotnet: {
289
+ sdk: {
290
+ title: 'Install the .NET {need} SDK, then run setup again',
291
+ lines: [
292
+ '{lock} needs dotnet restore to add the packages.',
293
+ 'This computer has no .NET SDK {need} or later, so this tool changed nothing. It found: {have}.',
294
+ 'Install the .NET {need} SDK on this computer.',
295
+ ],
296
+ },
297
+ offline: {
298
+ title: 'Add the packages to the source that your build restores from',
299
+ lines: [
300
+ '{file} line {line} restores the packages with --network=none, so new packages cannot arrive in the image. This tool changed nothing.',
301
+ 'Add these packages to the package source that your build uses. Then add them to {csproj}, update packages.lock.json and start the app with instrument.sh, as below.',
302
+ ],
303
+ },
304
+ runtimes: {
305
+ title: 'Update packages.lock.json by hand',
306
+ lines: [
307
+ '{file} line {line} restores for {value}. This tool refreshes the lock for 1 known runtime only, so it changed nothing.',
308
+ 'Add the packages below. Run dotnet restore --force-evaluate with the runtime options of your build. Commit packages.lock.json. Then start the app with instrument.sh.',
309
+ ],
310
+ /** Instead of the first line, when packages.lock.json has packages for more than 1 runtime. */
311
+ lock: '{lock} has packages for {value}. This tool refreshes the lock for 1 runtime only, so it changed nothing.',
312
+ },
313
+ /**
314
+ * Another project references the app project, such as a test project, and has its own packages.lock.json. That
315
+ * lock lists the packages of the app too, so a locked restore of the solution fails (NU1004) unless it is updated
316
+ * as well. The tool fills in {lock}, {project}, {csproj} and {command}.
317
+ */
318
+ dependent: '{lock} also locks the packages of {csproj}, because {project} references it. This tool updates it with {command}.',
319
+ /** The plan sentence for that refresh. */
320
+ dependentUpdate: 'Update {lock} with {command}, because {project} references {csproj}.',
321
+ /** That project is above the --path folder, so this tool cannot write its lock. */
322
+ dependentOutside: {
323
+ title: 'Run setup from the repo root',
324
+ lines: [
325
+ '{project} references {csproj} and has its own {lock}. It is above this folder, so this tool cannot update that lock, and it changed nothing.',
326
+ 'Run npx @uknows/setup in the repo root without --path, and pick this service.',
327
+ ],
328
+ },
329
+ /**
330
+ * The publish names a runtime for another C library than the final image: glibc (linux-x64) on Alpine, or musl
331
+ * (linux-musl-x64) on a glibc image. Then the native profiler of OpenTelemetry does not load. Without -r, the publish
332
+ * holds the native files of every platform and instrument.sh picks the right one, so that needs no step. Advice,
333
+ * not a required step: the startup hook still traces the requests. The tool fills in {where}, {rid}, {libc},
334
+ * {image}, {imageLibc} and {want}.
335
+ */
336
+ libc: {
337
+ title: 'Publish for the C library of the final image',
338
+ lines: [
339
+ '{where} publishes with -r {rid}, for {libc}. The final image {image} uses {imageLibc}.',
340
+ 'So the native part of OpenTelemetry does not load. Requests still show in UKNOWS, but libraries that need the native part, such as StackExchange.Redis and Microsoft.Data.Sqlite, get no spans.',
341
+ 'Publish with -r {want}. If the project has a packages.lock.json, add {want} to its RuntimeIdentifiers and run dotnet restore --force-evaluate first, so the lock has the packages for it.',
342
+ ],
343
+ },
344
+ /** A note when the tool runs dotnet restore and no .dockerignore keeps obj/ out of the image. */
345
+ dockerignore: 'dotnet restore writes obj/ folders in your repo. A docker build on this computer copies them into the image and the build can fail. Add bin/ and obj/ to .dockerignore.',
346
+ assets: {
347
+ title: 'Run setup again for {package}',
348
+ lines: [
349
+ '{assets} shows that the app uses {library} {version} through {via}. The build also needs {package} {need}. This tool put every file back. Nothing changed.',
350
+ 'Run npx @uknows/setup again. It reads that file now and adds {package}.',
351
+ ],
352
+ },
353
+ },
354
+ /**
355
+ * npm on this computer writes the lock that npm ci in the image reads. A newer npm than the one in the image can
356
+ * write a lock that the older npm refuses. The tool fills in {file}, {line}, {image}, {need} and {have}.
357
+ */
358
+ npm: {
359
+ title: 'Use npm {need} on this computer, then run setup again',
360
+ lines: [
361
+ '{file} line {line} builds with {image}, which comes with npm {need}. This computer has npm {have}. A newer npm can write a package-lock.json that npm ci in the image refuses, so this tool changed nothing.',
362
+ 'Install npm {need} on this computer with npm install -g npm@{need}, or use the Node.js version of the image, which comes with npm {need}.',
363
+ ],
364
+ },
365
+ }
366
+
367
+ /**
368
+ * The Windows tip for a bash terminal. It is the same everywhere, and the welcome email of the product says the same.
369
+ * Only the steps of a cloud name its own shell too: Azure Cloud Shell in the Azure steps, AWS CloudShell in the AWS steps.
370
+ */
371
+ export const WINDOWS_TIP = 'On Windows, use WSL.'
372
+ export const MACOS_TIP = 'On macOS, type bash first.'
373
+
374
+ /**
375
+ * What the setup tool needs on your computer. The tool updates the lock files of your app with the tools of your app,
376
+ * so it needs them too. The page and the --help of the tool show these lines.
377
+ */
378
+ export const SETUP_NEEDS = [
379
+ 'Node.js 18 or later, and git.',
380
+ 'For a Node.js app: its package tool, npm, pnpm or yarn. npm must not be newer than the npm of your Node.js image.',
381
+ 'For a Python app with uv or Poetry: that tool, at the version that your Dockerfile uses.',
382
+ 'For a .NET app with packages.lock.json: the .NET SDK of global.json.',
383
+ `A bash terminal for the key steps. ${MACOS_TIP} ${WINDOWS_TIP}`,
384
+ ]
385
+
386
+ /**
387
+ * Setup runs once on a computer, never in a pipeline. The /docs page and
388
+ * llms.txt show this section, and the setup tool prints it when it stops in a
389
+ * pipeline (exit 2).
390
+ */
391
+ export const CI_CD = {
392
+ id: 'ci-cd',
393
+ title: 'CI/CD and pipelines',
394
+ summary: 'Run setup once, on your computer, in your repo. Do not run it in your pipeline.',
395
+ steps: [
396
+ 'Run npx @uknows/setup in your repo. Review the diff. Commit the change and open a pull request.',
397
+ 'Your pipeline builds and deploys the change as usual. It needs no UKNOWS step to build.',
398
+ 'Store your key in the secret store of your pipeline, once for each environment. In GitHub Actions, use a secret named UKNOWS_KEY that holds the key only.',
399
+ 'In your deploy job, check that the secret is not empty. Set the key on your app before the deploy that uses it.',
400
+ 'If your deploy replaces all environment variables, add the key to that list. This includes --replace-env-vars, the environmentVariables of the deploy action, and Bicep or Terraform templates. Otherwise each deploy removes the key.',
401
+ ],
402
+ check: 'Setup stops when it runs in a pipeline. To check that a repo is set up, run the command below in the pipeline. It changes nothing. The repo is set up when the JSON says "status": "nothing-to-change" and "exitCode": 0, and the command fails otherwise. In a monorepo, it checks the services that setup set up. The command uses jq, which GitHub Actions and Azure Pipelines runners have.',
403
+ snippet: { caption: 'Check in your pipeline that the repo is set up', language: 'bash', code: `${setupCommand(' --dry-run --json')} | jq -e '.status == "nothing-to-change" and .exitCode == 0'` },
404
+ /** What the setup tool prints when it stops in a pipeline: what happened, then 1 next step. It fills in {docs}. */
405
+ stop: 'This runs in a pipeline, so the tool changes nothing.',
406
+ next: 'Run npx @uknows/setup once on your computer and commit the change. To check in a pipeline that a repo is set up, see {docs}.',
407
+ allowCi: 'A bot or an AI agent that opens a pull request can add --allow-ci.',
408
+ }
409
+
410
+ /**
411
+ * What the setup tool prints when a run has nothing to change. A repo counts as set up ("nothing-to-change", exit 0)
412
+ * when .uknows/setup.json lists every service, or when no required step is left. Otherwise the status is
413
+ * "needs-steps" with exit 3: the tool could plan nothing, and the steps are yours. The tool fills in {count} and
414
+ * {manifest}. The page does not show these.
415
+ */
416
+ export const SETUP_STATUS = {
417
+ done: 'Already set up. Nothing to change.',
418
+ doneSteps: 'Steps that this tool cannot check in your repo. Do them if you have not done them yet:',
419
+ steps: 'Nothing for this tool to change. The steps below are yours.',
420
+ recorded: 'Planning the {count} that {manifest} lists. To plan other services, use --all or --path <folder>.',
421
+ /** Set up earlier, but steps in the repo that this tool can check are not done yet ("steps-left", exit 3). */
422
+ stepsLeft: 'Set up earlier, but {count} in your repo {verb} not done yet. This tool checks {pronoun} each time it runs:',
423
+ /** Nothing to change, but the services are not in the record yet: the run writes only the record. */
424
+ record: 'This run writes only {manifest}, so the pipeline check and remove know this service. It changes no file of your app.',
425
+ }
426
+
427
+ /**
428
+ * The last line of a setup run. It fills in {count} and {open}. "data" only when the repo needs nothing more from you
429
+ * and the key check did not fail. {open} is openLive with the app address of the run, or "testCopy" when the run
430
+ * goes to a test copy of UKNOWS and does not know its app address.
431
+ */
432
+ export const SETUP_NEXT = {
433
+ data: 'Next: commit, open a pull request and deploy as usual. {open}',
434
+ testCopy: 'Then sign in to your test copy of UKNOWS. To see its address here, set UKNOWS_APP_URL to the address of its app.',
435
+ steps: 'Next: do the {count} above first. Then commit, open a pull request and deploy as usual.',
436
+ rejected: 'Next: fix the key first. An Admin or an Engineer of your workspace can show the key again, or create a new one, in Settings › Keys. Then check it with this 1 line in bash:',
437
+ network: 'Next: check the network as the lines above say. You can commit the change already. Then check the key again with this 1 line in bash:',
438
+ skipped: 'To check your key, run this 1 line in bash and paste your key. Nothing stays set in your terminal:',
439
+ }
440
+
441
+ const hostOf = (url ) => url.replace(/^https?:\/\//, '')
442
+ /** The store of the UKNOWS agent images (the agent channel): the host of DEFAULT_AGENT_REGISTRY in the setup tool. */
443
+ export const AGENT_REGISTRY_HOST = 'registry.uknows.my'
444
+
445
+ /**
446
+ * Every address that UKNOWS uses, and who connects to it. OpenTelemetry for Node.js asks the metadata addresses of
447
+ * the clouds when the app starts (169.254.169.254 on port 80, and the Kubernetes API on EKS), unless
448
+ * OTEL_NODE_RESOURCE_DETECTORS leaves those detectors out. Each cloud detector (alibaba, aws, azure, gcp) also asks a
449
+ * metadata address, so the Node.js steps keep only the local ones. Checked on 18 Oct 2026.
450
+ */
451
+ export const NETWORK = {
452
+ host: INGEST_HOST,
453
+ port: 443,
454
+ text: `Allow outbound HTTPS from your apps to ${INGEST_HOST} on port 443. Your apps send data to UKNOWS. UKNOWS never connects to your apps.`,
455
+ hosts: [
456
+ { host: INGEST_HOST, who: 'Your apps send their traces and logs here, over HTTPS on port 443.' },
457
+ { host: hostOf(APP_URL), who: 'Your team signs in here in the browser. The setup tool asks here for its sign-in link, and gets the key here after you approve.' },
458
+ { host: hostOf(SITE_URL), who: 'The docs are here. The setup tool asks here which workspace and environment a key sends to.' },
459
+ { host: AGENT_REGISTRY_HOST, who: 'The Docker builds of Java, .NET and Node.js apps copy the UKNOWS agent from here, over HTTPS on port 443, with no sign-in.' },
460
+ { host: 'registry.npmjs.org, pypi.org, files.pythonhosted.org and api.nuget.org', who: 'npm, uv, Poetry and dotnet restore download packages from their registry when the setup tool updates the lock file of your app, on your computer.' },
461
+ ],
462
+ node: 'A Node.js app that you set up with these steps asks no cloud metadata address when it starts. OTEL_NODE_RESOURCE_DETECTORS keeps only the detectors that read the app and its host. Without it, OpenTelemetry for Node.js asks the metadata addresses of the clouds, such as 169.254.169.254 on port 80.',
463
+ }
464
+
465
+ /** The settings that a stack commits: its own list, or the 5. */
466
+ export function stackSettings(stack) {
467
+ return stack.settings ?? SETTINGS
468
+ }
469
+
470
+ /** Extra steps that the page can show folded, under the title. */
471
+
472
+ /**
473
+ * The upstream OpenTelemetry Java agent, pinned. Checksum from the release
474
+ * checksums.txt. The OpenTelemetry Operator image holds the same jar at
475
+ * /javaagent.jar. The digest is the image index, for every platform.
476
+ */
477
+ export const JAVA_AGENT = {
478
+ version: '2.32.0',
479
+ file: 'opentelemetry-javaagent.jar',
480
+ url: 'https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.32.0/opentelemetry-javaagent.jar',
481
+ sha256: 'f787eb6c7f3d18e69a431e108a15278d25ee37f83d68b678f621e063f3988f82',
482
+ image: 'ghcr.io/open-telemetry/opentelemetry-operator/autoinstrumentation-java:2.32.0',
483
+ digest: 'sha256:9dad1c6e3e2ecee48fc164a19bcfde703510bee972c8cb421689fde89b5f89e9',
484
+ /** Where the jar is in the image. */
485
+ imagePath: '/javaagent.jar',
486
+ }
487
+
488
+ /** The Dockerfile line that copies the agent from the pinned image. It needs no BuildKit feature. */
489
+ export const JAVA_COPY_LINE = `COPY --from=${JAVA_AGENT.image}@${JAVA_AGENT.digest} ${JAVA_AGENT.imagePath} /otel/${JAVA_AGENT.file}`
490
+
491
+ /** For networks that block ghcr.io: a build stage that downloads the agent from GitHub and checks it. */
492
+ export const JAVA_AGENT_STAGE = [
493
+ '# A build stage, before your final stage',
494
+ 'FROM alpine:3.20 AS otel-agent',
495
+ `RUN wget -q -O /${JAVA_AGENT.file} ${JAVA_AGENT.url} \\`,
496
+ ` && echo "${JAVA_AGENT.sha256} /${JAVA_AGENT.file}" | sha256sum -c -`,
497
+ '',
498
+ '# Final stage: use this line instead of the COPY line from ghcr.io',
499
+ `COPY --from=otel-agent /${JAVA_AGENT.file} /otel/${JAVA_AGENT.file}`,
500
+ ].join('\n')
501
+
502
+ export const NODE_REGISTER = '@opentelemetry/auto-instrumentations-node/register'
503
+ /** For ES modules: the loader hook gives each request its route. No space after = so 1 value holds both options. */
504
+ export const NODE_ESM_OPTIONS = `--experimental-loader=@opentelemetry/instrumentation/hook.mjs --import=${NODE_REGISTER}`
505
+
506
+ /**
507
+ * A small start file for Node.js. It starts OpenTelemetry like the upstream
508
+ * register. On SIGTERM it sends the last data, then stops the app as Node
509
+ * does, unless the app has its own SIGTERM handler. A missing package only
510
+ * prints a warning. The setup tool writes the same file.
511
+ *
512
+ * As PID 1 in a container (CMD ["node", ...] without an init), Linux ignores
513
+ * a signal that has no handler, so the SIGTERM that the file sends again does
514
+ * not stop node. Then the file exits with code 143, the code of SIGTERM.
515
+ * Checked with docker stop on 18 Oct 2026: 10 seconds and a kill before, under
516
+ * 1 second after.
517
+ */
518
+ export const NODE_REGISTER_FILE = {
519
+ path: 'uknows-otel.cjs',
520
+ language: 'javascript',
521
+ summary: 'Add this file next to package.json.',
522
+ content: `'use strict'
523
+ // Added for UKNOWS. Starts standard OpenTelemetry for Node.js.
524
+ // OTEL_SDK_DISABLED=true turns it off. On SIGTERM it sends the last data, then
525
+ // stops the app as Node does, unless the app has its own SIGTERM handler.
526
+ if (String(process.env.OTEL_SDK_DISABLED || '').trim().toLowerCase() !== 'true') {
527
+ const before = new Set(process.listeners('SIGTERM'))
528
+ try {
529
+ require(require.resolve('${NODE_REGISTER}', { paths: [__dirname, process.cwd()] }))
530
+ } catch (error) {
531
+ console.warn(\`OpenTelemetry did not start: \${error.message}\`)
532
+ }
533
+ for (const listener of process.listeners('SIGTERM')) {
534
+ if (before.has(listener)) continue
535
+ process.removeListener('SIGTERM', listener)
536
+ process.on('SIGTERM', function stopAfterFlush(signal) {
537
+ const alone = process.listenerCount('SIGTERM') === 1
538
+ let stopping = false
539
+ const stop = () => {
540
+ if (!alone || stopping) return
541
+ stopping = true
542
+ process.removeListener('SIGTERM', stopAfterFlush)
543
+ process.kill(process.pid, 'SIGTERM')
544
+ // As PID 1 in a container, Linux ignores that signal. Then exit with the code of SIGTERM.
545
+ setTimeout(() => process.exit(143), 100)
546
+ }
547
+ setTimeout(stop, 5000).unref()
548
+ Promise.resolve(listener(signal)).then(stop, stop)
549
+ })
550
+ }
551
+ }`,
552
+ }
553
+ /** Where the setup tool and the steps put the start file in an image. */
554
+ export const NODE_REGISTER_PATH = `/app/${NODE_REGISTER_FILE.path}`
555
+
556
+ /**
557
+ * What the setup tool says about the start file. It fills in {file}, {dockerfile}, {ignore} and {dockerfiles}. When
558
+ * it cannot be sure that the file reaches the running app, it loads the register line instead and says why. The page does not show these.
559
+ */
560
+ export const NODE_START_FILE = {
561
+ add: 'Add {file}. It starts OpenTelemetry and stops the app cleanly on SIGTERM.',
562
+ copy: 'Copy the start file into the image.',
563
+ keep: '{file} is there already. This tool keeps it as it is.',
564
+ noContext: 'This tool cannot tell the build context of {dockerfile} from its COPY lines.',
565
+ noWorkdir: 'The final stage of {dockerfile} has no WORKDIR with a full path, so this tool does not know where the app is in the image.',
566
+ outside: '{file} is outside the build context of {dockerfile}.',
567
+ ignored: '{ignore} leaves {file} out of the build context.',
568
+ gitignored: '.gitignore leaves {file} out of git, so a fresh checkout and the build in your pipeline do not have it.',
569
+ dockerfiles: 'This tool did not pick a Dockerfile for this service. It saw {dockerfiles}. An image built from them would not have {file}.',
570
+ fallback: 'So this tool loads the OpenTelemetry register line instead of the start file. The app works, but it does not stop on SIGTERM by itself. Docker, Kubernetes and systemd then wait for their time limit and kill it. To stop cleanly, add the start file yourself.',
571
+ }
572
+
573
+ /** .NET writes its own OpenTelemetry log files. The default folder is not writable on macOS or as a user that is not root. */
574
+ export const DOTNET_LOG_DIRECTORY = 'OTEL_DOTNET_AUTO_LOG_DIRECTORY=/tmp/otel-dotnet'
575
+
576
+ /**
577
+ * .NET sends an ILogger line as its message template, such as "Served order {Id}", with the values beside it. UKNOWS
578
+ * keeps the text of a log line and not those values (DATA_FACTS logs), so the text must be the formatted message.
579
+ */
580
+ export const DOTNET_LOGS_MESSAGE = 'OTEL_DOTNET_AUTO_LOGS_INCLUDE_FORMATTED_MESSAGE=true'
581
+
582
+ /**
583
+ * Node.js: only the resource detectors that read the app and its host (NETWORK.node). env goes last, so a value in
584
+ * OTEL_RESOURCE_ATTRIBUTES wins over a detected one.
585
+ */
586
+ export const NODE_DETECTORS = 'OTEL_NODE_RESOURCE_DETECTORS=host,os,serviceinstance,container,env'
587
+
588
+ /**
589
+ * Python: the host name and the operating system of the app, as Node.js sends them. Python sends no host name without
590
+ * it, so UKNOWS could not tell which revision or pod of the app the data comes from. Neither detector asks a network
591
+ * address. OTEL_RESOURCE_ATTRIBUTES still wins over a detected value.
592
+ */
593
+ export const PYTHON_DETECTORS = 'OTEL_EXPERIMENTAL_RESOURCE_DETECTORS=host,os'
594
+
595
+ /**
596
+ * Python: the OpenTelemetry logging package stays off the root logger. On the root logger it changes what the app
597
+ * prints: Python's last-resort output, logging.warning() before any setup and Flask's own handler all stop (install
598
+ * proof, 22 Oct 2026). The UKNOWS log bridge sends the log records instead.
599
+ */
600
+ export const PYTHON_LOG_HANDLER_OFF = 'OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION=false'
601
+
602
+ /** Where the UKNOWS log bridge for Python goes: a folder next to the dependency file, copied into the image. */
603
+ export const PYTHON_BRIDGE_DIR = 'uknows-otel'
604
+ export const PYTHON_BRIDGE_IMAGE = '/opt/uknows-otel'
605
+
606
+ /**
607
+ * The UKNOWS log bridge for Python. opentelemetry-instrument finds its entry point on PYTHONPATH and calls install()
608
+ * after it starts OpenTelemetry. The bridge sends each log record that the app's own logging handles, without a
609
+ * handler on the root logger, so the app prints exactly what it printed before.
610
+ */
611
+ export const PYTHON_LOG_BRIDGE = {
612
+ dir: PYTHON_BRIDGE_DIR,
613
+ image: PYTHON_BRIDGE_IMAGE,
614
+ files: [
615
+ {
616
+ path: 'uknows_logs.py',
617
+ language: 'python',
618
+ summary: 'The bridge.',
619
+ content: `# Added for UKNOWS. Sends the log records of this app to UKNOWS with OpenTelemetry, and changes nothing in what the
620
+ # app prints: no handler goes on the root logger, so Python's last-resort output, logging.basicConfig, Flask's own
621
+ # handler and every check for handlers work as before. opentelemetry-instrument calls install() after it starts
622
+ # OpenTelemetry. OTEL_LOGS_EXPORTER=none turns it off.
623
+ import logging
624
+ import os
625
+
626
+ def install():
627
+ try:
628
+ _install()
629
+ except Exception: # never stop the app, never print in its output
630
+ pass
631
+
632
+ def _install():
633
+ if "otlp" not in [name.strip() for name in os.environ.get("OTEL_LOGS_EXPORTER", "").split(",")]:
634
+ return
635
+ if getattr(logging.Logger.callHandlers, "_uknows", False):
636
+ return
637
+ from opentelemetry._logs import get_logger_provider
638
+
639
+ try:
640
+ from opentelemetry.instrumentation.logging import LoggingInstrumentor
641
+ from opentelemetry.instrumentation.logging.handler import LoggingHandler
642
+ except ImportError:
643
+ return
644
+ # The logging package puts its handler on the root logger, which changes what the app prints. Take it off.
645
+ root = logging.getLogger()
646
+ other = getattr(LoggingInstrumentor, "_logging_handler", None)
647
+ if other is not None and other in root.handlers:
648
+ root.removeHandler(other)
649
+ handler = LoggingHandler(logger_provider=get_logger_provider())
650
+ handler.handleError = lambda record: None
651
+ call_handlers = logging.Logger.callHandlers
652
+
653
+ def callHandlers(self, record):
654
+ call_handlers(self, record)
655
+ if record.name.startswith("opentelemetry"):
656
+ return
657
+ try:
658
+ handler.handle(record)
659
+ except Exception:
660
+ pass
661
+
662
+ callHandlers._uknows = True
663
+ logging.Logger.callHandlers = callHandlers`,
664
+ },
665
+ {
666
+ path: 'uknows_logs-1.0.dist-info/METADATA',
667
+ language: 'text',
668
+ summary: 'Its name, so Python finds the entry point.',
669
+ content: `Metadata-Version: 2.1
670
+ Name: uknows-logs
671
+ Version: 1.0
672
+ Summary: Added for UKNOWS. Sends the log records of the app without changing what the app prints.`,
673
+ },
674
+ {
675
+ path: 'uknows_logs-1.0.dist-info/entry_points.txt',
676
+ language: 'ini',
677
+ summary: 'The entry point that opentelemetry-instrument calls.',
678
+ content: `[opentelemetry_post_instrument]
679
+ uknows_logs = uknows_logs:install`,
680
+ },
681
+ ],
682
+ }
683
+
684
+ /** PYTHONPATH with the bridge folder of the image after the value that the image has, if any. */
685
+ export const PYTHON_BRIDGE_PATH = 'PYTHONPATH=${PYTHONPATH:+${PYTHONPATH}:}' + PYTHON_BRIDGE_IMAGE
686
+
687
+ /** The Dockerfile line that copies the bridge folder into the image. */
688
+ export const PYTHON_BRIDGE_COPY = `COPY ${PYTHON_BRIDGE_DIR} ${PYTHON_BRIDGE_IMAGE}`
689
+
690
+ export const GO_INIT = `package main
691
+
692
+ import (
693
+ "context"
694
+ "net/http"
695
+ "os"
696
+ "strings"
697
+
698
+ "go.opentelemetry.io/otel"
699
+ "go.opentelemetry.io/otel/attribute"
700
+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
701
+ "go.opentelemetry.io/otel/propagation"
702
+ "go.opentelemetry.io/otel/sdk/resource"
703
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
704
+ "go.opentelemetry.io/otel/trace"
705
+ )
706
+
707
+ // setupOTel sends traces to the address in the OTEL_ settings.
708
+ // Call the returned function when the app stops. It sends the last data.
709
+ func setupOTel(ctx context.Context) (func(context.Context) error, error) {
710
+ if os.Getenv("OTEL_SDK_DISABLED") == "true" {
711
+ return func(context.Context) error { return nil }, nil
712
+ }
713
+ exporter, err := otlptracehttp.New(ctx)
714
+ if err != nil {
715
+ return nil, err
716
+ }
717
+ // The OTEL_ settings and the host name: UKNOWS tells the instances of the app apart by the host name.
718
+ // A detector that fails leaves only its own part out.
719
+ res, _ := resource.New(ctx, resource.WithFromEnv(), resource.WithTelemetrySDK(), resource.WithHost())
720
+ provider := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter), sdktrace.WithResource(res))
721
+ otel.SetTracerProvider(provider)
722
+ otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
723
+ propagation.TraceContext{}, propagation.Baggage{},
724
+ ))
725
+ return provider.Shutdown, nil
726
+ }
727
+
728
+ // withRoute adds the route of each request, such as /orders/{id}, to its
729
+ // trace, after the net/http ServeMux picks the route. Without it, UKNOWS
730
+ // shows all requests of the app as 1 endpoint.
731
+ func withRoute(mux http.Handler) http.Handler {
732
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
733
+ mux.ServeHTTP(w, r)
734
+ // r.Pattern is, for example, "GET /orders/{id}". The route starts at the first /.
735
+ if i := strings.IndexByte(r.Pattern, '/'); i >= 0 {
736
+ setRoute(r, r.Pattern[i:])
737
+ }
738
+ })
739
+ }
740
+
741
+ // setRoute adds a route to the trace of the request and names the trace
742
+ // after it, for example GET /orders/{id}.
743
+ func setRoute(r *http.Request, route string) {
744
+ if route != "" {
745
+ span := trace.SpanFromContext(r.Context())
746
+ span.SetName(r.Method + " " + route)
747
+ span.SetAttributes(attribute.String("http.route", route))
748
+ }
749
+ }`
750
+
751
+ /** The part of main.go before the router lines. */
752
+ export const GO_MAIN_HEAD = `// Imports for this code: "context", "errors", "log", "net/http",
753
+ // "os", "os/signal", "syscall" and "time".
754
+
755
+ // 1. Start OpenTelemetry at the start of main.
756
+ shutdown, err := setupOTel(context.Background())
757
+ if err != nil {
758
+ log.Fatal(err)
759
+ }`
760
+
761
+ /** The middleware of each router. Each one sets handler. echoOlder is Echo v4 with Go below 1.25. */
762
+ export const GO_ROUTERS = {
763
+ nethttp: `// net/http: wrap the ServeMux. withRoute is in otel.go.
764
+ // import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
765
+ handler = otelhttp.NewHandler(withRoute(mux), "http.server")`,
766
+ chi: `// chi: add this middleware before your routes. Then wrap the router.
767
+ // import "github.com/go-chi/chi/v5" and otelhttp
768
+ router.Use(func(next http.Handler) http.Handler {
769
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
770
+ next.ServeHTTP(w, r)
771
+ setRoute(r, chi.RouteContext(r.Context()).RoutePattern())
772
+ })
773
+ })
774
+ handler = otelhttp.NewHandler(router, "http.server")`,
775
+ gin: `// Gin: add the middleware. Then use the router as the handler.
776
+ // import "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
777
+ router.Use(otelgin.Middleware(os.Getenv("OTEL_SERVICE_NAME")))
778
+ handler = router`,
779
+ echo: `// Echo v4: add the middleware. Then use e as the handler.
780
+ // import echootel "github.com/labstack/echo-otel/v4"
781
+ e.Use(echootel.NewMiddleware(""))
782
+ handler = e`,
783
+ echoOlder: `// Echo v4: add the middleware. Then use e as the handler.
784
+ // import "go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho"
785
+ e.Use(otelecho.Middleware(os.Getenv("OTEL_SERVICE_NAME")))
786
+ handler = e`,
787
+ }
788
+
789
+ /**
790
+ * main.go with the middleware of the given routers and the address the server listens on. The page shows every
791
+ * router and :8080. The setup tool shows the router it found and the port of the app.
792
+ */
793
+ export function goMain(routers = ['nethttp', 'chi', 'gin', 'echo'], address = ':8080') {
794
+ const keep = routers.length === 1 ? '' : ' Keep only the lines for your router.'
795
+ return `${GO_MAIN_HEAD}
796
+
797
+ // 2. Add the middleware for your router.${keep}
798
+ var handler http.Handler
799
+
800
+ ${routers.map((router) => GO_ROUTERS[router]).join('\n\n')}
801
+
802
+ // 3. Run the server. On Ctrl+C or SIGTERM, stop the server.
803
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
804
+ defer stop()
805
+ srv := &http.Server{Addr: "${address}", Handler: handler}
806
+ serveErr := make(chan error, 1)
807
+ go func() { serveErr <- srv.ListenAndServe() }()
808
+ select {
809
+ case err = <-serveErr:
810
+ case <-ctx.Done():
811
+ stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
812
+ err = srv.Shutdown(stopCtx)
813
+ cancel()
814
+ }
815
+
816
+ // 4. Send the last data before the app exits.
817
+ flushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
818
+ _ = shutdown(flushCtx)
819
+ cancel()
820
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
821
+ log.Fatal(err)
822
+ }`
823
+ }
824
+
825
+ export const GO_MAIN = goMain()
826
+
827
+ /**
828
+ * OpenTelemetry Go for an app that builds with an older Go. For each Go version: the newest OpenTelemetry Go
829
+ * release whose go.mod allows it, and the contrib release made with it.
830
+ * Checked on proxy.golang.org on 18 Oct 2026, and built with each Go in Docker. otel.go needs Go 1.23 or later,
831
+ * because it reads http.Request.Pattern.
832
+ */
833
+ export const GO_RELEASES = [
834
+ { go: '1.25', otel: 'v1.46.0', contrib: 'v0.71.0' },
835
+ { go: '1.24', otel: 'v1.41.0', contrib: 'v0.66.0' },
836
+ { go: '1.23', otel: 'v1.38.0', contrib: 'v0.63.0' },
837
+ ]
838
+
839
+ /** echo-otel needs Go 1.25. With an older Go, Echo v4 uses the older contrib middleware otelecho. */
840
+ export const GO_ECHO = { module: 'github.com/labstack/echo-otel/v4', go: '1.25', older: 'go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho' }
841
+ export const GO_GORM = 'gorm.io/plugin/opentelemetry'
842
+ export const GO_REDIS = 'github.com/redis/go-redis/extra/redisotel/v9'
843
+ const goOlder = (release ) => Number(release.go.split('.')[1]) < Number(GO_ECHO.go.split('.')[1])
844
+
845
+ /** The go get lines. Without a release, go get takes the newest modules. With one, each module gets its version. */
846
+ export function goInstall(release) {
847
+ const otel = (module ) => (release ? `${module}@${release.otel}` : module)
848
+ const contrib = (module ) => (release ? `${module}@${release.contrib}` : module)
849
+ const echo = !release ? GO_ECHO.module : goOlder(release) ? contrib(GO_ECHO.older) : `${GO_ECHO.module}@v4.0.0`
850
+ return [
851
+ `go get ${otel('go.opentelemetry.io/otel')} \\`,
852
+ ` ${otel('go.opentelemetry.io/otel/sdk')} \\`,
853
+ ` ${otel('go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp')} \\`,
854
+ ` ${contrib('go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp')}`,
855
+ '# Gin only',
856
+ `go get ${contrib('go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin')}`,
857
+ '# Echo v4 only',
858
+ `go get ${echo}`,
859
+ ].join('\n')
860
+ }
861
+
862
+ /** The go-redis version of go.mod, for the redisotel line of the docs. The setup tool prints the version itself. */
863
+ export const GO_REDIS_VERSION = "$(go list -m -f '{{.Version}}' github.com/redis/go-redis/v9)"
864
+
865
+ /**
866
+ * Spans for each database query and each Redis command. The setup tool prints the one that the app uses, at a
867
+ * version that does not raise the gorm or go-redis of go.mod. Checked on proxy.golang.org on 18 Oct 2026, with go get,
868
+ * go mod tidy and go build in golang:1.24-alpine: the plugin raises gorm to the gorm in its module graph.
869
+ */
870
+ export const GO_LIBRARIES = {
871
+ gorm: {
872
+ module: GO_GORM,
873
+ /** Newest first: each plugin version, the lowest gorm it works with (it raises gorm to that), and its Go. */
874
+ versions: [
875
+ { version: 'v0.1.16', gorm: 'v1.30.0', go: '1.22' },
876
+ { version: 'v0.1.14', gorm: 'v1.25.10', go: '1.22' },
877
+ { version: 'v0.1.12', gorm: 'v1.25.1', go: '1.22' },
878
+ ],
879
+ note: `For gorm, run go get ${GO_GORM} at the version for the gorm of your go.mod: v0.1.16 for gorm v1.30.0 or later, v0.1.14 for gorm v1.25.10 or later, and v0.1.12 for gorm v1.25.1 or later. A newer plugin version also raises the gorm version in go.mod. After gorm.Open, call db.Use(tracing.NewPlugin(tracing.WithoutMetrics())). Import ${GO_GORM}/tracing. Each query then gets its own span.`,
880
+ /** The setup tool adds this when it cannot read the gorm version of go.mod. It fills in {gorm}. */
881
+ raises: 'This also raises gorm in go.mod to {gorm}.',
882
+ /** The setup tool says this instead of the go get line when the gorm of go.mod is older than every row. */
883
+ tooOld: 'This app uses gorm {gorm}. The gorm plugin needs gorm v1.25.1 or later, so this tool leaves it out. The other spans work without it. To get a span for each query, raise gorm to v1.25.1 or later first. Then run npx @uknows/setup again.',
884
+ },
885
+ redis: {
886
+ module: GO_REDIS,
887
+ /**
888
+ * The redisotel/v9 versions on proxy.golang.org, oldest first. redisotel v9.X needs go-redis v9.X, and some
889
+ * go-redis releases have no redisotel tag. The setup tool takes the newest one at or below the go-redis of go.mod.
890
+ */
891
+ versions: ['v9.0.0', 'v9.0.1', 'v9.0.2', 'v9.0.4', 'v9.0.5', 'v9.5.3', 'v9.5.5', 'v9.6.2', 'v9.6.3', 'v9.7.0', 'v9.7.1', 'v9.7.2', 'v9.7.3',
892
+ 'v9.8.0', 'v9.9.0', 'v9.10.0', 'v9.11.0', 'v9.12.0', 'v9.12.1', 'v9.13.0', 'v9.14.0', 'v9.14.1', 'v9.15.1', 'v9.16.0', 'v9.17.0', 'v9.17.1',
893
+ 'v9.17.2', 'v9.17.3', 'v9.18.0', 'v9.19.0', 'v9.20.0', 'v9.20.1', 'v9.21.0', 'v9.22.0', 'v9.23.0'],
894
+ note: `For go-redis v9, run go get ${GO_REDIS} at the go-redis version of your go.mod, as in go get ${GO_REDIS}@${GO_REDIS_VERSION}. A newer redisotel also raises the go-redis version in go.mod. For example, go-redis v9.18.0 sends an extra command when it connects, and Redis 7 answers it with an error, so each new connection shows an error span. After redis.NewClient, call redisotel.InstrumentTracing(rdb). Each Redis command then gets its own span.`,
895
+ },
896
+ }
897
+
898
+ /** The Echo v4 code for Go below 1.25. */
899
+ export const GO_ECHO_OLDER = `For Echo v4 with Go below ${GO_ECHO.go}, use otelecho instead of echo-otel. echo-otel needs Go ${GO_ECHO.go}. Import ${GO_ECHO.older}. Then use e.Use(otelecho.Middleware(os.Getenv("OTEL_SERVICE_NAME"))).`
900
+
901
+ /** The step for an app that builds with Go 1.23, 1.24 or 1.25. The setup tool prints the lines for the Go of the app. */
902
+ export const GO_OLDER = {
903
+ title: 'Your app builds with Go 1.23, 1.24 or 1.25?',
904
+ summary: 'The newest OpenTelemetry Go modules need Go 1.26. Use the versions for your Go instead. Then go get does not raise the go line of go.mod, and the build works with the Go of your image.',
905
+ steps: [
906
+ 'Use the go get lines for the Go version of your build image.',
907
+ 'The code in otel.go and main.go stays the same.',
908
+ GO_ECHO_OLDER,
909
+ 'For gorm and go-redis, add the versions that fit your go.mod, as the notes for gorm and go-redis say.',
910
+ ],
911
+ echo: GO_ECHO_OLDER,
912
+ /** What the setup tool says in its plan. It fills in {version}. */
913
+ plan: 'This app builds with Go {version}. The newest OpenTelemetry Go modules need Go 1.26, so the go get lines name the versions for Go {version}.',
914
+ }
915
+
916
+ const GO_OLDER_OPTION = {
917
+ title: GO_OLDER.title,
918
+ summary: GO_OLDER.summary,
919
+ steps: GO_OLDER.steps,
920
+ snippets: GO_RELEASES.map((release) => ({
921
+ caption: `Go ${release.go}`,
922
+ language: 'bash',
923
+ code: [goInstall(release), '# go-redis v9 only: redisotel at the go-redis version of go.mod', `go get ${GO_REDIS}@${GO_REDIS_VERSION}`].join('\n'),
924
+ })),
925
+ }
926
+
927
+ export const RUBY_INITIALIZER = `# UKNOWS monitoring. The OTEL_ settings configure it.
928
+ unless ENV["OTEL_SDK_DISABLED"] == "true"
929
+ require "opentelemetry/sdk"
930
+ require "opentelemetry/exporter/otlp"
931
+ require "opentelemetry/instrumentation/all"
932
+
933
+ OpenTelemetry::SDK.configure do |c|
934
+ c.use_all
935
+ end
936
+ end`
937
+
938
+ /** Adds UKNOWS beside the exporters that a Collector has now. otlphttp adds /v1/traces to the endpoint. */
939
+ export const COLLECTOR_CONFIG = [
940
+ 'exporters:',
941
+ ' otlphttp/uknows:',
942
+ ` endpoint: ${INGEST_ENDPOINT}`,
943
+ ' headers:',
944
+ ' x-uknows-key: ${env:UKNOWS_KEY}',
945
+ '',
946
+ 'service:',
947
+ ' pipelines:',
948
+ ' traces:',
949
+ ' # Keep your exporters. otlp/current stands for the one you have now.',
950
+ ' exporters: [otlp/current, otlphttp/uknows]',
951
+ ].join('\n')
952
+
953
+ /** UKNOWS counts the spans it gets. It does not scale them up by a sample rate. */
954
+ export const SAMPLING_NOTE = 'Keep sampling off. UKNOWS counts the requests that it gets. With OTEL_TRACES_SAMPLER=traceidratio and OTEL_TRACES_SAMPLER_ARG=0.1, UKNOWS shows 1 in 10 of your requests. Remove both settings, or set OTEL_TRACES_SAMPLER=parentbased_always_on.'
955
+
956
+ /** Every OTLP exporter in the app reads OTEL_EXPORTER_OTLP_HEADERS, and Phoenix falls back to it. */
957
+ export const OTHER_EXPORTERS_NOTE = 'Every OTLP exporter in your app reads OTEL_EXPORTER_OTLP_HEADERS, so it gets your UKNOWS key. If your app also sends traces to Phoenix, set PHOENIX_COLLECTOR_ENDPOINT and PHOENIX_API_KEY for it. Then Phoenix gets its own address and key.'
958
+
959
+ /**
960
+ * What the setup tool says when the app uses Phoenix and the repo does not give Phoenix its own address. Phoenix
961
+ * register() falls back to OTEL_EXPORTER_OTLP_ENDPOINT. Seen on 18 Oct 2026 with arize-phoenix-otel 0.17.2: it
962
+ * then sent to the UKNOWS host on port 4317, and each span end waited about 20 seconds. The page does not show this.
963
+ */
964
+ export const PHOENIX_STEP = {
965
+ title: 'Give Phoenix its own address',
966
+ lines: [
967
+ 'Phoenix sends its traces to OTEL_EXPORTER_OTLP_ENDPOINT when PHOENIX_COLLECTOR_ENDPOINT is not set. After this setup, that is the UKNOWS address, which refuses them, and your app can wait for each span.',
968
+ 'Set PHOENIX_COLLECTOR_ENDPOINT to the address of your Phoenix where the app runs, and set PHOENIX_API_KEY for it. Or pass the address to register() in your code.',
969
+ ],
970
+ }
971
+
972
+ /**
973
+ * Phoenix register() with no headers and no PHOENIX_API_KEY makes its exporter read the OTLP headers settings, which
974
+ * hold the UKNOWS key after this setup. Seen in the bench review of 18 Oct 2026. The page does not show this.
975
+ */
976
+ export const PHOENIX_KEY_STEP = {
977
+ title: 'Give Phoenix its own key',
978
+ lines: [
979
+ 'When the app gives Phoenix no key, Phoenix sends its traces with the headers in OTEL_EXPORTER_OTLP_TRACES_HEADERS or OTEL_EXPORTER_OTLP_HEADERS. After this setup, that is your UKNOWS key, so your Phoenix server would get it.',
980
+ 'Set PHOENIX_API_KEY where the app runs, or pass headers to register() in your code.',
981
+ ],
982
+ }
983
+
984
+ /** For an app that already uses OpenTelemetry: send only its traces to UKNOWS. */
985
+ export const TRACES_ONLY_NOTE = `For Node.js, Python and Java, set OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=${INGEST_ENDPOINT}/v1/traces and OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf instead of OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_PROTOCOL. Their OpenTelemetry SDKs can send metrics and logs over OTLP by default, and UKNOWS refuses metrics. Then your metrics and logs keep going where they go now. Set your key as OTEL_EXPORTER_OTLP_TRACES_HEADERS instead of OTEL_EXPORTER_OTLP_HEADERS too, so only the traces exporter sends it. Do the same in other languages when your app also sends metrics or logs over OTLP.`
986
+
987
+ /**
988
+ * What the setup tool says when it uses the traces-only settings. It fills in {where} and {lang}. The page does
989
+ * not show these.
990
+ */
991
+ export const TRACES_ONLY_PLAN = {
992
+ code: '{where} also sends metrics or logs over OTLP.',
993
+ sdk: 'The OpenTelemetry SDK for {lang} can send metrics and logs over OTLP by default.',
994
+ set: 'This tool sets OTEL_EXPORTER_OTLP_TRACES_ENDPOINT and OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, so only traces go to UKNOWS. Your metrics and logs keep their current address.',
995
+ /** The key name for these apps, and the advice that goes with it. */
996
+ key: 'Your key goes in OTEL_EXPORTER_OTLP_TRACES_HEADERS, not in OTEL_EXPORTER_OTLP_HEADERS, so only the traces exporter sends it. The key steps below use that name.',
997
+ keyTitle: 'Keep your UKNOWS key on the traces exporter',
998
+ keyCheck: 'If your code gives the OTLP exporter of metrics or logs its own headers, check that they never hold the UKNOWS key.',
999
+ /** For a .NET, Go, PHP or Ruby app with no metrics or logs over OTLP in its code. It fills in {endpoint}. */
1000
+ none: 'If your app also sends metrics or logs over OTLP, set OTEL_EXPORTER_OTLP_TRACES_ENDPOINT={endpoint}/v1/traces and OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf instead of OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_PROTOCOL, and set your key as OTEL_EXPORTER_OTLP_TRACES_HEADERS. UKNOWS refuses metrics.',
1001
+ }
1002
+
1003
+ /**
1004
+ * .NET AddOtlpExporter() reads only the general OTLP settings, not the OTEL_EXPORTER_OTLP_TRACES_ ones (only
1005
+ * UseOtlpExporter() reads those). With the traces-only settings, the traces exporter takes the address in code.
1006
+ */
1007
+ export const DOTNET_TRACES_ONLY = {
1008
+ note: 'For .NET, AddOtlpExporter() does not read the OTEL_EXPORTER_OTLP_TRACES_ settings. UseOtlpExporter() reads them. If your app uses AddOtlpExporter(), pass the traces address and the key on in the code of the traces exporter.',
1009
+ code: `using OpenTelemetry.Exporter;
1010
+
1011
+ // In WithTracing(...):
1012
+ .AddOtlpExporter(otlp =>
1013
+ {
1014
+ // AddOtlpExporter does not read the OTEL_EXPORTER_OTLP_TRACES_ settings, so pass the address and the key on.
1015
+ if (Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") is { Length: > 0 } endpoint)
1016
+ {
1017
+ otlp.Endpoint = new Uri(endpoint);
1018
+ otlp.Protocol = OtlpExportProtocol.HttpProtobuf;
1019
+ otlp.Headers = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_TRACES_HEADERS") ?? string.Empty;
1020
+ }
1021
+ })`,
1022
+ }
1023
+
1024
+ /**
1025
+ * The block for configure_azure_monitor at module level: 2 blank lines before the call, as PEP 8 wants.
1026
+ * The ${'\n'} line keeps them, because tools/sync-contract.mjs folds 3 new lines into 2. A nested
1027
+ * function, so it never shadows the os or trace names of the app. Inside a function, indent it to the call.
1028
+ */
1029
+ export const AZURE_PYTHON_BLOCK = `# Also send these traces to UKNOWS.
1030
+ def _send_traces_to_uknows():
1031
+ import os
1032
+
1033
+ if not os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"):
1034
+ return
1035
+ from opentelemetry import trace
1036
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
1037
+ OTLPSpanExporter,
1038
+ )
1039
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
1040
+
1041
+ provider = trace.get_tracer_provider()
1042
+ if hasattr(provider, "add_span_processor"):
1043
+ exporter = OTLPSpanExporter()
1044
+ provider.add_span_processor(BatchSpanProcessor(exporter))
1045
+ ${'\n'}
1046
+ _send_traces_to_uknows()`
1047
+
1048
+ export const AZURE_NODE_CODE = `// Also send these traces to UKNOWS. Keep your other options.
1049
+ const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-base')
1050
+ const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-proto')
1051
+
1052
+ useAzureMonitor({
1053
+ // ...your options, such as azureMonitorExporterOptions
1054
+ spanProcessors: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ? [new BatchSpanProcessor(new OTLPTraceExporter())] : [],
1055
+ })`
1056
+
1057
+ /**
1058
+ * Apps that use Azure Monitor (App Insights) keep it. UKNOWS adds 1 OTLP
1059
+ * exporter to the same tracer provider, so nothing is traced twice. The setup
1060
+ * tool inserts this code and these settings word for word.
1061
+ */
1062
+ export const AZURE_MONITOR = {
1063
+ /** Setup only, after the .NET note of the otel stack: the pages name UKNOWS only. */
1064
+ otelNote: 'If your app uses Azure Monitor, keep it. Setup adds UKNOWS beside it.',
1065
+ title: 'Your app uses Azure Monitor? Keep it and add UKNOWS',
1066
+ summary: 'Your App Insights setup stays as it is. UKNOWS adds 1 exporter to the same traces, so nothing is traced twice. Add 1 package and a few lines of code for your language, and 3 settings.',
1067
+ steps: [
1068
+ 'Add the OTLP exporter package for your language. Use the OpenTelemetry version that your app has now.',
1069
+ 'Add the code for your language after the line that starts Azure Monitor.',
1070
+ 'Add the 3 settings. Set OTEL_SERVICE_NAME only if your app does not set it now.',
1071
+ 'Keep APPLICATIONINSIGHTS_CONNECTION_STRING. Do not add opentelemetry-instrument, NODE_OPTIONS or instrument.sh.',
1072
+ ],
1073
+ python: {
1074
+ /** The call that starts Azure Monitor. The block goes after its statement. */
1075
+ call: 'configure_azure_monitor(',
1076
+ package: 'opentelemetry-exporter-otlp-proto-http',
1077
+ code: AZURE_PYTHON_BLOCK,
1078
+ /** The web frameworks whose requests azure-monitor-opentelemetry traces. */
1079
+ traced: ['django', 'fastapi', 'flask'],
1080
+ /**
1081
+ * Web frameworks that it does not trace, the requirement names that show them, and the OpenTelemetry
1082
+ * instrumentation to add. FastMCP and the mcp SDK serve HTTP with Starlette.
1083
+ */
1084
+ servers: [
1085
+ { label: 'Starlette', names: ['starlette', 'fastmcp', 'mcp'], package: 'opentelemetry-instrumentation-starlette', module: 'opentelemetry.instrumentation.starlette', instrumentor: 'StarletteInstrumentor' },
1086
+ { label: 'aiohttp', names: ['aiohttp'], package: 'opentelemetry-instrumentation-aiohttp-server', module: 'opentelemetry.instrumentation.aiohttp_server', instrumentor: 'AioHttpServerInstrumentor' },
1087
+ { label: 'Tornado', names: ['tornado'], package: 'opentelemetry-instrumentation-tornado', module: 'opentelemetry.instrumentation.tornado', instrumentor: 'TornadoInstrumentor' },
1088
+ { label: 'Falcon', names: ['falcon'], package: 'opentelemetry-instrumentation-falcon', module: 'opentelemetry.instrumentation.falcon', instrumentor: 'FalconInstrumentor' },
1089
+ { label: 'Pyramid', names: ['pyramid'], package: 'opentelemetry-instrumentation-pyramid', module: 'opentelemetry.instrumentation.pyramid', instrumentor: 'PyramidInstrumentor' },
1090
+ ],
1091
+ /** The required step for those apps. The setup tool fills in each {name}. */
1092
+ untraced: {
1093
+ title: 'Trace the requests of your {label} app',
1094
+ found: '{file} line {line} lists {name}.',
1095
+ builtOn: '{name} serves HTTP with {label}.',
1096
+ why: 'Azure Monitor traces the requests of Django, FastAPI and Flask apps only. Without the step below, App Insights and UKNOWS get no spans for the requests of this app.',
1097
+ add: 'Add {package} to {manifest}. Use the version of your other opentelemetry-instrumentation packages{version}.',
1098
+ call: 'Put the code below at the top of the file that starts the app, before its other imports. instrument() must run before the app imports {name}, or it does not trace the requests.',
1099
+ caption: 'Python: at the top of the file that starts the app',
1100
+ },
1101
+ },
1102
+ dotnet: {
1103
+ call: 'UseAzureMonitor(',
1104
+ package: 'OpenTelemetry.Exporter.OpenTelemetryProtocol',
1105
+ using: 'using OpenTelemetry.Trace;',
1106
+ /** The 1 line goes after this line. */
1107
+ after: 'var builder = WebApplication.CreateBuilder(args);',
1108
+ /**
1109
+ * The overload without the service provider. It runs when the services are added, so AddOtlpExporter can add
1110
+ * its own services. With (sp, tracing) the app stops at start: services cannot be added after the provider is built.
1111
+ * Only with OTEL_EXPORTER_OTLP_ENDPOINT, as the Python and Node.js code: without it, AddOtlpExporter would send to
1112
+ * localhost, for example in tests. Removing that setting turns UKNOWS off and keeps App Insights.
1113
+ */
1114
+ line: 'if (System.Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") is { Length: > 0 }) builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter());',
1115
+ },
1116
+ node: {
1117
+ call: 'useAzureMonitor(',
1118
+ packages: ['@opentelemetry/exporter-trace-otlp-proto', '@opentelemetry/sdk-trace-base'],
1119
+ code: AZURE_NODE_CODE,
1120
+ /** For apps that start Azure Monitor with the applicationinsights 3.x package. */
1121
+ shim: 'The applicationinsights 3.x package runs on @azure/monitor-opentelemetry. To add the UKNOWS processor, start Azure Monitor with useAzureMonitor from @azure/monitor-opentelemetry, as in the code.',
1122
+ },
1123
+ /** The 3 settings. OTEL_SERVICE_NAME only when the app does not set it. */
1124
+ settings: SHARED_SETTINGS,
1125
+ roleName: 'OTEL_SERVICE_NAME also sets the Cloud Role Name in App Insights. Alerts and the application map use that name. Set it only when your app does not set it now.',
1126
+ sampling: 'Azure Monitor samples traces, by default 5 traces a second. UKNOWS gets the same traces, so it shows fewer requests for a busy app. A sampling_ratio or traces_per_second in your code, or OTEL_TRACES_SAMPLER, sets other sampling. To send every trace, set OTEL_TRACES_SAMPLER=parentbased_always_on. Then App Insights gets every trace too, which can cost more.',
1127
+ notes: [
1128
+ 'Your metrics and logs keep going to App Insights. These settings do not turn them off.',
1129
+ 'To turn UKNOWS off and keep App Insights, remove OTEL_EXPORTER_OTLP_ENDPOINT and restart the app. The code above sends to UKNOWS only when that setting is there. Do not use OTEL_SDK_DISABLED=true: App Insights uses the same OpenTelemetry, so it stops too.',
1130
+ 'For Python, Azure Monitor traces the requests of Django, FastAPI and Flask apps only. For Starlette, FastMCP, aiohttp, Tornado, Falcon or Pyramid, add the OpenTelemetry instrumentation of that framework. Call its instrument() at the top of the file that starts the app, before the app imports the framework.',
1131
+ 'For Node.js, the code shows the spanProcessors option. If your code already passes spanProcessors, add the UKNOWS processor to that list.',
1132
+ 'For .NET, the line needs the OpenTelemetry.Trace namespace. Put the using line with the other using lines.',
1133
+ ],
1134
+ /**
1135
+ * What the setup tool says in its plan for these apps. It fills in {name}, {file}, {line} and {value}.
1136
+ * The page does not show these.
1137
+ */
1138
+ plan: {
1139
+ /** 1 sentence on this mode, in the plan of each Azure Monitor app. */
1140
+ mode: 'This app uses Azure Monitor (App Insights). This tool keeps it and adds 1 exporter beside it, so the same traces also go to UKNOWS. App Insights keeps getting the traces, metrics and logs that it gets now.',
1141
+ roleName: {
1142
+ title: 'App Insights Cloud Role Name',
1143
+ set: [
1144
+ 'This tool sets OTEL_SERVICE_NAME={name}. Azure Monitor uses that name as the Cloud Role Name of this app, so App Insights shows the app as {name}.',
1145
+ 'Alerts, workbooks and the application map that use the current role name stop matching when the name changes. To see the current name, run the query below in Logs in your App Insights resource.',
1146
+ 'To keep the current name, run npx @uknows/setup again with --service-name and that name. It takes lowercase letters, digits and -.',
1147
+ ],
1148
+ query: 'requests | summarize count() by cloud_RoleName',
1149
+ kept: '{where} sets the service name already. This tool keeps it, so the Cloud Role Name in App Insights stays the same.',
1150
+ },
1151
+ /** For the Keep line of AZURE_MONITOR.steps, per stack. The tool fills in {start}. */
1152
+ keep: 'Keep APPLICATIONINSIGHTS_CONNECTION_STRING. Do not add {start}.',
1153
+ start: { python: 'opentelemetry-instrument', dotnet: 'instrument.sh', node: 'NODE_OPTIONS' },
1154
+ samplingDefault: 'This app uses the default sampling of Azure Monitor: 5 traces a second.',
1155
+ samplingCode: '{file} line {line} sets the sampling of Azure Monitor: {value}.',
1156
+ samplingEnv: '{file} sets OTEL_TRACES_SAMPLER to {value}.',
1157
+ /** The rest of the sampling advice, per stack. The tool fills in {options}. */
1158
+ sampling: [
1159
+ 'UKNOWS gets the same sampled traces, so it shows fewer requests than a busy app gets.',
1160
+ '{options} in your code, or OTEL_TRACES_SAMPLER, sets other sampling.',
1161
+ 'To send every trace, set OTEL_TRACES_SAMPLER=parentbased_always_on. Then App Insights gets every trace too, which can cost more.',
1162
+ ],
1163
+ samplingOptions: { python: 'A sampling_ratio or traces_per_second', dotnet: 'A SamplingRatio or TracesPerSecond option', node: 'A samplingRatio or tracesPerSecond option' },
1164
+ /**
1165
+ * How the exporter version follows the SDK. A uv or Poetry project gets a lower limit, so its resolver keeps the
1166
+ * exporter at the SDK version, also after a later Azure Monitor upgrade. A requirements file gets the exact pin
1167
+ * of its other lines. The tool fills in {file}, {package}, {version} and {tool}.
1168
+ */
1169
+ floor: '{file} gets {package}>={version}, a lower limit, so {tool} keeps it at the opentelemetry-sdk version of the app, also when you upgrade Azure Monitor later.',
1170
+ pin: '{file} gets {package}=={version}, the opentelemetry-sdk version of the app. When you upgrade Azure Monitor later, raise {package} to the new opentelemetry-sdk version too. They have the same version.',
1171
+ /** The plan sentence for the Python code. The tool fills in {count}, {call}, {file} and {line}. */
1172
+ pythonCode: 'Also send the Azure Monitor traces to UKNOWS: add {count} lines of code after the {call}...) call in {file} line {line}. The diff below shows them.',
1173
+ browser: '{file} uses {value}. It runs in the browser, so the setup tool does not change it.',
1174
+ connectionString: '{file} sets APPLICATIONINSIGHTS_CONNECTION_STRING, but the app has no Azure Monitor package. The setup tool adds standard OpenTelemetry.',
1175
+ autoAttach: '{file} sets AZURE_MONITOR_AUTO_ATTACH. It attaches Azure Monitor only on Azure App Service, so the setup tool adds standard OpenTelemetry.',
1176
+ },
1177
+ }
1178
+
1179
+ export const STACKS = [
1180
+ {
1181
+ id: 'node',
1182
+ label: 'Node.js',
1183
+ summary: 'Add the OpenTelemetry Node.js packages. Load them when the app starts.',
1184
+ manifests: ['package.json'],
1185
+ frameworks: ['Express', 'Fastify', 'NestJS', 'Koa', 'Hapi'],
1186
+ install: 'npm install --save @opentelemetry/api @opentelemetry/auto-instrumentations-node',
1187
+ files: [],
1188
+ dockerfile: [
1189
+ '# Final stage, after your app files and node_modules',
1190
+ `ENV NODE_OPTIONS="--require ${NODE_REGISTER}"`,
1191
+ dockerEnv([NODE_DETECTORS]),
1192
+ 'CMD ["node", "server.js"]',
1193
+ ].join('\n'),
1194
+ start: `node --require ${NODE_REGISTER} server.js`,
1195
+ // --require= has no space. With a space, a shell that loads otel.env runs the package path as a command.
1196
+ platformEnv: [`NODE_OPTIONS=--require=${NODE_REGISTER}`],
1197
+ extraSettings: {
1198
+ lines: [NODE_DETECTORS],
1199
+ why: 'OpenTelemetry then asks no cloud metadata address when the app starts.',
1200
+ },
1201
+ options: [
1202
+ {
1203
+ title: 'Your app uses ES modules?',
1204
+ summary: 'Use these lines when package.json has "type": "module", or when your app starts from an .mjs file. With the --require line alone, UKNOWS gets each request without its route.',
1205
+ steps: [
1206
+ 'Add 1 more package: @opentelemetry/instrumentation.',
1207
+ 'Load OpenTelemetry with the 2 options below, in your Dockerfile, your start line or the NODE_OPTIONS setting on your platform.',
1208
+ 'Node prints an ExperimentalWarning about --experimental-loader when the app starts. You can ignore it.',
1209
+ ],
1210
+ snippets: [
1211
+ { caption: 'Terminal: 1 more package', language: 'bash', code: 'npm install --save @opentelemetry/instrumentation' },
1212
+ { caption: 'Dockerfile for ES modules', language: 'dockerfile', code: `ENV NODE_OPTIONS="${NODE_ESM_OPTIONS}"` },
1213
+ { caption: 'Start line for ES modules', language: 'bash', code: `node ${NODE_ESM_OPTIONS} server.mjs` },
1214
+ { caption: 'NODE_OPTIONS on your platform', language: 'text', code: NODE_ESM_OPTIONS },
1215
+ ],
1216
+ },
1217
+ {
1218
+ title: 'Stops cleanly on SIGTERM: add a start file',
1219
+ summary: 'OpenTelemetry listens for SIGTERM to send the last data. Because of that, Node no longer stops on SIGTERM by itself, and Docker, Kubernetes and systemd wait for their time limit and then kill the app. This start file sends the last data, then stops the app as Node does. An app with its own SIGTERM handler keeps it. The setup tool adds the same file.',
1220
+ steps: [
1221
+ `Add the file ${NODE_REGISTER_FILE.path} next to package.json.`,
1222
+ 'In the final stage of your Dockerfile, copy the file into the folder of your app. Load it with --require instead of the register line.',
1223
+ `Without Docker, start the app with node --require ./${NODE_REGISTER_FILE.path} server.js.`,
1224
+ `For ES modules, keep the loader option. Use --import=${NODE_REGISTER_PATH} instead of --import=${NODE_REGISTER}.`,
1225
+ 'If a package is missing, the app still starts. It prints the warning "OpenTelemetry did not start".',
1226
+ ],
1227
+ snippets: [
1228
+ { caption: NODE_REGISTER_FILE.path, language: NODE_REGISTER_FILE.language, code: NODE_REGISTER_FILE.content },
1229
+ {
1230
+ caption: 'Dockerfile, final stage',
1231
+ language: 'dockerfile',
1232
+ code: ['WORKDIR /app', `COPY ${NODE_REGISTER_FILE.path} ./`, `ENV NODE_OPTIONS="--require ${NODE_REGISTER_PATH}"`].join('\n'),
1233
+ },
1234
+ { caption: 'Start line', language: 'bash', code: `node --require ./${NODE_REGISTER_FILE.path} server.js` },
1235
+ { caption: 'NODE_OPTIONS on your platform', language: 'text', code: `--require=${NODE_REGISTER_PATH}` },
1236
+ ],
1237
+ },
1238
+ ],
1239
+ notes: [
1240
+ 'Replace server.js with the file that starts your server.',
1241
+ 'Install the packages as normal dependencies. Your production image must include them.',
1242
+ 'If your image already sets NODE_OPTIONS, add the --require option to that value.',
1243
+ 'If your app uses ES modules, use the steps in "Your app uses ES modules?". The --require line alone loses the route of each request.',
1244
+ 'If your repo uses yarn or pnpm, add the same packages with that tool.',
1245
+ // OpenTelemetry for Node.js sends the log records of pino and bunyan (instrumentation-pino, instrumentation-bunyan:
1246
+ // "log sending"). It does not read console.log, and winston needs @opentelemetry/winston-transport, which the UKNOWS
1247
+ // agent image does not have (checked on registry.uknows.my/uknows/agent-node:stable 1.1.1, 25 Oct 2026).
1248
+ 'UKNOWS gets the logs that your app writes with pino or bunyan, with the trace id of each request. Lines that your app writes with console.log stay in its own output only.',
1249
+ 'With the register line, Node no longer stops on SIGTERM by itself. To stop cleanly, use the start file in "Stops cleanly on SIGTERM".',
1250
+ `Keep ${NODE_DETECTORS} with the 5 settings. Without it, OpenTelemetry asks the metadata addresses of the clouds when the app starts, such as 169.254.169.254 on port 80, and logs a MetadataLookupWarning outside Google Cloud.`,
1251
+ ],
1252
+ },
1253
+ {
1254
+ id: 'python',
1255
+ label: 'Python',
1256
+ summary: 'Add the OpenTelemetry packages for Python and for the libraries that your app uses. Start the app with opentelemetry-instrument.',
1257
+ manifests: ['requirements.txt', 'pyproject.toml', 'Pipfile'],
1258
+ frameworks: ['Django', 'Flask', 'FastAPI', 'Starlette', 'Falcon', 'Tornado'],
1259
+ install: [
1260
+ '[ -z "$(tail -c1 requirements.txt)" ] || echo >> requirements.txt',
1261
+ 'pip install opentelemetry-distro opentelemetry-exporter-otlp-proto-http',
1262
+ "pip freeze | grep -E '^opentelemetry-(distro|exporter-otlp-proto-http)==' >> requirements.txt",
1263
+ 'opentelemetry-bootstrap -a requirements >> requirements.txt',
1264
+ 'pip install -r requirements.txt',
1265
+ ].join('\n'),
1266
+ files: [],
1267
+ dockerfile: [
1268
+ '# requirements.txt now lists the OpenTelemetry packages',
1269
+ 'RUN pip install --no-cache-dir -r requirements.txt',
1270
+ PYTHON_BRIDGE_COPY,
1271
+ dockerEnv([PYTHON_LOG_HANDLER_OFF, PYTHON_DETECTORS, PYTHON_BRIDGE_PATH]),
1272
+ 'CMD ["opentelemetry-instrument", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]',
1273
+ ].join('\n'),
1274
+ start: 'opentelemetry-instrument uvicorn main:app --host 0.0.0.0 --port 8000',
1275
+ startRequired: 'Python still needs the start line. The settings alone do not start monitoring. Start the app with opentelemetry-instrument, also under systemd, Docker and SAE.',
1276
+ extraSettings: {
1277
+ lines: [PYTHON_LOG_HANDLER_OFF, PYTHON_DETECTORS],
1278
+ why: 'The OpenTelemetry logging package then stays off the root logger, so the app prints what it printed before, and the app sends its host name.',
1279
+ },
1280
+ options: [
1281
+ {
1282
+ title: 'Send the logs: add the UKNOWS log bridge',
1283
+ summary: `The OpenTelemetry logging package sends logs from the root logger, and that changes what your app prints: warnings of a logger with no handler, logging.warning() before any setup and the error lines of Flask stop showing. The bridge sends the same log records and changes nothing in the app output. opentelemetry-instrument loads it from PYTHONPATH. The setup tool adds the same files.`,
1284
+ steps: [
1285
+ `Add the folder ${PYTHON_BRIDGE_DIR} next to requirements.txt or pyproject.toml, with the 3 files below.`,
1286
+ `In the final stage of your Dockerfile, copy the folder to ${PYTHON_BRIDGE_IMAGE} and add it to PYTHONPATH, with ${PYTHON_LOG_HANDLER_OFF}.`,
1287
+ `Without Docker, set PYTHONPATH to the full path of the folder, and keep ${PYTHON_LOG_HANDLER_OFF}.`,
1288
+ 'Without the bridge, the app sends no logs. Its output stays as it is.',
1289
+ ],
1290
+ snippets: [
1291
+ ...PYTHON_LOG_BRIDGE.files.map((file) => ({ caption: `${PYTHON_BRIDGE_DIR}/${file.path}`, language: file.language, code: file.content })),
1292
+ { caption: 'Dockerfile, final stage', language: 'dockerfile', code: [PYTHON_BRIDGE_COPY, dockerEnv([PYTHON_LOG_HANDLER_OFF, PYTHON_BRIDGE_PATH], [])].join('\n') },
1293
+ ],
1294
+ },
1295
+ ],
1296
+ notes: [
1297
+ 'Use Python 3.10 or later. The OpenTelemetry packages need it.',
1298
+ 'Run the install commands in the virtual environment of the app. Install the app packages first, because opentelemetry-bootstrap reads them.',
1299
+ `The install line adds opentelemetry-instrumentation-logging. On its own it puts an OpenTelemetry handler on the root logger, whatever OTEL_LOGS_EXPORTER says, and then the warnings and errors of loggers that have no handler do not show in the app output. ${PYTHON_LOG_HANDLER_OFF} keeps it off the root logger. The UKNOWS log bridge in "Send the logs" sends the logs instead.`,
1300
+ `Keep ${PYTHON_DETECTORS} with the 5 settings. Python then sends the host name of the app, as Node.js does.`,
1301
+ 'Put opentelemetry-instrument in front of the command that starts your app now.',
1302
+ 'For Django, set DJANGO_SETTINGS_MODULE before the app starts.',
1303
+ 'opentelemetry-instrument works with 1 worker. For Gunicorn or uvicorn with more than 1 worker, follow the pre-fork server steps in the OpenTelemetry Python docs.',
1304
+ 'Remove the --reload option in production.',
1305
+ 'If your repo uses uv or Poetry, add the packages to the project that your image installs, then run uv lock or poetry lock. In a uv workspace, that is the member that uv sync --package names. Commit the lock file.',
1306
+ 'If your app needs protobuf below version 5, use opentelemetry-distro==0.48b0 and opentelemetry-exporter-otlp-proto-http==1.27.0. They are the last releases that work with protobuf 4. Later releases need protobuf 5 or later.',
1307
+ 'A Procfile starts the app only on a platform that reads it. If yours does, put opentelemetry-instrument before the command on the web line yourself.',
1308
+ ],
1309
+ },
1310
+ {
1311
+ id: 'dotnet',
1312
+ label: '.NET',
1313
+ summary: 'Add the OpenTelemetry.AutoInstrumentation package. Start the app with instrument.sh.',
1314
+ manifests: ['*.csproj', '*.sln'],
1315
+ frameworks: ['ASP.NET Core', 'Minimal APIs', 'gRPC for ASP.NET Core'],
1316
+ install: [
1317
+ 'dotnet add package OpenTelemetry.AutoInstrumentation',
1318
+ 'dotnet publish -c Release -r linux-x64 -o ./publish',
1319
+ ].join('\n'),
1320
+ files: [],
1321
+ dockerfile: [
1322
+ '# Build stage',
1323
+ 'RUN dotnet publish -c Release -r linux-x64 -o /app/publish',
1324
+ '',
1325
+ '# Final stage',
1326
+ 'WORKDIR /app',
1327
+ 'COPY --from=build /app/publish .',
1328
+ dockerEnv([DOTNET_LOG_DIRECTORY, DOTNET_LOGS_MESSAGE]),
1329
+ 'ENTRYPOINT ["sh", "./instrument.sh", "dotnet", "OrdersApi.dll"]',
1330
+ ].join('\n'),
1331
+ start: 'sh ./publish/instrument.sh dotnet ./publish/OrdersApi.dll',
1332
+ startRequired: '.NET still needs the start line. The settings alone do not start monitoring. Start the app with instrument.sh, also under systemd, Docker and SAE.',
1333
+ extraSettings: {
1334
+ lines: [DOTNET_LOG_DIRECTORY, DOTNET_LOGS_MESSAGE],
1335
+ why: 'OpenTelemetry writes its log files to this folder, so the app must be able to write to it. The formatted message puts the values of each log line in its text.',
1336
+ },
1337
+ notes: [
1338
+ 'Replace OrdersApi.dll with the dll of your app.',
1339
+ 'Use .NET 8 or later.',
1340
+ 'A runtime identifier in the build is optional. With one, such as -r linux-x64, linux-arm64 or linux-musl-x64 for Alpine images, the publish folder holds the native files of that platform only. Without one, it holds them for every platform, and instrument.sh picks the one it runs on.',
1341
+ 'The package puts instrument.sh in the publish folder. The script starts your app with monitoring on.',
1342
+ `Keep ${DOTNET_LOG_DIRECTORY} with the 5 settings. Without a folder that the app can write, the app stops when it starts. The default folder, /var/log/opentelemetry, is not writable on a Mac or in an image that runs as a user that is not root.`,
1343
+ `Keep ${DOTNET_LOGS_MESSAGE} too. Without it, a log line reaches UKNOWS as its message template, such as "Served order {Id}", without the values.`,
1344
+ 'If your app uses StackExchange.Redis, also when a package such as NRedisStack brings it in, the build stops and names 1 more package and its version. Add that package. For OpenTelemetry.AutoInstrumentation 1.17.0, run dotnet add package OpenTelemetry.Instrumentation.StackExchangeRedis --version 1.19.0-beta.1.',
1345
+ 'If your repo has packages.lock.json, run dotnet restore --force-evaluate after you add the packages. Use the same -r runtime identifier as your build, for example -r linux-x64. Run it also for each project that references the app and has its own packages.lock.json, such as a test project, with the -r of that lock. Then commit every packages.lock.json that changed.',
1346
+ 'If your Dockerfile restores the packages with --network=none, add the new packages to the package source that it uses first.',
1347
+ 'To turn monitoring off for .NET, set OTEL_DOTNET_AUTO_TRACES_ENABLED=false.',
1348
+ ],
1349
+ },
1350
+ {
1351
+ id: 'java',
1352
+ label: 'Java',
1353
+ summary: `Add the OpenTelemetry Java agent ${JAVA_AGENT.version}. Start the JVM with -javaagent.`,
1354
+ manifests: ['pom.xml', 'build.gradle', 'build.gradle.kts'],
1355
+ frameworks: ['Spring Boot', 'Spring WebFlux', 'Quarkus', 'Micronaut', 'Jakarta EE', 'Dropwizard'],
1356
+ install: [
1357
+ `curl -fsSL -o ${JAVA_AGENT.file} ${JAVA_AGENT.url}`,
1358
+ `echo "${JAVA_AGENT.sha256} ${JAVA_AGENT.file}" | sha256sum -c -`,
1359
+ ].join('\n'),
1360
+ files: [],
1361
+ dockerfile: [
1362
+ '# Final stage, after your last RUN line',
1363
+ JAVA_COPY_LINE,
1364
+ `ENV JAVA_TOOL_OPTIONS="-javaagent:/otel/${JAVA_AGENT.file}"`,
1365
+ SETTINGS_DOCKERFILE,
1366
+ 'CMD ["java", "-jar", "app.jar"]',
1367
+ ].join('\n'),
1368
+ start: `java -javaagent:./${JAVA_AGENT.file} -jar app.jar`,
1369
+ platformEnv: [`JAVA_TOOL_OPTIONS=-javaagent:/path/to/${JAVA_AGENT.file}`],
1370
+ options: [
1371
+ {
1372
+ title: 'Your network blocks ghcr.io?',
1373
+ summary: 'Download the agent from GitHub in a build stage instead. The stage checks the SHA-256 checksum of the agent.',
1374
+ steps: [
1375
+ 'Add the build stage below before the final stage of your Dockerfile.',
1376
+ 'In the final stage, use its COPY line instead of the COPY line from ghcr.io.',
1377
+ 'Keep the ENV JAVA_TOOL_OPTIONS line.',
1378
+ ],
1379
+ snippets: [{ caption: 'Dockerfile', language: 'dockerfile', code: JAVA_AGENT_STAGE }],
1380
+ },
1381
+ ],
1382
+ notes: [
1383
+ `The agent is version ${JAVA_AGENT.version}. Check its SHA-256 checksum before you use it.`,
1384
+ 'In the JAVA_TOOL_OPTIONS line, replace /path/to with the folder of the agent.',
1385
+ 'On macOS, use shasum -a 256 -c - instead of sha256sum -c -.',
1386
+ 'Replace app.jar with your built app. Put -javaagent before -jar. Keep your other JVM options.',
1387
+ 'If your image already sets JAVA_TOOL_OPTIONS, add the -javaagent option to that value.',
1388
+ `The COPY line takes the same agent from the OpenTelemetry image ${JAVA_AGENT.image}. The digest pins the exact image. Docker keeps it in its cache, so a build does not download the agent each time.`,
1389
+ 'Put the COPY and ENV lines after the last RUN line of the final stage, so build steps do not load the agent.',
1390
+ 'Use Java 8 or later. Use a JVM build. Native images need the OpenTelemetry starter for your framework.',
1391
+ 'To turn the agent off completely, set OTEL_JAVAAGENT_ENABLED=false.',
1392
+ ],
1393
+ },
1394
+ {
1395
+ id: 'go',
1396
+ label: 'Go',
1397
+ summary: 'Add the OpenTelemetry Go SDK and the middleware for your router. Add a short init file.',
1398
+ manifests: ['go.mod'],
1399
+ frameworks: ['net/http', 'Gin', 'Echo', 'chi'],
1400
+ install: goInstall(),
1401
+ files: [
1402
+ { path: 'otel.go', language: 'go', summary: 'Add this file to your main package.', content: GO_INIT },
1403
+ { path: 'main.go', language: 'go', summary: 'Change main like this. Keep only the middleware for your router.', content: GO_MAIN },
1404
+ ],
1405
+ dockerfile: [
1406
+ '# Build stage: no change. otel.go builds into your binary.',
1407
+ '# Final stage',
1408
+ SETTINGS_DOCKERFILE,
1409
+ ].join('\n'),
1410
+ start: `go build -o ${EXAMPLE_SERVICE} . && ./${EXAMPLE_SERVICE}`,
1411
+ options: [GO_OLDER_OPTION],
1412
+ notes: [
1413
+ `Use Go 1.26 or later. The current OpenTelemetry Go modules need it. With an older Go, the build stops with a Go version error. For Go 1.23, 1.24 or 1.25, use the versions in "${GO_OLDER.title}".`,
1414
+ 'Call setupOTel at the start of main. The main.go code stops the server on Ctrl+C or SIGTERM. Then it calls the shutdown function, which sends the last data.',
1415
+ 'If your app already stops the server on SIGTERM, keep your code. Call the shutdown function after the server stops and before the app exits.',
1416
+ 'Use the middleware for your router. For net/http, wrap the ServeMux with withRoute, then with otelhttp.NewHandler. withRoute adds the route of each request. Gin and Echo add the route on their own.',
1417
+ 'For chi, add the route middleware before your routes. chi stops with an error when a middleware comes after a route.',
1418
+ 'For another router, call setRoute with the route of the request after the router picks it.',
1419
+ 'For outgoing HTTP calls, use otelhttp.NewTransport in your HTTP client. Pass the request context to each call.',
1420
+ 'For Echo v5, use github.com/labstack/echo-otel/v5.',
1421
+ GO_LIBRARIES.gorm.note,
1422
+ GO_LIBRARIES.redis.note,
1423
+ 'Run go mod tidy. Then run your tests and build.',
1424
+ 'These steps add code at startup only. Your business logic stays the same.',
1425
+ ],
1426
+ },
1427
+ {
1428
+ id: 'php',
1429
+ label: 'PHP',
1430
+ summary: 'Add the OpenTelemetry extension and the Composer packages. Turn on autoload with OTEL_PHP_AUTOLOAD_ENABLED.',
1431
+ manifests: ['composer.json'],
1432
+ frameworks: ['Laravel', 'Symfony', 'Slim'],
1433
+ install: [
1434
+ 'pecl install opentelemetry',
1435
+ 'composer config allow-plugins.php-http/discovery false',
1436
+ 'composer require --ignore-platform-req=ext-opentelemetry open-telemetry/sdk open-telemetry/exporter-otlp open-telemetry/opentelemetry-auto-laravel',
1437
+ ].join('\n'),
1438
+ files: [
1439
+ { path: 'php.ini', language: 'ini', summary: 'Turn on the extension. Check it with php -m | grep opentelemetry.', content: '[opentelemetry]\nextension=opentelemetry.so' },
1440
+ ],
1441
+ dockerfile: [
1442
+ '# Official php images',
1443
+ 'RUN pecl install opentelemetry \\',
1444
+ ' && docker-php-ext-enable opentelemetry',
1445
+ dockerEnv(['OTEL_PHP_AUTOLOAD_ENABLED=true']),
1446
+ ].join('\n'),
1447
+ start: 'OTEL_PHP_AUTOLOAD_ENABLED=true php artisan serve --no-reload',
1448
+ platformEnv: ['OTEL_PHP_AUTOLOAD_ENABLED=true'],
1449
+ notes: [
1450
+ 'Set OTEL_PHP_AUTOLOAD_ENABLED=true with the 5 settings. PHP needs it to start OpenTelemetry.',
1451
+ 'Keep --no-reload with php artisan serve. Without it, artisan serve does not pass the OTEL_ settings to your app.',
1452
+ 'For Symfony, use open-telemetry/opentelemetry-auto-symfony instead of the Laravel package.',
1453
+ 'For Slim, use open-telemetry/opentelemetry-auto-slim instead of the Laravel package.',
1454
+ 'The exporter needs a PSR-18 HTTP client. Laravel apps include Guzzle, which works. For other apps, run composer require guzzlehttp/guzzle.',
1455
+ 'The --ignore-platform-req option lets composer add the packages on a computer without the extension. The image installs the extension. If your Dockerfile runs composer install in a stage without the extension, such as the composer image, add --ignore-platform-req=ext-opentelemetry to that line too.',
1456
+ 'PHP-FPM removes environment variables by default. Set clear_env = no in the pool config, or pass each setting with env[NAME].',
1457
+ 'On Alpine images, install the build tools first with apk add --no-cache $PHPIZE_DEPS.',
1458
+ ],
1459
+ },
1460
+ {
1461
+ id: 'ruby',
1462
+ label: 'Ruby',
1463
+ summary: 'Add the OpenTelemetry gems. Add an initializer that turns on all instrumentation.',
1464
+ manifests: ['Gemfile'],
1465
+ frameworks: ['Rails', 'Sinatra', 'Rack'],
1466
+ install: 'bundle add opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-instrumentation-all',
1467
+ files: [
1468
+ { path: 'config/initializers/opentelemetry.rb', language: 'ruby', summary: 'Add this file for Rails.', content: RUBY_INITIALIZER },
1469
+ ],
1470
+ dockerfile: [
1471
+ '# bundle install adds the gems from Gemfile.lock',
1472
+ 'RUN bundle install',
1473
+ SETTINGS_DOCKERFILE,
1474
+ 'CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]',
1475
+ ].join('\n'),
1476
+ start: 'bundle exec rails server',
1477
+ notes: [
1478
+ 'For Sinatra, put the same code in config.ru after require "sinatra" and before your app loads.',
1479
+ 'For a plain Rack app, put the same code in config.ru. Below it, add use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args).',
1480
+ 'Commit Gemfile and Gemfile.lock.',
1481
+ ],
1482
+ },
1483
+ {
1484
+ id: 'otel',
1485
+ label: 'I already use OpenTelemetry',
1486
+ summary: 'Keep your OpenTelemetry setup. Point its traces at UKNOWS with 3 of the settings.',
1487
+ manifests: ['package.json', 'requirements.txt', 'pyproject.toml', '*.csproj', 'pom.xml', 'build.gradle', 'go.mod', 'composer.json', 'Gemfile'],
1488
+ frameworks: ['Any app that already lists OpenTelemetry packages: @opentelemetry/*, opentelemetry-*, OpenTelemetry.*, io.opentelemetry, go.opentelemetry.io or open-telemetry/*'],
1489
+ install: '# No new packages. Keep the OpenTelemetry packages that you use now.',
1490
+ files: [],
1491
+ dockerfile: ['# Final stage. Your SDK reads these settings at startup.', SHARED_SETTINGS_DOCKERFILE].join('\n'),
1492
+ start: '# Keep your start command. Your SDK reads the OTEL_ settings when the app starts.',
1493
+ settings: SHARED_SETTINGS,
1494
+ warning: 'These settings send your traces to UKNOWS instead of the backend that gets them now. Your metrics and logs keep going where they go now. To keep your current backend for traces too, add UKNOWS to an OpenTelemetry Collector instead. See "Keep your current backend: add UKNOWS to your Collector".',
1495
+ options: [
1496
+ {
1497
+ title: 'Keep your current backend: add UKNOWS to your Collector',
1498
+ summary: 'Your apps keep their settings and send to your OpenTelemetry Collector. The Collector sends traces to your current backend and to UKNOWS.',
1499
+ steps: [
1500
+ 'Add the otlphttp/uknows exporter below to your Collector config.',
1501
+ 'Add otlphttp/uknows to the exporters of your traces pipeline. Keep the exporters that you have.',
1502
+ `Set UKNOWS_KEY as a secret on the Collector, as "${STEP_TITLES.secret}" says. Use the key only, without x-uknows-key=.`,
1503
+ 'Restart the Collector.',
1504
+ 'Newer Collectors name this exporter otlp_http. If your Collector says that otlphttp is deprecated, rename otlphttp/uknows to otlp_http/uknows in both places.',
1505
+ ],
1506
+ snippets: [{ caption: 'Collector config', language: 'yaml', code: COLLECTOR_CONFIG }],
1507
+ },
1508
+ ],
1509
+ notes: [
1510
+ 'Remove exporter addresses and headers from your code. Then the OTEL_ settings apply.',
1511
+ 'Set OTEL_TRACES_EXPORTER to otlp, or remove it.',
1512
+ 'UKNOWS takes OTLP over HTTP. If your code creates a gRPC exporter, change it to the HTTP exporter.',
1513
+ 'For Python, use the opentelemetry-exporter-otlp-proto-http package. For Go, use the otlptracehttp exporter.',
1514
+ 'If you send data through an OpenTelemetry Collector, add UKNOWS to the Collector. The config is in "Keep your current backend: add UKNOWS to your Collector".',
1515
+ TRACES_ONLY_NOTE,
1516
+ DOTNET_TRACES_ONLY.note,
1517
+ SAMPLING_NOTE,
1518
+ ],
1519
+ },
1520
+ ]
1521
+
1522
+ /**
1523
+ * The settings of 1 stack as env lines, for otel.env and the settings tab:
1524
+ * the 5 settings, then for each extra group a # line and its lines. A shell,
1525
+ * systemd and docker run --env-file skip the # lines.
1526
+ */
1527
+ export function stackSettingsEnv(stack) {
1528
+ const lines = [stackSettings(stack).map((setting) => `${setting.name}=${setting.value}`).join('\n')]
1529
+ if (stack.extraSettings) lines.push(`# For ${stack.label}: ${stack.extraSettings.why}`, ...stack.extraSettings.lines)
1530
+ if (stack.platformEnv?.length) lines.push(`# For ${stack.label}: this loads OpenTelemetry when the app starts.`, ...stack.platformEnv)
1531
+ return lines.join('\n')
1532
+ }
1533
+
1534
+ /** A stricter way to store the key, shown folded under the host. */
1535
+
1536
+ /**
1537
+ * A check for 1 kind of deploy file. The page lists every check of a host.
1538
+ * The setup tool prints a check when it finds that kind of file in the repo,
1539
+ * with the file and line.
1540
+ */
1541
+
1542
+ /** How the setup tool and the page name the checks of a host. The tool fills in {where} for the files that it finds. */
1543
+ export const DEPLOY_CHECKS = {
1544
+ title: 'Check your deploy files',
1545
+ found: 'Found in {where}.',
1546
+ }
1547
+
1548
+ /** The path of the key file on a VM or Docker host. Only root can read it. */
1549
+ export const KEY_FILE = '/etc/uknows.env'
1550
+
1551
+ /** A word for a bash script: as it is when it is plain, else in single quotes. */
1552
+ const shellWord = (text ) => (/^[A-Za-z0-9._-]+$/.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`)
1553
+
1554
+ /** event.name of the deploy marker of a VM: the coverage watchdog of UKNOWS looks for log records with it. */
1555
+ export const DEPLOY_EVENT = 'uknows.deploy'
1556
+
1557
+ /**
1558
+ * The deploy marker of a VM: run on the host as the last step of each deploy, it sends 1 log record to UKNOWS. A deploy
1559
+ * inside a machine never reaches the activity log of the cloud, so this record is how UKNOWS knows that a deploy
1560
+ * happened, and can say when one stops the data of the app. The record has event.name uknows.deploy, the service name,
1561
+ * and the Azure resource id of the machine from the Azure instance metadata (exact, never a guess), and the body
1562
+ * "uknows deploy", with "commit <7 to 12 hex characters>" when COMMIT holds one. The key comes from KEY_FILE and is never
1563
+ * an argument of a program: curl reads the header from a pipe. The deploy never fails because of the marker: each problem
1564
+ * prints 1 line and the script ends with 0. RESOURCE_ID, when set, takes the place of the instance metadata; it must be
1565
+ * an Azure resource id of a virtual machine or a scale set instance.
1566
+ */
1567
+ export function vmDeployMarker(service = EXAMPLE_SERVICE) {
1568
+ return [
1569
+ '# Run in bash on the host, as root or with sudo, as the last step of each deploy.',
1570
+ `SERVICE=${shellWord(service)} # the service name of the app (OTEL_SERVICE_NAME)`,
1571
+ 'COMMIT=${COMMIT:-} # optional: the commit that this deploy runs, 7 to 12 hex characters',
1572
+ '(',
1573
+ ` KEY_FILE=\${UKNOWS_KEY_FILE:-${KEY_FILE}}`,
1574
+ ` ENDPOINT=\${OTEL_EXPORTER_OTLP_ENDPOINT:-${INGEST_ENDPOINT}}`,
1575
+ ` KEY="$(sed -n 's/^${SECRET.name}=${SECRET.header}=//p' "$KEY_FILE" 2>/dev/null | head -n 1)"`,
1576
+ ' [ -n "$KEY" ] || { echo "No key in $KEY_FILE, so no deploy marker. Do the key step first."; exit 0; }',
1577
+ " ID=\"${RESOURCE_ID:-$(curl -fsS -m 5 -H Metadata:true 'http://169.254.169.254/metadata/instance/compute/resourceId?api-version=2021-02-01&format=text' 2>/dev/null)}\"",
1578
+ ' case "$ID" in',
1579
+ ' /subscriptions/*/resourceGroups/*/providers/Microsoft.Compute/*) ;;',
1580
+ ' *) echo "This is not an Azure VM and RESOURCE_ID is not set, so no deploy marker."; exit 0 ;;',
1581
+ ' esac',
1582
+ ' case "$SERVICE$ID" in *[!A-Za-z0-9/._\\(\\)-]*) echo "SERVICE or the resource id has other characters, so no deploy marker."; exit 0 ;; esac',
1583
+ ' BODY="uknows deploy"',
1584
+ ' case "$COMMIT" in ?????????????*|*[!0-9a-f]*|"") ;; ???????*) BODY="uknows deploy commit $COMMIT" ;; esac',
1585
+ ' NOW="$(date +%s)000000000"',
1586
+ ` attr() { printf '{"key":"%s","value":{"stringValue":"%s"}}' "$1" "$2"; }`,
1587
+ ` printf '{"resourceLogs":[{"resource":{"attributes":[%s,%s,%s,%s]},"scopeLogs":[{"scope":{"name":"${DEPLOY_EVENT}"},"logRecords":[{"timeUnixNano":"%s","observedTimeUnixNano":"%s","severityNumber":9,"severityText":"INFO","body":{"stringValue":"%s"},"attributes":[%s]}]}]}]}' \\`,
1588
+ ' "$(attr service.name "$SERVICE")" "$(attr cloud.provider azure)" "$(attr cloud.platform azure.vm)" "$(attr cloud.resource_id "$ID")" \\',
1589
+ ` "$NOW" "$NOW" "$BODY" "$(attr event.name ${DEPLOY_EVENT})" |`,
1590
+ ` curl -fsS -m 10 -o /dev/null -X POST "$ENDPOINT/v1/logs" -H 'content-type: application/json' -H @<(printf '${SECRET.header}: %s\\n' "$KEY") --data-binary @- \\`,
1591
+ ' && echo "Sent the deploy marker to UKNOWS." || echo "UKNOWS did not get the deploy marker. Your deploy is done."',
1592
+ ')',
1593
+ ].join('\n')
1594
+ }
1595
+
1596
+ /**
1597
+ * A bash script that asks for the key without showing it. The ( ) block is a
1598
+ * subshell: exit stops the block, not the terminal, and the key is gone when
1599
+ * the block ends. bash reads the whole block before it runs, so pasted lines
1600
+ * never become the key.
1601
+ */
1602
+ export function keyScript(setup, body, where = 'Run in bash') {
1603
+ return [
1604
+ `# ${where}. The key does not show on the screen. It is gone when the ( ) block ends.`,
1605
+ '(',
1606
+ ...[...setup, KEY_PROMPT, KEY_EMPTY_GUARD, ...body].map((line) => ` ${line}`),
1607
+ ')',
1608
+ ].join('\n')
1609
+ }
1610
+
1611
+ /** The first step of the key steps of a host. Kubernetes and the other hosts that are not 1 cloud use the plain Windows tip. */
1612
+ export const BASH_STEP = `Open a bash terminal. ${MACOS_TIP} ${WINDOWS_TIP}`
1613
+ export const AZURE_BASH_STEP = `Open a bash terminal. ${MACOS_TIP} On Windows, use Azure Cloud Shell or WSL.`
1614
+ export const AWS_BASH_STEP = `Open a bash terminal. ${MACOS_TIP} On Windows, use AWS CloudShell or WSL.`
1615
+
1616
+ /**
1617
+ * The names that a key script uses. The page shows the examples (EXAMPLE_NAMES). The setup tool fills in the names
1618
+ * that it finds in the deploy files of the repo. from: where each found name comes from, such as "deploy.yml line 12".
1619
+ * Each name keeps a comment that says where it comes from, or that it is an example.
1620
+ */
1621
+
1622
+ /**
1623
+ * The first lines of each Azure script: the subscription, the resource group and the apps. Every az command names
1624
+ * the subscription, so a script never acts on another subscription that happens to be the default of the CLI.
1625
+ * The script stops when SUBSCRIPTION is empty.
1626
+ */
1627
+ export function azureNames(names = {}, one = false) {
1628
+ const from = names.from ?? {}
1629
+ // A comment for each name: where it comes from, what it is, or that it is an example.
1630
+ const say = (key , given , what , example ) =>
1631
+ (from[key] ? `from ${from[key]}` : given ? what : example)
1632
+ return [
1633
+ `SUBSCRIPTION="${names.subscription ?? ''}" # ${say('subscription', Boolean(names.subscription), 'the subscription of your apps', 'the id of the subscription of your apps. az account list -o table lists them.')}`,
1634
+ '[ -n "$SUBSCRIPTION" ] || { echo "Set SUBSCRIPTION first. Nothing changed."; exit 1; }',
1635
+ `RESOURCE_GROUP=${names.resourceGroup ?? EXAMPLE_NAMES.resourceGroup} # ${say('resourceGroup', Boolean(names.resourceGroup), 'the resource group of your apps', 'an example. Use the resource group of your apps.')}`,
1636
+ ...(one
1637
+ ? [`APP=${names.app ?? EXAMPLE_SERVICE} # ${say('app', Boolean(names.app), 'the name of your app on the platform', 'an example. Use the name of your app on the platform.')}`,
1638
+ ...(names.slot ? [names.slotUnknown
1639
+ ? `SLOT=your-slot # an example. Use the slot that ${from.slot ?? 'your deploy'} deploys to.`
1640
+ : `SLOT=${names.slot} # ${say('slot', true, 'the deployment slot', '')}`] : [])]
1641
+ : [`APPS="${names.apps ?? EXAMPLE_NAMES.apps}" # ${say('apps', Boolean(names.apps), 'only these apps get the key', 'examples. Only these apps get the key.')}${from.apps ? '. Only these apps get the key.' : ''}`]),
1642
+ ]
1643
+ }
1644
+
1645
+ /**
1646
+ * The lines before the key prompt of a script that changes apps one by one.
1647
+ * With DRY_RUN=1 the script only checks that each app exists and says what it
1648
+ * would change. It never asks for the key.
1649
+ */
1650
+ export function dryRun(show, change, names = {}) {
1651
+ return [
1652
+ ...azureNames(names),
1653
+ 'DRY_RUN=1 # 1: list the changes only. 0: make them.',
1654
+ 'if [ "$DRY_RUN" = 1 ]; then',
1655
+ ' for APP in $APPS; do',
1656
+ ` ${show} --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP" -o none &&`,
1657
+ ` echo "Would set the key on $APP. ${change}"`,
1658
+ ' done',
1659
+ ' exit 0',
1660
+ 'fi',
1661
+ ]
1662
+ }
1663
+
1664
+ /** The key script for Azure Container Apps. */
1665
+ export function acaKeyScript(names = {}) {
1666
+ return keyScript(dryRun('az containerapp show', 'It would restart the app.', names), [
1667
+ 'for APP in $APPS; do',
1668
+ ' az containerapp secret set --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP" -o none \\',
1669
+ ' --secrets "uknows-otlp-headers=x-uknows-key=$UKNOWS_KEY" &&',
1670
+ ' az containerapp update --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP" -o none \\',
1671
+ ' --set-env-vars "OTEL_EXPORTER_OTLP_HEADERS=secretref:uknows-otlp-headers" &&',
1672
+ ' echo "Set the key on $APP."',
1673
+ 'done',
1674
+ ])
1675
+ }
1676
+
1677
+ /** The Key Vault script for Azure Container Apps. It never sees the key. */
1678
+ export function acaVaultScript(names = {}) {
1679
+ return [
1680
+ '# Run in bash. This script never sees the key.',
1681
+ '(',
1682
+ ...dryRun('az containerapp show', 'It would restart the app.', names).map((line) => ` ${line}`),
1683
+ ' SECRET_URI=https://my-vault.vault.azure.net/secrets/uknows-otlp-headers',
1684
+ ' for APP in $APPS; do',
1685
+ ' az containerapp secret set --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP" -o none \\',
1686
+ ' --secrets "uknows-otlp-headers=keyvaultref:$SECRET_URI,identityref:system" &&',
1687
+ ' az containerapp update --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP" -o none \\',
1688
+ ' --set-env-vars "OTEL_EXPORTER_OTLP_HEADERS=secretref:uknows-otlp-headers" &&',
1689
+ ' echo "Set the key on $APP."',
1690
+ ' done',
1691
+ ')',
1692
+ ].join('\n')
1693
+ }
1694
+
1695
+ /** The key script for Azure App Service web apps. */
1696
+ export function appServiceKeyScript(names = {}) {
1697
+ return keyScript(dryRun('az webapp show', 'It would restart the app.', names), [
1698
+ 'for APP in $APPS; do',
1699
+ ' az webapp config appsettings set --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP" -o none \\',
1700
+ ' --settings "OTEL_EXPORTER_OTLP_HEADERS=x-uknows-key=$UKNOWS_KEY" &&',
1701
+ ' echo "Set the key on $APP."',
1702
+ 'done',
1703
+ ])
1704
+ }
1705
+
1706
+ /** The key script for Azure Functions: the key as an app setting of each function app. */
1707
+ export function functionsKeyScript(names = {}) {
1708
+ return keyScript(dryRun('az functionapp show', 'It would restart the app.', names), [
1709
+ 'for APP in $APPS; do',
1710
+ ' az functionapp config appsettings set --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP" -o none \\',
1711
+ ' --settings "OTEL_EXPORTER_OTLP_HEADERS=x-uknows-key=$UKNOWS_KEY" &&',
1712
+ ' echo "Set the key on $APP."',
1713
+ 'done',
1714
+ ])
1715
+ }
1716
+
1717
+ /** The 5 settings as app settings of each function app (the docs tab). It has no key. */
1718
+ export function functionsSettingsScript(names = {}) {
1719
+ return [
1720
+ '# Run in bash. This script has no key.',
1721
+ ...azureNames(names),
1722
+ 'for APP in $APPS; do',
1723
+ ' az functionapp config appsettings set --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP" -o none --settings \\',
1724
+ ...SETTINGS.map((setting) => ` "${setting.name}=${setting.name === 'OTEL_SERVICE_NAME' ? '$APP' : setting.value}" \\`),
1725
+ ' && echo "Set the 5 settings on $APP."',
1726
+ 'done',
1727
+ ].join('\n')
1728
+ }
1729
+
1730
+ /**
1731
+ * Azure Functions, for the setup tool. The docs page does not show these yet: it says that serverless functions are not
1732
+ * supported (SERVERLESS) until the install proof (test A3) passes. The Functions host itself sends a server span for
1733
+ * each call of a function, and the logs, over OTLP, when host.json has "telemetryMode": "OpenTelemetry" and the app
1734
+ * has the OTLP app settings. Measured on the bench on 22 Oct 2026: every request of a Node.js, a Python and a .NET
1735
+ * isolated function app had its server span, with no package and no code in the app. The setup tool fills in {file},
1736
+ * {value} and {count}.
1737
+ */
1738
+ export const FUNCTIONS = {
1739
+ label: 'Azure Functions',
1740
+ telemetryMode: 'OpenTelemetry',
1741
+ summary: 'Turn on OpenTelemetry in the Functions host: "telemetryMode": "OpenTelemetry" in {file}.',
1742
+ other: {
1743
+ title: 'Turn on OpenTelemetry in host.json',
1744
+ lines: [
1745
+ '{file} sets "telemetryMode" to {value}. This tool does not change it.',
1746
+ 'Set it to "OpenTelemetry". Then the Functions host sends the traces and logs of your functions to UKNOWS.',
1747
+ ],
1748
+ },
1749
+ unreadable: {
1750
+ title: 'Turn on OpenTelemetry in host.json',
1751
+ lines: [
1752
+ 'This tool cannot read {file} as JSON, so it did not change it.',
1753
+ 'Add "telemetryMode": "OpenTelemetry" next to "version" in it. Then the Functions host sends the traces and logs of your functions to UKNOWS.',
1754
+ ],
1755
+ },
1756
+ notes: [
1757
+ 'The Functions host sends a trace for each call of a function, and the logs, once host.json and the app settings are in. Your code needs no package.',
1758
+ 'With OpenTelemetry on, the log stream of the Azure portal does not show the logs of this app, and "Recent function invocations" only shows data that goes to Application Insights. If the app has APPLICATIONINSIGHTS_CONNECTION_STRING, the host keeps sending to Application Insights too.',
1759
+ ],
1760
+ /** The UKNOWS hooks cannot apply to this app (hooks.unsupported), so host.json stays as it is (measured 22 Oct 2026). */
1761
+ standDown: {
1762
+ title: 'OpenTelemetry stays off for this function app',
1763
+ reason: 'This tool cannot add the UKNOWS hooks to this app: {reason}.',
1764
+ signs: '{signs} show that this app sends to Application Insights.',
1765
+ unknown: 'This tool could not look at the app settings of your function app in Azure, so it cannot tell if the app sends to Application Insights.',
1766
+ lines: [
1767
+ 'With OpenTelemetry on in the Functions host, Application Insights still gets the requests and the logs. But it names each request by its route, such as GET api/orders/{id}, instead of the name of the function, and leaves out InvocationId. The function list under Monitor in the Azure portal, and your own queries and alerts on the function name, then change. The log stream of the Azure portal stops showing the logs of the app.',
1768
+ 'To turn it on in the Functions host anyway, run npx @uknows/setup --functions-opentelemetry.',
1769
+ ],
1770
+ },
1771
+ /** A dry run in a terminal: what a real run does first. */
1772
+ predicted: 'A real run first checks the app settings of your function app in Azure. If the app sends to Application Insights, it adds the UKNOWS hooks instead of this line.',
1773
+ /** The pull request text, when host.json gets the line: a paragraph of its own. */
1774
+ pr: 'Turns on OpenTelemetry in the Azure Functions host (host.json). The host then sends the traces and logs of each call of a function to UKNOWS. The log stream of the Azure portal stops showing the logs of the app; UKNOWS shows them.',
1775
+ /**
1776
+ * The UKNOWS worker hooks: the default for a function app that sends to Application Insights, or where setup cannot
1777
+ * tell. The host keeps its Application Insights mode (measured on the bench, 22 and 23 Oct 2026: Application Insights
1778
+ * the same, a server span for each call, the same trace id and InvocationId).
1779
+ */
1780
+ hooks: {
1781
+ summary: {
1782
+ node: 'Add the UKNOWS hooks: {file}, loaded with your functions ("main": "{main}" in package.json). Application Insights stays exactly as it is.',
1783
+ file: 'Add the UKNOWS hooks: {file}. Application Insights stays exactly as it is.',
1784
+ python: 'Add the UKNOWS hooks: {file}, loaded by 2 lines in {app}. Application Insights stays exactly as it is.',
1785
+ dotnet: 'Add the UKNOWS hooks: {file}, loaded by 2 lines in {program}. Application Insights stays exactly as it is.',
1786
+ },
1787
+ notes: [
1788
+ 'This function app sends to Application Insights, or this tool could not tell. So instead of the line in host.json, this tool adds small UKNOWS hooks to the code of the app. Application Insights keeps the same requests, names and fields. UKNOWS gets a span for each call of a function, with the trace id of Application Insights, and the logs.',
1789
+ ],
1790
+ packagesTitle: 'Add the OpenTelemetry packages of the UKNOWS hooks',
1791
+ packagesByHand: 'This tool cannot add these packages here. Add them at exactly these versions: UKNOWS tested the hooks with them.',
1792
+ pythonTitle: 'Load the UKNOWS hooks in function_app.py',
1793
+ pythonByHand: 'This tool did not find 1 FunctionApp and the azure.functions import in {file}. Add the import at the top, and the instrument line after the last function, with the name of your FunctionApp.',
1794
+ dotnetTitle: 'Add the UKNOWS middleware in Program.cs',
1795
+ dotnetByHand: 'This tool cannot place the 2 lines in {file}. Add them where the worker is set up, after the builder is made.',
1796
+ untested: {
1797
+ title: 'Check UKNOWS after the deploy: a version that UKNOWS did not test',
1798
+ other: 'This app uses {name} {found}. UKNOWS tested the hooks with {name} {tested}.',
1799
+ unknown: 'This tool cannot read the version of {name} that this app uses. UKNOWS tested the hooks with {name} {tested}.',
1800
+ on: 'Tested on {on}.',
1801
+ lines: ['After the deploy, check that each call of the app shows in UKNOWS. If it does not, run npx @uknows/setup remove and tell us.'],
1802
+ },
1803
+ unsupported: {
1804
+ otel: 'the app sends OpenTelemetry or Azure Monitor data from its own code already',
1805
+ language: 'the hooks are for Node.js, Python and .NET function apps, not {lang}',
1806
+ packageJson: 'this tool cannot read package.json',
1807
+ nodeV3: 'the hooks need version 4 of @azure/functions, the v4 programming model',
1808
+ nodeMain: 'package.json has no "main" that this tool can change',
1809
+ pythonV1: 'the hooks need function_app.py, the v2 programming model of Python',
1810
+ pythonRequirements: 'the app has no requirements.txt',
1811
+ dotnetInProcess: 'the hooks need the isolated worker (Microsoft.Azure.Functions.Worker); the in-process model has no middleware',
1812
+ },
1813
+ pr: 'Adds small UKNOWS hooks to the function app ({files}). The Functions host keeps its Application Insights mode, so Application Insights keeps the same requests, names and fields. UKNOWS gets a span for each call and the logs from the worker, with the same trace id.',
1814
+ prPython: 'Python: the function app needs the app setting PYTHON_ENABLE_WORKER_EXTENSIONS=1 too, with the other settings. The Python worker runs the hooks only with it.',
1815
+ },
1816
+ settings: {
1817
+ title: 'Set the app settings of your function app',
1818
+ lines: [
1819
+ 'A function app reads its settings from its app settings, not from a file in the repo. Add the {count} settings below to each function app that runs this code.',
1820
+ 'They stay when you deploy new code. Set them once for each app, for example staging and production.',
1821
+ ],
1822
+ },
1823
+ key: {
1824
+ title: 'Set your key once: Azure Functions',
1825
+ lines: [
1826
+ AZURE_BASH_STEP,
1827
+ 'Sign in with az login.',
1828
+ 'Set SUBSCRIPTION, RESOURCE_GROUP and APPS to your function apps at the top of the script. az account list -o table lists your subscriptions. Run the script with DRY_RUN=1 first. It checks each app and says what it would change.',
1829
+ 'Set DRY_RUN=0 and run it again. Paste your key when it asks. The key does not show on the screen.',
1830
+ 'Azure restarts each function app with the key. Run it at a quiet time.',
1831
+ 'Run the script on your own computer or in Azure Cloud Shell. Do not run it on a shared server. Other users of a shared server can see the commands that run, and the az command gets the key.',
1832
+ ],
1833
+ },
1834
+ }
1835
+
1836
+ /** The Key Vault script for Azure App Service. It never sees the key. */
1837
+ export function appServiceVaultScript(names = {}) {
1838
+ return [
1839
+ '# Run in bash. This script never sees the key.',
1840
+ '(',
1841
+ ...dryRun('az webapp show', 'It would restart the app.', names).map((line) => ` ${line}`),
1842
+ ' SECRET_URI=https://my-vault.vault.azure.net/secrets/uknows-otlp-headers',
1843
+ ' for APP in $APPS; do',
1844
+ ' az webapp config appsettings set --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP" -o none \\',
1845
+ ' --settings "OTEL_EXPORTER_OTLP_HEADERS=@Microsoft.KeyVault(SecretUri=$SECRET_URI)" &&',
1846
+ ' echo "Set the key on $APP."',
1847
+ ' done',
1848
+ ')',
1849
+ ].join('\n')
1850
+ }
1851
+
1852
+ /**
1853
+ * App Service with a deployment slot that is swapped into production. A swap moves the code and the plain app
1854
+ * settings between the slots, and a slot setting stays with its slot. So the key goes on each slot as a slot setting.
1855
+ * The setup tool prints this when a deploy file names a slot. It fills in {app}, {slot} and {where}.
1856
+ */
1857
+ export const APP_SERVICE_SLOTS = {
1858
+ title: 'Set the key on both slots of {app}',
1859
+ lines: [
1860
+ '{where} deploys {app} to the slot {slot}, and a swap moves the slot into production.',
1861
+ 'A swap moves the code and the plain app settings between the slots. A slot setting stays with its slot. So set the key as a slot setting on both slots: the production slot with the key of your Production environment, and the {slot} slot with the key of the environment that it serves.',
1862
+ 'Run both scripts below. Each one asks for its key. Each one restarts that slot, so run them at a quiet time.',
1863
+ ],
1864
+ }
1865
+
1866
+ /** The script that sets the key as a slot setting on 1 slot of a web app: production when names.slot is empty. */
1867
+ export function appServiceSlotScript(names = {}) {
1868
+ const slot = names.slot ? ' --slot "$SLOT"' : ''
1869
+ return keyScript(azureNames(names, true), [
1870
+ `az webapp config appsettings set --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP"${slot} -o none \\`,
1871
+ ' --slot-settings "OTEL_EXPORTER_OTLP_HEADERS=x-uknows-key=$UKNOWS_KEY" &&',
1872
+ names.slot ? 'echo "Set the key on the $SLOT slot of $APP."' : 'echo "Set the key on the production slot of $APP."',
1873
+ ], names.slotUnknown ? 'Run in bash for the deployment slot' : names.slot ? `Run in bash for the ${names.slot} slot` : 'Run in bash for the production slot')
1874
+ }
1875
+
1876
+ /**
1877
+ * App Service that runs the code of the app, not an image (a zip or Oryx deploy). App Service does not use the
1878
+ * Dockerfile of the repo then, so the start line and the settings must be set on App Service. The setup tool prints
1879
+ * this as a required step. It fills in {where}, {dockerfile}, {count}, {startWhere}, {startup} and {prefix}.
1880
+ */
1881
+ export const APP_SERVICE_CODE = {
1882
+ title: 'Set the start command and the settings on App Service',
1883
+ lines: [
1884
+ '{where} deploys the code of this app to App Service, not an image. App Service builds the code itself and does not use {dockerfile}, so the changes in {dockerfile} do not reach the app there.',
1885
+ 'Add the {count} settings below as app settings of the web app. Put them in the step of your deploy that sets the other app settings, so each deploy keeps them.',
1886
+ ],
1887
+ start: '{startWhere} sets the startup command to {startup}. Change it to the startup command below, so App Service starts the app with {prefix}.',
1888
+ startNone: 'Set the startup command below on the web app, so App Service starts the app with {prefix}. Replace the command after {prefix} with the command that starts your app.',
1889
+ noDockerfile: 'your Dockerfile',
1890
+ }
1891
+
1892
+ /** The GitHub environment secret commands, 1 for each environment, and the repository secret when a job has none. */
1893
+ export function githubSecretCommands(environments = [EXAMPLE_NAMES.environment], repo = EXAMPLE_NAMES.repo) {
1894
+ return environments.map((environment) => `gh secret set UKNOWS_KEY --env ${environment} --repo ${repo}`).join('\n')
1895
+ }
1896
+
1897
+ /** The workflow step that sets the key on a container app, before the deploy step. */
1898
+ export function githubKeyStep(names = {}) {
1899
+ const environment = names.environment === undefined ? EXAMPLE_NAMES.environment : names.environment
1900
+ return [
1901
+ `# Add this step before your deploy step${environment ? `, in the job with environment: ${environment}` : ''}.`,
1902
+ '# An earlier step signs in to Azure.',
1903
+ '- name: Set the UKNOWS key',
1904
+ ' env:',
1905
+ ' SUBSCRIPTION: ${{ vars.AZURE_SUBSCRIPTION_ID }} # the subscription id of the app',
1906
+ ` RESOURCE_GROUP: ${names.resourceGroup ?? EXAMPLE_NAMES.resourceGroup}`,
1907
+ ` APP: ${names.app ?? EXAMPLE_SERVICE}`,
1908
+ ' UKNOWS_KEY: ${{ secrets.UKNOWS_KEY }}',
1909
+ ' run: |',
1910
+ ' [ -n "$UKNOWS_KEY" ] || { echo "::error::Set the UKNOWS_KEY secret for this environment."; exit 1; }',
1911
+ ' az containerapp secret set --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP" -o none --secrets "uknows-otlp-headers=x-uknows-key=$UKNOWS_KEY"',
1912
+ `# In your deploy step, add ${ACA_SECRET_REF} to its environment variables.`,
1913
+ `# If the command has --set-env-vars already, add the pair to that list: az uses only the last --set-env-vars.`,
1914
+ ].join('\n')
1915
+ }
1916
+
1917
+ /**
1918
+ * What the setup tool says about the deploy jobs of each workflow. It fills in {where}, {job} and {environment}.
1919
+ * The page does not show these.
1920
+ */
1921
+ export const GITHUB_JOBS = {
1922
+ found: '{where}: the deploy job {job} has environment: {environment}. Set the key for that environment.',
1923
+ none: '{where}: the deploy job {job} has no environment: line, so it cannot read an environment secret.',
1924
+ }
1925
+
1926
+ export const QUIET_TIME = 'Run it at a quiet time.'
1927
+
1928
+ /** The env entry that loads the key from the uknows-otlp-headers secret of a container app. */
1929
+ export const ACA_SECRET_REF = 'OTEL_EXPORTER_OTLP_HEADERS=secretref:uknows-otlp-headers'
1930
+
1931
+ export const ACA_YAML = [
1932
+ '# Your Container Apps YAML file. Keep your other settings.',
1933
+ 'properties:',
1934
+ ' configuration:',
1935
+ ' secrets:',
1936
+ ' - name: uknows-otlp-headers',
1937
+ ' keyVaultUrl: https://my-vault.vault.azure.net/secrets/uknows-otlp-headers',
1938
+ ' identity: system',
1939
+ ' template:',
1940
+ ' containers:',
1941
+ ` - name: ${EXAMPLE_SERVICE}`,
1942
+ ' env:',
1943
+ ' - name: OTEL_EXPORTER_OTLP_HEADERS',
1944
+ ' secretRef: uknows-otlp-headers',
1945
+ ].join('\n')
1946
+
1947
+ export const ACA_BICEP = [
1948
+ '// In your Microsoft.App/containerApps resource. Keep your other settings.',
1949
+ '// The app reads the secret from Key Vault with its managed identity.',
1950
+ 'configuration: {',
1951
+ ' secrets: [',
1952
+ ' {',
1953
+ " name: 'uknows-otlp-headers'",
1954
+ " keyVaultUrl: 'https://my-vault.vault.azure.net/secrets/uknows-otlp-headers'",
1955
+ " identity: 'system'",
1956
+ ' }',
1957
+ ' ]',
1958
+ '}',
1959
+ 'template: {',
1960
+ ' containers: [',
1961
+ ' {',
1962
+ ` name: '${EXAMPLE_SERVICE}'`,
1963
+ ' env: [',
1964
+ " { name: 'OTEL_EXPORTER_OTLP_HEADERS', secretRef: 'uknows-otlp-headers' }",
1965
+ ' ]',
1966
+ ' }',
1967
+ ' ]',
1968
+ '}',
1969
+ ].join('\n')
1970
+
1971
+ export const ACA_TERRAFORM = [
1972
+ '# Pass the value with TF_VAR_uknows_otlp_headers. Keep it out of the repo.',
1973
+ 'variable "uknows_otlp_headers" {',
1974
+ ' type = string',
1975
+ ' sensitive = true',
1976
+ '}',
1977
+ '',
1978
+ '# In your azurerm_container_app resource. Keep your other settings.',
1979
+ 'secret {',
1980
+ ' name = "uknows-otlp-headers"',
1981
+ ' value = var.uknows_otlp_headers',
1982
+ '}',
1983
+ '',
1984
+ 'template {',
1985
+ ' container {',
1986
+ ` name = "${EXAMPLE_SERVICE}"`,
1987
+ ' env {',
1988
+ ' name = "OTEL_EXPORTER_OTLP_HEADERS"',
1989
+ ' secret_name = "uknows-otlp-headers"',
1990
+ ' }',
1991
+ ' }',
1992
+ '}',
1993
+ ].join('\n')
1994
+
1995
+ export const HOSTS = [
1996
+ {
1997
+ id: 'azure-container-apps',
1998
+ label: 'Azure Container Apps',
1999
+ summary: 'Set the key as a secret on the container apps that you name.',
2000
+ steps: [
2001
+ AZURE_BASH_STEP,
2002
+ 'Sign in with az login.',
2003
+ 'Set SUBSCRIPTION, RESOURCE_GROUP and APPS at the top of the script. APPS is the list of apps that send data to UKNOWS. az account list -o table lists your subscriptions, and az containerapp list --subscription <subscription id> -g <resource group> --query "[].name" -o tsv lists the apps.',
2004
+ 'Run the script with DRY_RUN=1 first. It checks each app and says what it would change. It changes nothing.',
2005
+ 'Set DRY_RUN=0 and run it again. Paste your key when it asks. The key does not show on the screen.',
2006
+ `Each app restarts with the key. ${QUIET_TIME}`,
2007
+ ],
2008
+ snippets: [
2009
+ {
2010
+ caption: 'Set the key on your container apps',
2011
+ language: 'bash',
2012
+ code: acaKeyScript(),
2013
+ },
2014
+ ],
2015
+ notes: [
2016
+ 'Run the script on your own computer or in Azure Cloud Shell. Do not run it on a shared server. Other users of a shared server can see the commands that run, and the az command gets the key.',
2017
+ 'When you add a new app, run the script again with APPS set to the new app.',
2018
+ 'If an app has more than 1 container, add --container-name to the update command.',
2019
+ ],
2020
+ stricter: {
2021
+ title: 'Stricter option: keep the key in Azure Key Vault',
2022
+ summary: 'The key stays in Key Vault. Each app reads it with its managed identity. The key never passes through a terminal.',
2023
+ steps: [
2024
+ 'In the Azure portal, open your key vault. Create a secret named uknows-otlp-headers. Set its value to x-uknows-key=<key>.',
2025
+ 'Turn on the system-assigned managed identity of each container app.',
2026
+ 'Give each identity the Key Vault Secrets User role on the key vault.',
2027
+ 'Set SUBSCRIPTION, RESOURCE_GROUP, APPS and SECRET_URI. Run the script with DRY_RUN=1 first. Then set DRY_RUN=0 and run it again at a quiet time.',
2028
+ ],
2029
+ snippets: [{ caption: 'Use the Key Vault secret on your container apps', language: 'bash', code: acaVaultScript() }],
2030
+ },
2031
+ checks: [
2032
+ {
2033
+ id: 'replace-env-vars',
2034
+ text: `If your deploy runs az containerapp update with --replace-env-vars, that command replaces every environment variable. The next deploy removes the key reference. Add ${ACA_SECRET_REF} to that list.`,
2035
+ },
2036
+ {
2037
+ id: 'deploy-action-env',
2038
+ text: `If your workflow uses azure/container-apps-deploy-action with environmentVariables, the action replaces every environment variable too. Add ${ACA_SECRET_REF} to environmentVariables.`,
2039
+ },
2040
+ {
2041
+ id: 'deploy-action-yaml',
2042
+ text: 'If the deploy action uses yamlConfigPath, put the secret and the env entry in that YAML file. Otherwise each deploy drops them.',
2043
+ snippets: [{ caption: 'Container Apps YAML', language: 'yaml', code: ACA_YAML }],
2044
+ },
2045
+ {
2046
+ id: 'template',
2047
+ text: 'If a Bicep or Terraform template deploys your container app, the template owns its environment variables and secrets. Add the secret and the env entry to the template, as a Key Vault reference, a @secure() parameter or a secret block. A change made only with az is undone the next time the template is applied.',
2048
+ snippets: [
2049
+ { caption: 'Bicep', language: 'bicep', code: ACA_BICEP },
2050
+ { caption: 'Terraform', language: 'hcl', code: ACA_TERRAFORM },
2051
+ ],
2052
+ },
2053
+ {
2054
+ id: 'template-ignored',
2055
+ text: 'If your Terraform template has lifecycle ignore_changes on the env or the secrets of the container app, terraform apply keeps what the app has. Then set the key with az, as the script above does. A change to the template does not reach this app.',
2056
+ },
2057
+ {
2058
+ id: 'secret-first',
2059
+ text: 'Set the secret on each app before the deploy that references it. A deploy that references a secret that is not there yet fails with ContainerAppSecretRefNotFound.',
2060
+ },
2061
+ ],
2062
+ },
2063
+ {
2064
+ id: 'azure-app-service',
2065
+ label: 'Azure App Service',
2066
+ summary: 'Set the key as an app setting on the web apps that you name.',
2067
+ steps: [
2068
+ AZURE_BASH_STEP,
2069
+ 'Sign in with az login.',
2070
+ 'Set SUBSCRIPTION, RESOURCE_GROUP and APPS to your web apps at the top of the script. az account list -o table lists your subscriptions. Run the script with DRY_RUN=1 first. It checks each app and says what it would change.',
2071
+ 'Set DRY_RUN=0 and run it again. Paste your key when it asks. The key does not show on the screen.',
2072
+ `App Service restarts each app with the key. ${QUIET_TIME}`,
2073
+ ],
2074
+ snippets: [
2075
+ {
2076
+ caption: 'Set the key on your web apps',
2077
+ language: 'bash',
2078
+ code: appServiceKeyScript(),
2079
+ },
2080
+ ],
2081
+ notes: [
2082
+ 'Run the script on your own computer or in Azure Cloud Shell. Do not run it on a shared server. Other users of a shared server can see the commands that run, and the az command gets the key.',
2083
+ 'If your app does not start from your Dockerfile, add the 5 settings as app settings too.',
2084
+ 'For Python and .NET, put the start line of your stack in the startup command of the app.',
2085
+ 'If you swap a deployment slot into production, set the key on both slots as a slot setting, with --slot-settings instead of --settings. A swap moves the plain app settings with the code, and a slot setting stays with its slot. Add --slot with the name of the slot for that slot, and no --slot for production.',
2086
+ ],
2087
+ stricter: {
2088
+ title: 'Stricter option: keep the key in Azure Key Vault',
2089
+ summary: 'The key stays in Key Vault. Each app reads it with its managed identity. The key never passes through a terminal.',
2090
+ steps: [
2091
+ 'In the Azure portal, open your key vault. Create a secret named uknows-otlp-headers. Set its value to x-uknows-key=<key>.',
2092
+ 'Turn on the system-assigned managed identity of each web app.',
2093
+ 'Give each identity the Key Vault Secrets User role on the key vault.',
2094
+ 'Set SUBSCRIPTION, RESOURCE_GROUP, APPS and SECRET_URI. Run the script with DRY_RUN=1 first. Then set DRY_RUN=0 and run it again at a quiet time.',
2095
+ ],
2096
+ snippets: [{ caption: 'Use the Key Vault secret on your web apps', language: 'bash', code: appServiceVaultScript() }],
2097
+ },
2098
+ checks: [
2099
+ {
2100
+ id: 'slot',
2101
+ text: 'If you deploy to a slot, with slot-name in azure/webapps-deploy or with --slot, set the key on each slot as a slot setting, with --slot-settings. Then each slot keeps its own key when you swap.',
2102
+ },
2103
+ ],
2104
+ },
2105
+ {
2106
+ id: 'azure-functions',
2107
+ label: 'Azure Functions',
2108
+ summary: 'The setup tool sets up a function app for you, for Node.js, Python and .NET. By hand: add 1 line to host.json, then set the 5 settings and your key as app settings of each function app.',
2109
+ steps: [
2110
+ 'Run the setup tool in the repo of your function app. If the app does not send to the monitoring built into Azure, the tool adds 1 line to host.json. If it does, or the tool cannot tell, the tool adds small UKNOWS hooks to the code of the app instead, so that monitoring keeps showing what it shows now. Then it prints the app settings and the key steps for your function apps.',
2111
+ 'By hand, for an app that does not send to the monitoring built into Azure: in host.json, add "telemetryMode": "OpenTelemetry" next to "version", as in the example. Commit it and deploy as usual. The Functions host then sends a trace for each call of a function, and the logs. Your code needs no package.',
2112
+ AZURE_BASH_STEP,
2113
+ 'Sign in with az login.',
2114
+ 'Set SUBSCRIPTION, RESOURCE_GROUP and APPS to your function apps at the top of both scripts. az account list -o table lists your subscriptions. Run the key script with DRY_RUN=1 first. It checks each app and says what it would change.',
2115
+ 'Set DRY_RUN=0 and run it again. Paste your key when it asks. The key does not show on the screen.',
2116
+ 'Run the settings script. A function app reads its settings from its app settings, not from a file in your repo, and keeps them when you deploy new code. Each app gets its own name in UKNOWS: the name of the function app.',
2117
+ `Azure restarts each function app when its settings change. ${QUIET_TIME}`,
2118
+ ],
2119
+ snippets: [
2120
+ {
2121
+ caption: 'host.json: the line to add',
2122
+ language: 'json',
2123
+ code: ['{', ' "version": "2.0",', ' "telemetryMode": "OpenTelemetry"', '}'].join('\n'),
2124
+ },
2125
+ { caption: 'Set the key on your function apps', language: 'bash', code: functionsKeyScript() },
2126
+ { caption: 'Add the 5 settings to your function apps', language: 'bash', code: functionsSettingsScript() },
2127
+ ],
2128
+ notes: [
2129
+ 'Run the scripts on your own computer or in Azure Cloud Shell. Do not run them on a shared server. Other users of a shared server can see the commands that run, and the az command gets the key.',
2130
+ 'Tested with Node.js, Python and .NET (isolated worker) function apps on Linux.',
2131
+ 'If the function app sends its data to the monitoring built into Azure (it has the app setting APPLICATIONINSIGHTS_CONNECTION_STRING), the line in host.json changes what that monitoring shows. It still gets the requests, but names each one by its route, such as GET api/orders/{id}, instead of the name of the function, and leaves out InvocationId. The function list under Monitor in the Azure portal, and your own queries and alerts on the function name, then change. So for such an app the setup tool adds the UKNOWS hooks instead. The hooks keep that monitoring exactly as it is, and send a span for each call of a function and the logs to UKNOWS, with the same trace id.',
2132
+ 'The hooks need the v4 programming model of Node.js, the v2 model of Python or the isolated worker of .NET. For an app on another model that sends to the monitoring built into Azure, the tool changes nothing and says why. To turn on the line in host.json anyway, run npx @uknows/setup --functions-opentelemetry.',
2133
+ 'A Python function app with the hooks also needs the app setting PYTHON_ENABLE_WORKER_EXTENSIONS=1. The setup tool lists it with the other app settings.',
2134
+ 'With the line in host.json, the log stream of the Azure portal no longer shows the logs of the app.',
2135
+ 'For a deployment slot, add --slot with the name of the slot to each az command.',
2136
+ ],
2137
+ stricter: {
2138
+ title: 'Stricter option: keep the key in Azure Key Vault',
2139
+ summary: 'The key stays in Key Vault. Each function app reads it with its managed identity. The key never passes through a terminal.',
2140
+ steps: [
2141
+ 'In the Azure portal, open your key vault. Create a secret named uknows-otlp-headers. Set its value to x-uknows-key=<key>.',
2142
+ 'Turn on the system-assigned managed identity of each function app.',
2143
+ 'Give each identity the Key Vault Secrets User role on the key vault.',
2144
+ 'Set SUBSCRIPTION, RESOURCE_GROUP, APPS and SECRET_URI. Run the script with DRY_RUN=1 first. Then set DRY_RUN=0 and run it again at a quiet time.',
2145
+ ],
2146
+ snippets: [
2147
+ {
2148
+ caption: 'Use the Key Vault secret on your function apps',
2149
+ language: 'bash',
2150
+ code: [
2151
+ '# Run in bash. This script never sees the key.',
2152
+ '(',
2153
+ ...dryRun('az functionapp show', 'It would restart the app.').map((line) => ` ${line}`),
2154
+ ' SECRET_URI=https://my-vault.vault.azure.net/secrets/uknows-otlp-headers',
2155
+ ' for APP in $APPS; do',
2156
+ ' az functionapp config appsettings set --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP" -o none \\',
2157
+ ' --settings "OTEL_EXPORTER_OTLP_HEADERS=@Microsoft.KeyVault(SecretUri=$SECRET_URI)" &&',
2158
+ ' echo "Set the key on $APP."',
2159
+ ' done',
2160
+ ')',
2161
+ ].join('\n'),
2162
+ },
2163
+ ],
2164
+ },
2165
+ },
2166
+ {
2167
+ id: 'aws-ecs',
2168
+ label: 'AWS ECS and Fargate',
2169
+ summary: 'Store the key in AWS Secrets Manager. Each task definition reads it as a secret.',
2170
+ steps: [
2171
+ AWS_BASH_STEP,
2172
+ 'Sign in to the AWS CLI, for example with aws sso login.',
2173
+ 'Run the script once for each environment. Paste your key when it asks. The script prints the ARN of the secret.',
2174
+ 'In the task definition of each app, add the secret to the container. Use the ARN that the script printed.',
2175
+ 'Let the task execution role of each app read the secret: it needs secretsmanager:GetSecretValue on it.',
2176
+ `Register the new task definition. Then update the service to use it. ECS replaces the running tasks. ${QUIET_TIME}`,
2177
+ ],
2178
+ snippets: [
2179
+ {
2180
+ caption: 'Store the key in Secrets Manager',
2181
+ language: 'bash',
2182
+ code: keyScript(['SECRET_NAME=uknows-otlp-headers-prod'], [
2183
+ "printf 'x-uknows-key=%s' \"$UKNOWS_KEY\" \\",
2184
+ ' | aws secretsmanager create-secret --name "$SECRET_NAME" \\',
2185
+ ' --secret-string file:///dev/stdin --query ARN --output text',
2186
+ ]),
2187
+ },
2188
+ {
2189
+ caption: 'Add the secret to the container in your task definition',
2190
+ language: 'json',
2191
+ code: [
2192
+ '"secrets": [',
2193
+ ' {',
2194
+ ' "name": "OTEL_EXPORTER_OTLP_HEADERS",',
2195
+ ' "valueFrom": "arn:aws:secretsmanager:<region>:<account>:secret:uknows-otlp-headers-prod-AbCdEf"',
2196
+ ' }',
2197
+ ']',
2198
+ ].join('\n'),
2199
+ },
2200
+ ],
2201
+ notes: [
2202
+ 'The key goes to the AWS CLI through a pipe, not as an argument. Other users of the computer cannot see it.',
2203
+ 'If your image does not have ENV lines for the 5 settings, add the 5 settings to the environment of the container in the task definition.',
2204
+ 'ECS reads the secret when a task starts. To change the key, store the new key with aws secretsmanager put-secret-value. Then update the service with --force-new-deployment.',
2205
+ 'You can use AWS Systems Manager Parameter Store instead. Store a SecureString parameter and put its ARN in valueFrom.',
2206
+ 'For EKS, use the Kubernetes steps. For EC2, use the VM steps.',
2207
+ ],
2208
+ checks: [
2209
+ {
2210
+ id: 'task-definition',
2211
+ text: 'If your repo has the task definition JSON, add the secrets entry to that file and commit it. The ARN is not a secret.',
2212
+ },
2213
+ ],
2214
+ },
2215
+ {
2216
+ id: 'kubernetes',
2217
+ label: 'Kubernetes (any cluster)',
2218
+ summary: 'Store the key in a Kubernetes secret. Load it into each deployment with envFrom.',
2219
+ steps: [
2220
+ BASH_STEP,
2221
+ 'Create the secret once in each namespace that runs your apps. Paste your key when the script asks.',
2222
+ 'Add envFrom to each deployment. Commit this change with the 5 settings.',
2223
+ 'If your image does not have ENV lines for the 5 settings, add them to env in the deployment too.',
2224
+ 'Apply the deployment. Kubernetes starts new pods with the key.',
2225
+ ],
2226
+ snippets: [
2227
+ {
2228
+ caption: 'Create the secret',
2229
+ language: 'bash',
2230
+ code: keyScript(['NAMESPACE=orders'], [
2231
+ "printf 'x-uknows-key=%s' \"$UKNOWS_KEY\" \\",
2232
+ ' | kubectl create secret generic uknows -n "$NAMESPACE" \\',
2233
+ ' --from-file=OTEL_EXPORTER_OTLP_HEADERS=/dev/stdin --dry-run=client -o yaml \\',
2234
+ ' | kubectl apply -f -',
2235
+ ]),
2236
+ },
2237
+ {
2238
+ caption: 'Load the secret in your deployment',
2239
+ language: 'yaml',
2240
+ code: [
2241
+ '# Adds OTEL_EXPORTER_OTLP_HEADERS from the uknows secret.',
2242
+ 'spec:',
2243
+ ' template:',
2244
+ ' spec:',
2245
+ ' containers:',
2246
+ ` - name: ${EXAMPLE_SERVICE}`,
2247
+ ' envFrom:',
2248
+ ' - secretRef:',
2249
+ ' name: uknows',
2250
+ ' optional: true',
2251
+ ].join('\n'),
2252
+ },
2253
+ {
2254
+ caption: 'The 5 settings in your deployment, if your image does not have them',
2255
+ language: 'yaml',
2256
+ code: [
2257
+ 'spec:',
2258
+ ' template:',
2259
+ ' spec:',
2260
+ ' containers:',
2261
+ ` - name: ${EXAMPLE_SERVICE}`,
2262
+ ' env:',
2263
+ ...SETTINGS.flatMap((setting) => [` - name: ${setting.name}`, ` value: ${setting.value}`]),
2264
+ ].join('\n'),
2265
+ },
2266
+ {
2267
+ caption: 'Helm: the same in values.yaml, if your chart passes env and envFrom to the container',
2268
+ language: 'yaml',
2269
+ code: [
2270
+ '# Charts differ. Use the keys that your chart reads.',
2271
+ 'env:',
2272
+ ...SETTINGS.flatMap((setting) => [` - name: ${setting.name}`, ` value: ${setting.value}`]),
2273
+ 'envFrom:',
2274
+ ' - secretRef:',
2275
+ ' name: uknows',
2276
+ ' optional: true',
2277
+ ].join('\n'),
2278
+ },
2279
+ ],
2280
+ notes: [
2281
+ 'The same steps work on any cluster: Amazon EKS, Azure AKS, Google GKE, Alibaba ACK and your own clusters.',
2282
+ 'The key goes to kubectl through a pipe, not as an argument. Other users of the computer cannot see it.',
2283
+ 'To change the key, run the script again. Then run kubectl rollout restart for each deployment.',
2284
+ ],
2285
+ checks: [
2286
+ {
2287
+ id: 'optional',
2288
+ text: 'The envFrom lines mark the secret optional: true, so a missing secret never stops pods from starting. Create the secret first. Pods that start without it send no data to UKNOWS.',
2289
+ },
2290
+ ],
2291
+ },
2292
+ {
2293
+ id: 'alibaba-sae',
2294
+ label: 'Alibaba Cloud SAE',
2295
+ summary: 'Store the key in an SAE secret. Reference it in the environment variables of each app.',
2296
+ steps: [
2297
+ 'Open the SAE console. Select the region and namespace of your apps.',
2298
+ 'Create a secret named uknows with the key and value below.',
2299
+ 'Open each app. Edit its environment variables.',
2300
+ 'Add a variable named OTEL_EXPORTER_OTLP_HEADERS. Make it reference the secret uknows and the key OTEL_EXPORTER_OTLP_HEADERS.',
2301
+ 'If your image does not have ENV lines for the 5 settings, add the 5 settings as environment variables too.',
2302
+ 'Deploy the app. SAE restarts it with the key.',
2303
+ ],
2304
+ snippets: [
2305
+ {
2306
+ caption: 'Secret content',
2307
+ language: 'text',
2308
+ code: ['Secret name: uknows', 'Key: OTEL_EXPORTER_OTLP_HEADERS', `Value: ${SECRET.value}`].join('\n'),
2309
+ },
2310
+ ],
2311
+ notes: [
2312
+ 'Replace <key> with your key in the SAE console. Do not put the key in a file or a chat.',
2313
+ 'SAE restarts the app when you deploy. Deploy at a quiet time.',
2314
+ ],
2315
+ },
2316
+ {
2317
+ id: 'vm-docker',
2318
+ label: 'Any VM or Docker',
2319
+ summary: `Store the key in ${KEY_FILE} on each host. Load the file when the app starts.`,
2320
+ steps: [
2321
+ 'On each host, run the script in bash. Paste your key when it asks.',
2322
+ `The script creates ${KEY_FILE}. Only root can read it.`,
2323
+ `For Docker, add --env-file ${KEY_FILE} to docker run.`,
2324
+ `For systemd, add EnvironmentFile=${KEY_FILE} to the service.`,
2325
+ 'Restart the app.',
2326
+ 'At the end of each deploy, run the deploy marker below on the host. It tells UKNOWS that a deploy happened, so UKNOWS can warn you when a deploy stops the data of your app. On an Azure VM it finds the resource id of the machine by itself.',
2327
+ ],
2328
+ snippets: [
2329
+ {
2330
+ caption: 'Create the key file',
2331
+ language: 'bash',
2332
+ code: keyScript([], [
2333
+ `sudo touch ${KEY_FILE}`,
2334
+ `sudo chmod 600 ${KEY_FILE}`,
2335
+ `printf 'OTEL_EXPORTER_OTLP_HEADERS=x-uknows-key=%s\\n' "$UKNOWS_KEY" | sudo tee ${KEY_FILE} > /dev/null &&`,
2336
+ `echo "Saved the key in ${KEY_FILE}."`,
2337
+ ], 'Run in bash on each host'),
2338
+ },
2339
+ {
2340
+ caption: 'Docker',
2341
+ language: 'bash',
2342
+ code: ['# Loads OTEL_EXPORTER_OTLP_HEADERS from the key file.', `sudo docker run -d --env-file ${KEY_FILE} ${EXAMPLE_SERVICE}:latest`].join('\n'),
2343
+ },
2344
+ {
2345
+ caption: 'systemd service',
2346
+ language: 'ini',
2347
+ code: [
2348
+ '[Service]',
2349
+ `# The 5 settings, from ${SETTINGS_FILE.path} in your repo.`,
2350
+ `EnvironmentFile=/opt/${EXAMPLE_SERVICE}/${SETTINGS_FILE.path}`,
2351
+ '# Loads OTEL_EXPORTER_OTLP_HEADERS from the key file.',
2352
+ `EnvironmentFile=${KEY_FILE}`,
2353
+ ].join('\n'),
2354
+ },
2355
+ { caption: 'Mark each deploy', language: 'bash', code: vmDeployMarker() },
2356
+ ],
2357
+ notes: [
2358
+ 'Docker reads the file as the user that runs docker. Run docker with sudo or as root.',
2359
+ 'If your image does not have ENV lines for the 5 settings, add --env-file otel.env to docker run too.',
2360
+ `For Docker Compose, add env_file: ${KEY_FILE} to the service.`,
2361
+ 'After you change a systemd service, run sudo systemctl daemon-reload. Then restart the service.',
2362
+ `If you start the app from a shell as a user that is not root, make that user the owner of the key file: sudo chown <user> ${KEY_FILE}.`,
2363
+ 'The same steps work on any Linux VM or server: Amazon EC2, Azure VMs, Google Compute Engine, Alibaba ECS and servers in your own data center.',
2364
+ ],
2365
+ },
2366
+ {
2367
+ id: 'github-actions',
2368
+ label: 'GitHub Actions',
2369
+ summary: 'Store the key as a GitHub environment secret, once for each environment. Your deploy workflow sets it on your app.',
2370
+ steps: [
2371
+ 'Install the GitHub CLI. Sign in with gh auth login.',
2372
+ 'Run the command once for each GitHub environment that your deploy jobs use. Paste the key of that environment when it asks. Paste the key only, without x-uknows-key=. The key does not show on the screen.',
2373
+ 'Add the step below to each deploy job, before its deploy step. It reads the secret and sets the key on your app. The example is for Azure Container Apps.',
2374
+ ],
2375
+ snippets: [
2376
+ { caption: 'Set the environment secret', language: 'bash', code: githubSecretCommands() },
2377
+ { caption: 'Set the key in your deploy workflow', language: 'yaml', code: githubKeyStep() },
2378
+ { caption: 'Or: 1 organization secret for many repos', language: 'bash', code: `gh secret set UKNOWS_KEY --org ${EXAMPLE_NAMES.org} --visibility private` },
2379
+ ],
2380
+ notes: [
2381
+ `Replace ${EXAMPLE_NAMES.repo} with your repo, and ${EXAMPLE_NAMES.environment} with the environment: name of your deploy job. Never rename an environment that your workflows use: its other secrets and variables stay with the old name.`,
2382
+ `A job can read an environment secret only when it has an environment: line. If your deploy job has none, do not add one just for the key. A job that signs in to Azure with OpenID Connect then signs in as environment:<name> instead of ref:refs/heads/<branch>, and azure/login fails until the Azure identity trusts that name. Either add a federated credential for environment:<name> to the identity first, or store the key as a repository secret: gh secret set UKNOWS_KEY --repo ${EXAMPLE_NAMES.repo}.`,
2383
+ `Each environment has its own key. gh secret set UKNOWS_KEY --env ${EXAMPLE_NAMES.environment} sets the key for ${EXAMPLE_NAMES.environment} only. For staging, run it again with --env staging and the staging key.`,
2384
+ 'An organization secret holds 1 key. Use it only when all the repos that read it deploy to the same environment.',
2385
+ '--visibility private shares the organization secret with the private repos of the organization. Public repos cannot use it.',
2386
+ `To share it with some repos only, use --visibility selected --repos ${EXAMPLE_SERVICE},payments instead.`,
2387
+ `In the example, replace ${EXAMPLE_NAMES.resourceGroup} and ${EXAMPLE_SERVICE} with your resource group and app.`,
2388
+ `The app must load the secret. Make your deploy command set ${ACA_SECRET_REF}, as the Azure Container Apps script does. If the command has --set-env-vars already, add the pair to that list: az uses only the last --set-env-vars.`,
2389
+ 'On other platforms, pass the secret the same way to the step that sets your app settings.',
2390
+ ],
2391
+ checks: [
2392
+ {
2393
+ id: 'guard',
2394
+ text: 'The step above fails the job when the UKNOWS_KEY secret is empty, so the app is never deployed without the key. It sets the key on the app first. Then your deploy step adds the env entry that reads it.',
2395
+ },
2396
+ ],
2397
+ },
2398
+ ]
2399
+
2400
+ /** Said plainly, on the page and in llms.txt. */
2401
+ export const SERVERLESS = 'Azure Functions works: the setup tool sets up Node.js, Python and .NET function apps, and its tab has the steps by hand. AWS Lambda, Google Cloud Functions and Alibaba Function Compute are not supported yet. Apps in containers, on Kubernetes and on VMs work.'
2402
+
2403
+ /**
2404
+ * What the setup tool says when the check cannot reach UKNOWS. The site check can pass and the upload to the ingest
2405
+ * address still fail: they are 2 addresses. The tool fills in {host}, {port} and {seconds}.
2406
+ */
2407
+ export const CHECK_NETWORK = {
2408
+ keyFine: 'The key is fine, but the test span did not reach UKNOWS.',
2409
+ timeout: 'This computer cannot reach {host} on port {port}: no answer in {seconds} seconds.',
2410
+ refused: '{host} refused the connection on port {port}.',
2411
+ reset: 'The connection to {host} closed before UKNOWS answered.',
2412
+ unreachable: 'This computer cannot reach {host}. Check its network.',
2413
+ dns: 'This computer cannot find {host} in DNS. Check DNS, or the proxy that this computer must use.',
2414
+ other: 'This computer cannot reach {host} ({code}).',
2415
+ ask: 'Ask your network team to allow outbound HTTPS to {host} on port {port}.',
2416
+ where: 'This check tests the network of this computer. Your apps need the same address from where they run. To test that, run the network check in "Check your key" on a host of your app.',
2417
+ proxy: 'This tool does not use HTTPS_PROXY. Run the check on a network that can reach UKNOWS directly.',
2418
+ }
2419
+
2420
+ const KEY_ANSWERS = [
2421
+ { answer: '"workspace" and "environment"', meaning: 'The key works. It sends data to that workspace and environment.' },
2422
+ { answer: '"keyState": "retiring"', meaning: 'The key still works until the time in "retiresAt". A new key replaced it. Set the new key.' },
2423
+ { answer: 'ingest_key_invalid', meaning: 'UKNOWS does not know this key. Copy the whole key again, with no spaces or quotes. An Admin or an Engineer of your workspace can show it again in Settings › Keys.' },
2424
+ { answer: 'ingest_key_revoked', meaning: 'The key no longer works. An Admin revoked it, a new key replaced it, or its environment was turned off. Get a new key from an Admin of your workspace.' },
2425
+ { answer: 'workspace_paused', meaning: 'Your workspace is paused. Talk to us to start it again.' },
2426
+ { answer: 'rate_limited', meaning: 'Too many checks from your address. Wait 1 minute.' },
2427
+ { answer: `No answer from ${INGEST_HOST}`, meaning: `The key can still be fine. This computer cannot reach ${INGEST_HOST} on port 443. Ask your network team to allow outbound HTTPS to ${INGEST_HOST}, then check again.` },
2428
+ { answer: `Cannot find ${INGEST_HOST}`, meaning: `This computer cannot find ${INGEST_HOST} in DNS. Check DNS, or the proxy that this computer must use.` },
2429
+ ]
2430
+
2431
+ /**
2432
+ * Check a key before you deploy: the ping route of the site says which
2433
+ * workspace and environment the key sends to, or why it is refused. A second
2434
+ * line checks that a host reaches the ingest address.
2435
+ */
2436
+ export const KEY_CHECK = {
2437
+ id: 'check-key',
2438
+ title: 'Check your key',
2439
+ summary: 'Check the key before you deploy. Then you know that the key works, and for which workspace and environment.',
2440
+ steps: [
2441
+ 'Run the script in bash on your own computer. Paste your key when it asks. The key does not show on the screen.',
2442
+ 'Read the answer. The list below says what each answer means.',
2443
+ 'Run the network check on a host that runs your app.',
2444
+ ],
2445
+ snippet: {
2446
+ caption: 'Check the key',
2447
+ language: 'bash',
2448
+ code: keyScript([], [
2449
+ `printf '${SECRET.header}: %s\\n' "$UKNOWS_KEY" | curl -sS -H @- ${PING_URL}`,
2450
+ 'echo',
2451
+ ]),
2452
+ },
2453
+ answers: KEY_ANSWERS,
2454
+ network: {
2455
+ caption: 'Check that your app can reach UKNOWS',
2456
+ language: 'bash',
2457
+ code: `curl -sS -o /dev/null -w '%{http_code}\\n' https://${INGEST_HOST}/otlp/v1/traces`,
2458
+ },
2459
+ networkText: 'Any 3-digit number means the host reaches UKNOWS. 000 or an error means it cannot. Then check DNS, the firewall and any proxy.',
2460
+ }
2461
+
2462
+ /**
2463
+ * How to load otel.env when the app does not start from a Dockerfile with the
2464
+ * ENV lines. The Dockerfile ENV lines stay the main path.
2465
+ */
2466
+ export const SETTINGS_FILE_LOADERS = [
2467
+ {
2468
+ id: 'shell',
2469
+ label: 'Plain start',
2470
+ summary: `Load the file and the key file in the shell that starts your app. Then run the start line for your stack. The key file belongs to root. Run the start as root. Or make the app user the owner with sudo chown <user> ${KEY_FILE}, then run the start as that user. Do not use chmod to let more users read the key file.`,
2471
+ snippet: {
2472
+ caption: 'Load the settings and the key, then start the app',
2473
+ language: 'bash',
2474
+ code: [
2475
+ '# Run in bash, in the folder of the service.',
2476
+ `# Run as root, or as the owner of ${KEY_FILE}. Only they can read it.`,
2477
+ 'set -a',
2478
+ `. ./${SETTINGS_FILE.path} # the 5 settings`,
2479
+ `. ${KEY_FILE} # the key file from "${STEP_TITLES.secret}"`,
2480
+ 'set +a',
2481
+ '# Now run the start line for your stack.',
2482
+ ].join('\n'),
2483
+ },
2484
+ },
2485
+ {
2486
+ id: 'systemd',
2487
+ label: 'systemd',
2488
+ summary: 'Add the file and the key file to the service. Then restart the service.',
2489
+ snippet: {
2490
+ caption: 'systemd service',
2491
+ language: 'ini',
2492
+ code: [
2493
+ '[Service]',
2494
+ `# The 5 settings, from ${SETTINGS_FILE.path} in your repo.`,
2495
+ `EnvironmentFile=/opt/${EXAMPLE_SERVICE}/${SETTINGS_FILE.path}`,
2496
+ `# The key file from "${STEP_TITLES.secret}".`,
2497
+ `EnvironmentFile=${KEY_FILE}`,
2498
+ ].join('\n'),
2499
+ },
2500
+ },
2501
+ {
2502
+ id: 'docker',
2503
+ label: 'Docker',
2504
+ summary: 'If your Dockerfile does not have the ENV lines, pass the file and the key file to docker run.',
2505
+ snippet: {
2506
+ caption: 'docker run',
2507
+ language: 'bash',
2508
+ code: [
2509
+ '# Loads the 5 settings and the key.',
2510
+ `sudo docker run -d --env-file ${SETTINGS_FILE.path} --env-file ${KEY_FILE} ${EXAMPLE_SERVICE}:latest`,
2511
+ ].join('\n'),
2512
+ },
2513
+ },
2514
+ {
2515
+ id: 'alibaba-sae',
2516
+ label: 'Alibaba Cloud SAE',
2517
+ summary: `Open each app in the SAE console. Add the 5 settings as environment variables. If your stack has a line that loads OpenTelemetry, add it too. Then set the key as "${STEP_TITLES.secret}" says.`,
2518
+ snippet: {
2519
+ caption: 'Environment variables',
2520
+ language: 'text',
2521
+ code: SETTINGS_ENV,
2522
+ },
2523
+ listsSettings: true,
2524
+ },
2525
+ ]
2526
+
2527
+ /**
2528
+ * When the setup tool finds no Dockerfile, no Kubernetes file and no file in the repo that loads otel.env, it adds
2529
+ * no file. It prints the settings in the form of the platform instead. The page does not show these.
2530
+ */
2531
+ export const SETTINGS_ELSEWHERE = {
2532
+ title: 'Add these settings where your app runs',
2533
+ why: `This tool found no Dockerfile, no Kubernetes file and no file in your repo that loads ${SETTINGS_FILE.path}. So it did not add ${SETTINGS_FILE.path}.`,
2534
+ /** Instead of why, when the repo has Dockerfiles that this tool did not pick for this service. It fills in {dockerfiles}. */
2535
+ notPicked: `This tool did not pick a Dockerfile for this service. It saw {dockerfiles}. It also found no Kubernetes file and no file in your repo that loads ${SETTINGS_FILE.path}, so it did not add ${SETTINGS_FILE.path}.`,
2536
+ steps: [
2537
+ 'Add these settings to the environment of your app, where it runs. Use the form for your platform below.',
2538
+ `Or add ${SETTINGS_FILE.path} with these lines next to your app, and load it when the app starts: EnvironmentFile= in a systemd service, env_file: in Docker Compose, --env-file in docker run, or . ./${SETTINGS_FILE.path} in a start script. Then run npx @uknows/setup again. It keeps the file up to date.`,
2539
+ ],
2540
+ /** A repo file loads otel.env, so the setup tool adds the settings there. It fills in {file}, {line} and {path}. */
2541
+ loaded: '{file} line {line} loads {path}, so this tool adds the settings to {path}.',
2542
+ }
2543
+
2544
+ /** The settings in the form of 1 platform, for SETTINGS_ELSEWHERE. app is the name of the app on the platform. */
2545
+ export function settingsSnippet(host, pairs, app = EXAMPLE_SERVICE, names = {}) {
2546
+ const env = pairs.map(([name, value]) => `${name}=${value}`)
2547
+ // names.slot (App Service only): the settings go on that deployment slot, as the deploy of the repo does.
2548
+ const az = (command , flag , slot = false) => [
2549
+ '# Run in bash.',
2550
+ ...azureNames({ ...names, app: names.app ?? app, slot: slot ? names.slot : undefined }, true).filter((line) => !line.startsWith('[')),
2551
+ `${command} --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP"${slot && names.slot ? ' --slot "$SLOT"' : ''} -o none ${flag} \\`,
2552
+ env.map((line) => ` "${line}"`).join(' \\\n'),
2553
+ ].join('\n')
2554
+ switch (host) {
2555
+ case 'azure-app-service':
2556
+ return { caption: 'App settings of your web app', language: 'bash', code: az('az webapp config appsettings set', '--settings', true) }
2557
+ case 'azure-functions':
2558
+ return { caption: 'App settings of your function app', language: 'bash', code: az('az functionapp config appsettings set', '--settings') }
2559
+ case 'azure-container-apps':
2560
+ return { caption: 'Environment variables of your container app', language: 'bash', code: az('az containerapp update', '--set-env-vars') }
2561
+ case 'aws-ecs':
2562
+ return { caption: 'The environment of the container in your task definition', language: 'json',
2563
+ code: ['"environment": [', pairs.map(([name, value]) => ` { "name": "${name}", "value": "${value}" }`).join(',\n'), ']'].join('\n') }
2564
+ case 'kubernetes':
2565
+ return { caption: 'The env of the container in your deployment', language: 'yaml',
2566
+ code: ['env:', ...pairs.flatMap(([name, value]) => [` - name: ${name}`, ` value: "${value}"`])].join('\n') }
2567
+ case 'alibaba-sae':
2568
+ return { caption: 'Environment variables of your app in the SAE console', language: 'text', code: env.join('\n') }
2569
+ default:
2570
+ return { caption: 'The settings', language: 'text', code: env.join('\n') }
2571
+ }
2572
+ }
2573
+
2574
+ /** Where a person checks their key in their own terminal: the key check of the Reference. */
2575
+ export const KEY_CHECK_URL = `${DOCS_URL}/reference#check`
2576
+
2577
+ /** What a person chose for the AI prompt: the name of the app in UKNOWS, and the environment of the key they will use. */
2578
+
2579
+ /** The app name as OTEL_SERVICE_NAME takes it: lower case letters, digits, dots, dashes and underscores. Empty when none. */
2580
+ export function serviceName(raw) {
2581
+ return raw.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 63)
2582
+ }
2583
+
2584
+ /**
2585
+ * The prompt a person copies into an AI coding assistant. It has this build's
2586
+ * addresses and the app and environment the person chose. It never contains a
2587
+ * key: the person gets and stores the key with the key command, in their own terminal.
2588
+ */
2589
+ export function aiPrompt({ app = '', env = 'Staging' } = {}) {
2590
+ const name = serviceName(app)
2591
+ const named = name ? ` --service-name ${name}` : ''
2592
+ return [
2593
+ 'Set up UKNOWS monitoring in this repo with the UKNOWS setup tool.',
2594
+ `First read ${LLMS_URL} and follow its "Rules for AI assistants".`,
2595
+ '',
2596
+ `1. In the root folder of the repo, run: ${setupCommand(` --dry-run --json${named}`)}`,
2597
+ ' It changes nothing. Show me the plan: each service, each file it changes and the packages it adds.',
2598
+ `2. When I say yes, run: ${setupCommand(` --yes${named}`)}`,
2599
+ ' It makes exactly the change in the plan and asks nothing. If setup stops, change nothing and tell me why.',
2600
+ '3. Build the project and run its tests.',
2601
+ '4. Commit the change, with .uknows/setup.json, on a new branch. Open a pull request named "Add UKNOWS monitoring".',
2602
+ '5. Do not deploy, and do not change business logic.',
2603
+ '',
2604
+ 'Never ask me for my UKNOWS key, and never write, print or commit it.',
2605
+ `When you are done, tell me to run ${setupCommand(' key')} in my own terminal, in this repo. I sign in and choose ${env}. Then it checks my ${env} key and stores it where the app runs.`,
2606
+ `Then I merge and deploy as usual, and watch ${name || 'the app'} connect at ${CONNECT_URL}?app=${name || '<app name>'}.`,
2607
+ ].join('\n')
2608
+ }
2609
+
2610
+ export const TROUBLESHOOTING = [
2611
+ {
2612
+ id: 'no-data',
2613
+ problem: 'No data after 10 minutes',
2614
+ steps: [
2615
+ 'Send a few requests to your app. UKNOWS shows the requests that your app gets.',
2616
+ 'Restart or redeploy the app after you set the key.',
2617
+ 'Compare the names and values of the 5 settings with this page.',
2618
+ 'Check the key, as "Check your key" says.',
2619
+ 'Open Settings › Keys. It shows the uploads UKNOWS rejected for each key, and why.',
2620
+ `Make sure that the app can reach ${INGEST_HOST} on port 443.`,
2621
+ 'Remove OTEL_SDK_DISABLED, or set it to false.',
2622
+ 'Read the app log for OpenTelemetry export errors. Some SDKs log nothing when UKNOWS refuses the key.',
2623
+ ],
2624
+ },
2625
+ {
2626
+ id: 'unauthorized',
2627
+ problem: 'The key is not accepted (401)',
2628
+ cause: 'UKNOWS does not accept the key. The key is wrong or in the wrong format, or it was revoked. Your app may not show this. Some OpenTelemetry SDKs, Node.js among them, drop the 401 and log nothing.',
2629
+ steps: [
2630
+ 'Check the key, as "Check your key" says. It says if the key is invalid or revoked.',
2631
+ 'Open Settings › Keys. It shows the uploads UKNOWS rejected for each key, and why.',
2632
+ 'If an Admin revoked the key, an Admin creates a new key there.',
2633
+ 'Copy the key again. Set the secret again.',
2634
+ 'Use the format x-uknows-key=<key>. Remove spaces and quotes.',
2635
+ 'Restart the app.',
2636
+ ],
2637
+ },
2638
+ {
2639
+ id: 'forbidden',
2640
+ problem: 'The app log shows 403 (access stopped)',
2641
+ cause: 'Your workspace is paused. A revoked or unknown key gets 401, not 403.',
2642
+ steps: [
2643
+ 'Talk to us to start the workspace again.',
2644
+ ],
2645
+ },
2646
+ {
2647
+ id: 'too-many',
2648
+ problem: 'The app log shows 429 (too many requests)',
2649
+ cause: 'Each key can send 200 requests a second, with short bursts up to 400. Your apps sent more.',
2650
+ steps: [
2651
+ 'Keep the batch settings of OpenTelemetry. They send many traces in 1 request.',
2652
+ 'Talk to us if your apps need more.',
2653
+ ],
2654
+ },
2655
+ {
2656
+ id: 'missing-jobs',
2657
+ problem: 'Queue consumers or batch jobs do not show',
2658
+ cause: 'UKNOWS drops traces that have links. Queue consumers and batch jobs often add links.',
2659
+ steps: [
2660
+ 'Check the requests of the same app. They still show.',
2661
+ 'Talk to us if you need these traces. We are working on it.',
2662
+ ],
2663
+ },
2664
+ {
2665
+ id: 'low-counts',
2666
+ problem: 'UKNOWS shows fewer requests than your app gets',
2667
+ cause: 'Your app samples traces. UKNOWS counts only the requests that it gets.',
2668
+ steps: [
2669
+ 'Remove OTEL_TRACES_SAMPLER and OTEL_TRACES_SAMPLER_ARG, or set OTEL_TRACES_SAMPLER=parentbased_always_on.',
2670
+ 'Remove any sampler from the OpenTelemetry code of the app.',
2671
+ 'Restart the app.',
2672
+ ],
2673
+ },
2674
+ {
2675
+ id: 'other-apm',
2676
+ problem: 'Your app already uses another APM agent',
2677
+ cause: '2 tracing agents in 1 app can conflict.',
2678
+ steps: [
2679
+ 'Use 1 tracing agent in each app.',
2680
+ 'Remove the other agent, or turn off its tracing, before you add UKNOWS.',
2681
+ 'If your app already uses OpenTelemetry, use the steps for "I already use OpenTelemetry".',
2682
+ ],
2683
+ },
2684
+ {
2685
+ id: 'turn-off',
2686
+ problem: 'Turn monitoring off',
2687
+ steps: [
2688
+ 'Set OTEL_SDK_DISABLED=true on the platform.',
2689
+ 'Restart the app.',
2690
+ 'For .NET, set OTEL_DOTNET_AUTO_TRACES_ENABLED=false and OTEL_DOTNET_AUTO_LOGS_ENABLED=false.',
2691
+ 'If your app also sends to the monitoring built into Azure, remove OTEL_EXPORTER_OTLP_ENDPOINT instead, then restart the app. That turns UKNOWS off and keeps that monitoring. OTEL_SDK_DISABLED=true would stop it too.',
2692
+ 'To turn monitoring on again, remove the setting and restart the app.',
2693
+ ],
2694
+ },
2695
+ ]
2696
+
2697
+ /**
2698
+ * Help that only the setup tool prints, when it stops for what it found in a repo. The pages leave it out: it names
2699
+ * the products that the tool finds, and the public pages name UKNOWS only.
2700
+ */
2701
+ export const SETUP_TROUBLESHOOTING = [
2702
+ {
2703
+ id: 'otel-wrapper',
2704
+ problem: 'A library in your app brings its own OpenTelemetry',
2705
+ cause: 'A library that is not standard OpenTelemetry, such as a telemetry library of your company, brings OpenTelemetry and can limit its version. Standard OpenTelemetry can conflict with it.',
2706
+ steps: [
2707
+ 'Remove that library and the code that calls it. Then your app can use standard OpenTelemetry.',
2708
+ 'Or ask the owners of the library to allow a newer OpenTelemetry version.',
2709
+ ],
2710
+ },
2711
+ {
2712
+ id: 'app-insights-classic',
2713
+ problem: 'Your app uses an Application Insights SDK that UKNOWS cannot join',
2714
+ cause: 'UKNOWS can add its exporter only beside the Azure Monitor OpenTelemetry packages. It cannot join the .NET SDK 2.x, the Node.js applicationinsights package 2.x, OpenCensus for Python, the Java agent or the codeless attach of App Service.',
2715
+ steps: [
2716
+ 'Move to the Azure Monitor OpenTelemetry package for your language: Azure.Monitor.OpenTelemetry.AspNetCore for .NET, @azure/monitor-opentelemetry for Node.js or azure-monitor-opentelemetry for Python.',
2717
+ 'Then keep Azure Monitor and add UKNOWS beside it. Use the steps in "Your app uses Azure Monitor?".',
2718
+ ],
2719
+ },
2720
+ ]
2721
+
2722
+ /**
2723
+ * How many days UKNOWS keeps workspace data. This is an owner decision. The
2724
+ * trace store keeps traces for 7 or 15 days by default, so no number is
2725
+ * promised yet. Until the owner picks a number and the store is set and
2726
+ * checked, this stays null and the page gives no number. Set it to the
2727
+ * decided number of days, for example 30.
2728
+ */
2729
+ export const RETENTION_DAYS = null
2730
+
2731
+ /** The signals UKNOWS checks every minute for an alert: Refused and Throttled, and the scan and revoked-key alerts
2732
+ when the owner turned them on. */
2733
+ export function checkedEveryMinute() {
2734
+ const alerts = ['Refused', 'Throttled', ...(SCAN_ALERT ? ['scans'] : []), ...(REVOKED_KEY_ALERT ? ['uploads with a revoked key'] : [])]
2735
+ return `${alerts.slice(0, -1).join(', ')} and ${alerts[alerts.length - 1]}`
2736
+ }