@open-mercato/cli 0.8.1-develop.7275.1.f772b944d4 → 0.8.1-develop.7295.1.d0e0e33014

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 (94) hide show
  1. package/.turbo/turbo-build.log +3 -1
  2. package/dist/agentic/guides/module-facts.json +1164 -142
  3. package/dist/agentic/guides/module-facts.v2.json +1164 -142
  4. package/dist/agentic/guides/modules/agent_orchestrator/index.md +1 -1
  5. package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
  6. package/dist/agentic/guides/modules/api_docs/index.md +1 -1
  7. package/dist/agentic/guides/modules/api_keys/index.md +1 -1
  8. package/dist/agentic/guides/modules/attachments/index.md +1 -1
  9. package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
  10. package/dist/agentic/guides/modules/auth/index.md +1 -1
  11. package/dist/agentic/guides/modules/business_rules/index.md +1 -1
  12. package/dist/agentic/guides/modules/catalog/index.md +1 -1
  13. package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
  14. package/dist/agentic/guides/modules/channel_discord/index.md +1 -1
  15. package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
  16. package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
  17. package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
  18. package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
  19. package/dist/agentic/guides/modules/channel_resend/index.md +1 -1
  20. package/dist/agentic/guides/modules/channel_ses/index.md +1 -1
  21. package/dist/agentic/guides/modules/checkout/index.md +1 -1
  22. package/dist/agentic/guides/modules/communication_channels/index.md +1 -1
  23. package/dist/agentic/guides/modules/configs/index.md +1 -1
  24. package/dist/agentic/guides/modules/content/index.md +1 -1
  25. package/dist/agentic/guides/modules/currencies/index.md +1 -1
  26. package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
  27. package/dist/agentic/guides/modules/customers/index.md +1 -1
  28. package/dist/agentic/guides/modules/dashboards/index.md +1 -1
  29. package/dist/agentic/guides/modules/data_sync/active-extension-bindings.md +1 -1
  30. package/dist/agentic/guides/modules/data_sync/index.md +1 -1
  31. package/dist/agentic/guides/modules/design_system/index.md +1 -1
  32. package/dist/agentic/guides/modules/devices/index.md +1 -1
  33. package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
  34. package/dist/agentic/guides/modules/directory/index.md +1 -1
  35. package/dist/agentic/guides/modules/documents/index.md +1 -1
  36. package/dist/agentic/guides/modules/entities/index.md +1 -1
  37. package/dist/agentic/guides/modules/eudr/index.md +1 -1
  38. package/dist/agentic/guides/modules/events/index.md +1 -1
  39. package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
  40. package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
  41. package/dist/agentic/guides/modules/generators/index.md +1 -1
  42. package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
  43. package/dist/agentic/guides/modules/integrations/index.md +1 -1
  44. package/dist/agentic/guides/modules/messages/index.md +1 -1
  45. package/dist/agentic/guides/modules/notifications/index.md +1 -1
  46. package/dist/agentic/guides/modules/onboarding/index.md +1 -1
  47. package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
  48. package/dist/agentic/guides/modules/perspectives/index.md +1 -1
  49. package/dist/agentic/guides/modules/phone_calls/index.md +1 -1
  50. package/dist/agentic/guides/modules/planner/index.md +1 -1
  51. package/dist/agentic/guides/modules/portal/index.md +1 -1
  52. package/dist/agentic/guides/modules/progress/index.md +1 -1
  53. package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
  54. package/dist/agentic/guides/modules/query_index/index.md +1 -1
  55. package/dist/agentic/guides/modules/record_locks/index.md +1 -1
  56. package/dist/agentic/guides/modules/resources/index.md +1 -1
  57. package/dist/agentic/guides/modules/sales/index.md +1 -1
  58. package/dist/agentic/guides/modules/scheduler/index.md +1 -1
  59. package/dist/agentic/guides/modules/search/index.md +1 -1
  60. package/dist/agentic/guides/modules/security/index.md +1 -1
  61. package/dist/agentic/guides/modules/seeds/index.md +1 -1
  62. package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
  63. package/dist/agentic/guides/modules/sso/index.md +1 -1
  64. package/dist/agentic/guides/modules/staff/host-extension-points.md +1 -1
  65. package/dist/agentic/guides/modules/staff/index.md +10 -10
  66. package/dist/agentic/guides/modules/staff/umes-diagnostics.md +4 -0
  67. package/dist/agentic/guides/modules/staff/umes-hosts.md +36 -0
  68. package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
  69. package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
  70. package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
  71. package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
  72. package/dist/agentic/guides/modules/telemetry/index.md +12 -0
  73. package/dist/agentic/guides/modules/telemetry/owned-contract-module-metadata.md +11 -0
  74. package/dist/agentic/guides/modules/tillio/index.md +1 -1
  75. package/dist/agentic/guides/modules/translations/index.md +1 -1
  76. package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
  77. package/dist/agentic/guides/modules/webhooks/index.md +1 -1
  78. package/dist/agentic/guides/modules/wms/index.md +1 -1
  79. package/dist/agentic/guides/modules/workflows/index.md +1 -1
  80. package/dist/agentic/guides/reference-module-facts.json +1 -1
  81. package/dist/agentic/guides/upstream/manifest.json +1 -1
  82. package/dist/agentic/shared/ai/harness/README.md +2 -2
  83. package/dist/agentic/shared/ai/harness/RELEASE.md +4 -4
  84. package/dist/agentic/shared/ai/harness/cases.json +77 -0
  85. package/dist/agentic/shared/ai/harness/cases.schema.json +4 -4
  86. package/dist/agentic/shared/ai/harness/validators.json +1 -1
  87. package/dist/lib/telemetry-init.js +109 -4
  88. package/dist/lib/telemetry-init.js.map +2 -2
  89. package/dist/lib/testing/integration.js +10 -0
  90. package/dist/lib/testing/integration.js.map +2 -2
  91. package/package.json +6 -6
  92. package/src/lib/__tests__/telemetry-init.test.ts +174 -4
  93. package/src/lib/telemetry-init.ts +166 -8
  94. package/src/lib/testing/integration.ts +10 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.8.1-develop.7275.1.f772b944d4",
3
+ "version": "0.8.1-develop.7295.1.d0e0e33014",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -60,9 +60,9 @@
60
60
  "@mikro-orm/decorators": "^7.1.14",
61
61
  "@mikro-orm/migrations": "^7.1.14",
62
62
  "@mikro-orm/postgresql": "^7.1.14",
63
- "@open-mercato/queue": "0.8.1-develop.7275.1.f772b944d4",
64
- "@open-mercato/shared": "0.8.1-develop.7275.1.f772b944d4",
65
- "@open-mercato/telemetry": "0.8.1-develop.7275.1.f772b944d4",
63
+ "@open-mercato/queue": "0.8.1-develop.7295.1.d0e0e33014",
64
+ "@open-mercato/shared": "0.8.1-develop.7295.1.d0e0e33014",
65
+ "@open-mercato/telemetry": "0.8.1-develop.7295.1.d0e0e33014",
66
66
  "cross-spawn": "^7.0.6",
67
67
  "pg": "8.23.0",
68
68
  "semver": "^7.8.5",
@@ -73,10 +73,10 @@
73
73
  "typescript-js": "npm:typescript@6.0.3"
74
74
  },
75
75
  "peerDependencies": {
76
- "@open-mercato/shared": "0.8.1-develop.7275.1.f772b944d4"
76
+ "@open-mercato/shared": "0.8.1-develop.7295.1.d0e0e33014"
77
77
  },
78
78
  "devDependencies": {
79
- "@open-mercato/shared": "0.8.1-develop.7275.1.f772b944d4",
79
+ "@open-mercato/shared": "0.8.1-develop.7295.1.d0e0e33014",
80
80
  "@types/jest": "^30.0.0",
81
81
  "jest": "^30.4.2",
82
82
  "ts-jest": "^29.4.12"
@@ -8,6 +8,10 @@ const TEMPLATE_DIR = path.join(__dirname, '../../../../../packages/create-app/te
8
8
  const TEMPLATE_DISPATCHER = path.join(TEMPLATE_DIR, 'src/app/api/[...slug]/route.ts')
9
9
  const TEMPLATE_NEXT_CONFIG = path.join(TEMPLATE_DIR, 'next.config.ts')
10
10
  const TEMPLATE_INSTRUMENTATION = path.join(TEMPLATE_DIR, 'src/instrumentation.ts')
11
+ const TEMPLATE_LAYOUT = path.join(TEMPLATE_DIR, 'src/app/(backend)/backend/layout.tsx')
12
+ const TEMPLATE_MODULES = path.join(TEMPLATE_DIR, 'src/modules.ts')
13
+
14
+ const LAYOUT_FILE = 'src/app/(backend)/backend/layout.tsx'
11
15
 
12
16
  // The telemetry statements the shipped scaffold wires into the dispatcher — the
13
17
  // oracle for "did the command reproduce the real wiring?". Read from the live
@@ -21,8 +25,8 @@ const TELEMETRY_DISPATCHER_LINES = [
21
25
  ]
22
26
 
23
27
  /** Assert a string is syntactically valid TypeScript (parse errors, not types). */
24
- function assertParses(code: string, label: string): void {
25
- const sf = ts.createSourceFile(`${label}.ts`, code, ts.ScriptTarget.Latest, true)
28
+ function assertParses(code: string, label: string, extension: '.ts' | '.tsx' = '.ts'): void {
29
+ const sf = ts.createSourceFile(`${label}${extension}`, code, ts.ScriptTarget.Latest, true)
26
30
  const diagnostics = (sf as unknown as { parseDiagnostics?: ts.Diagnostic[] }).parseDiagnostics ?? []
27
31
  const messages = diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n'))
28
32
  expect({ label, messages }).toEqual({ label, messages: [] })
@@ -43,6 +47,20 @@ function stripTelemetry(src: string): string {
43
47
  .join('\n')
44
48
  }
45
49
 
50
+ /** Turn the live wired template layout back into one from before browser RUM shipped. */
51
+ function stripBrowserTelemetry(src: string): string {
52
+ return src
53
+ .replace(/\n[ \t]*\/\/ Resolved per request[\s\S]*?const browserTelemetryConfig = resolveBrowserTelemetryConfig\(.*\)\n/, '\n')
54
+ .split('\n')
55
+ .filter(
56
+ (line) =>
57
+ !line.includes("from '@open-mercato/telemetry/browser'") &&
58
+ !line.includes("from '@open-mercato/telemetry/browser/server'") &&
59
+ !line.includes('<BrowserTelemetry'),
60
+ )
61
+ .join('\n')
62
+ }
63
+
46
64
  const NEXT_CONFIG = `import type { NextConfig } from 'next'
47
65
 
48
66
  const nextConfig: NextConfig = {
@@ -272,6 +290,7 @@ describe('mercato telemetry init', () => {
272
290
  instrumentation: read('src/instrumentation.ts'),
273
291
  nextConfig: read('next.config.ts'),
274
292
  route: read(dispatcherPath),
293
+ modules: read('src/modules.ts'),
275
294
  }
276
295
 
277
296
  await runTelemetryInit([])
@@ -280,18 +299,169 @@ describe('mercato telemetry init', () => {
280
299
  expect(read('src/instrumentation.ts')).toBe(after1.instrumentation)
281
300
  expect(read('next.config.ts')).toBe(after1.nextConfig)
282
301
  expect(read(dispatcherPath)).toBe(after1.route)
302
+ expect(read('src/modules.ts')).toBe(after1.modules)
283
303
 
284
304
  const route = read(dispatcherPath)
285
305
  expect((read('next.config.ts').match(/@open-mercato\/telemetry\/nextjs-config/g) ?? []).length).toBe(1)
286
306
  expect((route.match(/from '@open-mercato\/shared\/lib\/telemetry\/runtime'/g) ?? []).length).toBe(1)
287
307
  })
288
308
 
309
+ /**
310
+ * The adoption path the first RUM release has to serve: an app that already ran
311
+ * `mercato telemetry init` before browser RUM existed. Everything server-side is
312
+ * wired; none of the RUM wiring is.
313
+ */
314
+ describe('an app already initialized before browser RUM shipped', () => {
315
+ function legacyFixture(): void {
316
+ baseFixture(tmpDir)
317
+ fs.copyFileSync(TEMPLATE_DISPATCHER, path.join(tmpDir, dispatcherPath))
318
+ fs.copyFileSync(TEMPLATE_NEXT_CONFIG, path.join(tmpDir, 'next.config.ts'))
319
+ fs.copyFileSync(TEMPLATE_INSTRUMENTATION, path.join(tmpDir, 'src', 'instrumentation.ts'))
320
+ // The core env block as the pre-RUM command wrote it: no TELEMETRY_BROWSER_* keys.
321
+ fs.writeFileSync(
322
+ path.join(tmpDir, '.env.example'),
323
+ 'DATABASE_URL=postgres://localhost/app\n\n# TELEMETRY_BACKEND=otlp\n# OTEL_SERVICE_NAME=open-mercato\n',
324
+ )
325
+ fs.mkdirSync(path.join(tmpDir, 'src', 'app', '(backend)', 'backend'), { recursive: true })
326
+ fs.writeFileSync(
327
+ path.join(tmpDir, LAYOUT_FILE),
328
+ stripBrowserTelemetry(fs.readFileSync(TEMPLATE_LAYOUT, 'utf8')),
329
+ )
330
+ fs.writeFileSync(
331
+ path.join(tmpDir, 'src', 'modules.ts'),
332
+ `import type { ModuleOverrides } from '@open-mercato/shared/modules/overrides'\n\nexport type ModuleEntry = { id: string; from?: string; overrides?: ModuleOverrides }\n\nexport const enabledModules: ModuleEntry[] = [\n { id: 'auth', from: '@open-mercato/core' },\n]\n`,
333
+ )
334
+ }
335
+
336
+ it('appends the missing browser env keys instead of skipping the whole block', async () => {
337
+ legacyFixture()
338
+
339
+ await runTelemetryInit([])
340
+
341
+ const env = read('.env.example')
342
+ expect(env).toContain('TELEMETRY_BROWSER_ENABLED')
343
+ expect(env).toContain('TELEMETRY_BROWSER_SAMPLING_RATIO')
344
+ // The core block the app already had must not be duplicated.
345
+ expect((env.match(/^#?\s*TELEMETRY_BACKEND=/gm) ?? []).length).toBe(1)
346
+ })
347
+
348
+ it('registers the telemetry module, without which the browser exports into a 404', async () => {
349
+ legacyFixture()
350
+
351
+ await runTelemetryInit([])
352
+
353
+ const modules = read('src/modules.ts')
354
+ assertParses(modules, 'legacy-modules')
355
+ expect(modules).toContain("{ id: 'telemetry', from: '@open-mercato/telemetry' },")
356
+ expect(modules).toContain("{ id: 'auth', from: '@open-mercato/core' },") // preserved
357
+ })
358
+
359
+ it('renders the client bootstrap in the backoffice shell', async () => {
360
+ legacyFixture()
361
+
362
+ await runTelemetryInit([])
363
+
364
+ const layout = read(LAYOUT_FILE)
365
+ assertParses(layout, 'legacy-layout', '.tsx')
366
+ expect(layout).toContain("import { BrowserTelemetry } from '@open-mercato/telemetry/browser'")
367
+ // The credential-reading half stays on the server entry.
368
+ expect(layout).toContain("import { resolveBrowserTelemetryConfig } from '@open-mercato/telemetry/browser/server'")
369
+ // The scaffold layout already awaits cookies(), so the patcher reuses that binding — the
370
+ // header is read only by the integration-test opt-in, never in production.
371
+ expect(layout).toContain(
372
+ 'const browserTelemetryConfig = resolveBrowserTelemetryConfig({ cookieHeader: cookieStore.toString() })',
373
+ )
374
+ expect(layout).toContain('<BrowserTelemetry config={browserTelemetryConfig} />')
375
+ // Resolved before it is used, and rendered inside the shell.
376
+ expect(layout.indexOf('const browserTelemetryConfig')).toBeLessThan(layout.indexOf('<BrowserTelemetry'))
377
+ expect(layout.indexOf('<BrowserTelemetry')).toBeLessThan(layout.indexOf('</AppShell>'))
378
+ })
379
+
380
+ it('reproduces the live template layout wiring (round-trip)', async () => {
381
+ legacyFixture()
382
+ const wired = fs.readFileSync(TEMPLATE_LAYOUT, 'utf8')
383
+
384
+ await runTelemetryInit([])
385
+
386
+ const patched = read(LAYOUT_FILE)
387
+ for (const line of [
388
+ "import { BrowserTelemetry } from '@open-mercato/telemetry/browser'",
389
+ "import { resolveBrowserTelemetryConfig } from '@open-mercato/telemetry/browser/server'",
390
+ 'const browserTelemetryConfig = resolveBrowserTelemetryConfig({ cookieHeader: cookieStore.toString() })',
391
+ '<BrowserTelemetry config={browserTelemetryConfig} />',
392
+ ]) {
393
+ expect(wired).toContain(line)
394
+ expect(patched).toContain(line)
395
+ }
396
+ })
397
+
398
+ it('falls back to the argument-free call when the layout no longer awaits cookies()', async () => {
399
+ legacyFixture()
400
+ const layoutPath = path.join(tmpDir, LAYOUT_FILE)
401
+ // An app that customized the layout away from the scaffold's `cookies()` binding must still
402
+ // get something that compiles; the cookie is only ever read by the integration-test opt-in.
403
+ fs.writeFileSync(
404
+ layoutPath,
405
+ fs.readFileSync(layoutPath, 'utf8').replace(/const\s+cookieStore\s*=\s*await\s+cookies\(\)/, 'const cookieStore = null'),
406
+ )
407
+
408
+ await runTelemetryInit([])
409
+
410
+ const layout = read(LAYOUT_FILE)
411
+ assertParses(layout, 'no-cookies-layout', '.tsx')
412
+ expect(layout).toContain('const browserTelemetryConfig = resolveBrowserTelemetryConfig()')
413
+ expect(layout).toContain('<BrowserTelemetry config={browserTelemetryConfig} />')
414
+ })
415
+
416
+ it('is idempotent across the RUM steps too', async () => {
417
+ legacyFixture()
418
+ await runTelemetryInit([])
419
+ const afterFirst = { env: read('.env.example'), modules: read('src/modules.ts'), layout: read(LAYOUT_FILE) }
420
+
421
+ await runTelemetryInit([])
422
+
423
+ expect(read('.env.example')).toBe(afterFirst.env)
424
+ expect(read('src/modules.ts')).toBe(afterFirst.modules)
425
+ expect(read(LAYOUT_FILE)).toBe(afterFirst.layout)
426
+ })
427
+ })
428
+
429
+ it('is a no-op on the already-wired template layout and modules', async () => {
430
+ baseFixture(tmpDir)
431
+ fs.writeFileSync(path.join(tmpDir, dispatcherPath), SIMPLE_DISPATCHER)
432
+ fs.mkdirSync(path.join(tmpDir, 'src', 'app', '(backend)', 'backend'), { recursive: true })
433
+ fs.copyFileSync(TEMPLATE_LAYOUT, path.join(tmpDir, LAYOUT_FILE))
434
+ fs.copyFileSync(TEMPLATE_MODULES, path.join(tmpDir, 'src', 'modules.ts'))
435
+ const before = { layout: read(LAYOUT_FILE), modules: read('src/modules.ts') }
436
+
437
+ await runTelemetryInit([])
438
+
439
+ expect(read(LAYOUT_FILE)).toBe(before.layout)
440
+ expect(read('src/modules.ts')).toBe(before.modules)
441
+ })
442
+
443
+ it('leaves an unrecognizable backend layout untouched and prints the manual snippet', async () => {
444
+ baseFixture(tmpDir)
445
+ fs.writeFileSync(path.join(tmpDir, dispatcherPath), SIMPLE_DISPATCHER)
446
+ fs.mkdirSync(path.join(tmpDir, 'src', 'app', '(backend)', 'backend'), { recursive: true })
447
+ const custom = `export default function Layout() { return null }\n`
448
+ fs.writeFileSync(path.join(tmpDir, LAYOUT_FILE), custom)
449
+
450
+ await runTelemetryInit([])
451
+
452
+ expect(read(LAYOUT_FILE)).toBe(custom)
453
+ const printed = logSpy.mock.calls.flat().join('\n')
454
+ expect(printed).toContain(`manual step for ${LAYOUT_FILE}`)
455
+ expect(printed).toContain('<BrowserTelemetry config={browserTelemetryConfig} />')
456
+ })
457
+
289
458
  it('dry run writes nothing', async () => {
290
459
  baseFixture(tmpDir)
291
460
  fs.writeFileSync(path.join(tmpDir, dispatcherPath), SIMPLE_DISPATCHER)
292
- const before = read('next.config.ts')
461
+ const before = { nextConfig: read('next.config.ts'), modules: read('src/modules.ts') }
293
462
  await runTelemetryInit(['--dry-run'])
294
- expect(read('next.config.ts')).toBe(before)
463
+ expect(read('next.config.ts')).toBe(before.nextConfig)
464
+ expect(read('src/modules.ts')).toBe(before.modules)
295
465
  expect(fs.existsSync(path.join(tmpDir, 'src', 'instrumentation.ts'))).toBe(false)
296
466
  expect(JSON.parse(read('package.json')).dependencies['@open-mercato/telemetry']).toBeUndefined()
297
467
  })
@@ -9,10 +9,15 @@ import { dirname, join, resolve } from 'node:path'
9
9
  * The web-tier wiring lives in app-owned source files (a dependency bump cannot
10
10
  * touch them), so this patches them in place using the same idioms the rest of
11
11
  * the CLI uses: JSON edits, detect-before-append for `.env`, a ts-morph edit for
12
- * `next.config.ts`, and anchored insertion for `instrumentation.ts` + the API
13
- * dispatcher. When a file's shape is not recognized (a customized dispatcher, an
14
- * unusual next.config), the step degrades to printing the exact snippet and
15
- * flags it as a manual step rather than editing code it does not understand.
12
+ * `next.config.ts`, and anchored insertion for `instrumentation.ts`, the API
13
+ * dispatcher, `src/modules.ts`, and the backoffice layout. When a file's shape is
14
+ * not recognized (a customized dispatcher, an unusual next.config, a rewritten
15
+ * layout), the step degrades to printing the exact snippet and flags it as a
16
+ * manual step rather than editing code it does not understand.
17
+ *
18
+ * "Already wired" is decided per capability, not per file: an app initialized
19
+ * before browser RUM shipped has the core telemetry block and none of the RUM
20
+ * wiring, and re-running the command is how it adopts the difference.
16
21
  *
17
22
  * Worker/scheduler telemetry is not handled here — it ships transitively via
18
23
  * `@open-mercato/cli`'s telemetry dependency once the app updates its packages.
@@ -34,7 +39,7 @@ type TelemetryInitOptions = {
34
39
  dryRun: boolean
35
40
  }
36
41
 
37
- const ENV_BLOCK = `
42
+ const ENV_CORE_BLOCK = `
38
43
  # --- Telemetry & Observability (vendor-neutral OTLP: traces, logs, metrics, errors) ---
39
44
  # Off by default: leave TELEMETRY_BACKEND unset (or =noop) for a hard no-op
40
45
  # (the OpenTelemetry SDK is never loaded). Set to 'console' for local span/metric
@@ -66,6 +71,31 @@ const ENV_BLOCK = `
66
71
  # OTEL_RESOURCE_ATTRIBUTES=deployment.environment=local
67
72
  `
68
73
 
74
+ /**
75
+ * Appended on its own when an app already carries the core block — an app
76
+ * initialized before browser RUM shipped must be able to adopt it by re-running
77
+ * the command.
78
+ */
79
+ const ENV_BROWSER_BLOCK = `
80
+ # --- Browser RUM (client-side telemetry) ---
81
+ # Document-load and fetch spans exported from the backoffice through a
82
+ # same-origin proxy (/api/telemetry/browser-traces) that adds the collector
83
+ # credential server-side. Off by default; requires an active
84
+ # TELEMETRY_BACKEND + OTLP endpoint above.
85
+ # Read at request time (not NEXT_PUBLIC_*), so toggling needs no rebuild.
86
+ # Browser spans join their server spans only when TELEMETRY_TRUST_INBOUND_TRACE=true
87
+ # above; without it RUM still works, but each page yields two separate traces.
88
+ # Ingest budget: each open tab exports at most 20 batches/min (a 3s batch delay),
89
+ # and the proxy allows 400/min per authenticated user — roughly 20 concurrent tabs.
90
+ # Over that, batches are dropped with 204 rather than a retryable status, so the
91
+ # only symptom is missing spans for that one user, never a client retry storm.
92
+ # TELEMETRY_BROWSER_ENABLED=false
93
+ # TELEMETRY_BROWSER_SAMPLING_RATIO=1.0 # 0.0-1.0 (default 1.0)
94
+ # TELEMETRY_BROWSER_SERVICE_NAME= # default: <OTEL_SERVICE_NAME>-browser
95
+ `
96
+
97
+ const ENV_BLOCK = `${ENV_CORE_BLOCK}${ENV_BROWSER_BLOCK}`
98
+
69
99
  const INSTRUMENTATION_TS = `import { isTelemetryBackendEnabled } from '@open-mercato/shared/lib/telemetry/runtime'
70
100
 
71
101
  export async function register(): Promise<void> {
@@ -155,12 +185,138 @@ function patchEnvFile(appDir: string, relativePath: string, options: TelemetryIn
155
185
  const path = join(appDir, relativePath)
156
186
  if (!existsSync(path)) return null
157
187
  const content = readFileSync(path, 'utf8')
158
- if (content.includes('TELEMETRY_BACKEND')) {
188
+ // Checked key by key, not block by block: an app initialized before browser RUM
189
+ // shipped has the core keys and none of the browser ones, and re-running the
190
+ // command is how it adopts them.
191
+ const hasCore = content.includes('TELEMETRY_BACKEND')
192
+ const hasBrowser = content.includes('TELEMETRY_BROWSER_ENABLED')
193
+ if (hasCore && hasBrowser) {
159
194
  return { file: relativePath, status: 'skipped', detail: 'TELEMETRY_* block already present' }
160
195
  }
161
- const next = `${content.replace(/\s*$/, '')}\n${ENV_BLOCK}`
196
+ const next = `${content.replace(/\s*$/, '')}\n${hasCore ? ENV_BROWSER_BLOCK : ENV_BLOCK}`
197
+ if (!options.dryRun) writeFileSync(path, next)
198
+ return {
199
+ file: relativePath,
200
+ status: 'patched',
201
+ detail: hasCore
202
+ ? 'appended the missing TELEMETRY_BROWSER_* block'
203
+ : 'appended commented TELEMETRY_* / OTEL_* block',
204
+ }
205
+ }
206
+
207
+ const MODULES_SNIPPET = ` // in the enabledModules array:
208
+ { id: 'telemetry', from: '@open-mercato/telemetry' },`
209
+
210
+ /**
211
+ * Register the `telemetry` module, which owns the same-origin OTLP proxy the
212
+ * browser exporter posts to. Without it `TELEMETRY_BROWSER_ENABLED` produces a
213
+ * client that exports into a 404.
214
+ */
215
+ function patchModules(appDir: string, options: TelemetryInitOptions): StepResult {
216
+ const file = 'src/modules.ts'
217
+ const path = join(appDir, file)
218
+ const content = readFileSync(path, 'utf8')
219
+ if (/id:\s*'telemetry'/.test(content)) {
220
+ return { file, status: 'skipped', detail: 'telemetry module already enabled' }
221
+ }
222
+ const anchor = content.match(/export\s+const\s+enabledModules[^=]*=\s*\[/)
223
+ if (!anchor || anchor.index === undefined) {
224
+ return {
225
+ file,
226
+ status: 'manual',
227
+ detail: 'enabledModules array not found — add the entry by hand',
228
+ manualSnippet: MODULES_SNIPPET,
229
+ }
230
+ }
231
+ const insertAt = anchor.index + anchor[0].length
232
+ const block =
233
+ `\n // Same-origin OTLP proxy for browser RUM spans; inert unless` +
234
+ `\n // TELEMETRY_BROWSER_ENABLED is set alongside an active telemetry backend.` +
235
+ `\n { id: 'telemetry', from: '@open-mercato/telemetry' },`
236
+ if (!options.dryRun) writeFileSync(path, content.slice(0, insertAt) + block + content.slice(insertAt))
237
+ return { file, status: 'patched', detail: "enabled the 'telemetry' module (browser-traces proxy route)" }
238
+ }
239
+
240
+ const LAYOUT_FILE = 'src/app/(backend)/backend/layout.tsx'
241
+
242
+ const LAYOUT_SNIPPET = ` // add near the other imports:
243
+ import { BrowserTelemetry } from '@open-mercato/telemetry/browser'
244
+ import { resolveBrowserTelemetryConfig } from '@open-mercato/telemetry/browser/server'
245
+
246
+ // in the component body, before the returned JSX:
247
+ // pass the request's Cookie header when the layout already reads cookies
248
+ // (only consulted by the integration-test opt-in; production is env-only):
249
+ const browserTelemetryConfig = resolveBrowserTelemetryConfig({ cookieHeader: cookieStore.toString() })
250
+
251
+ // as the last child of <AppShell>:
252
+ <BrowserTelemetry config={browserTelemetryConfig} />`
253
+
254
+ /**
255
+ * The config resolver takes the request's `Cookie` header so an integration spec can opt one page
256
+ * into browser RUM; production resolution is env-only and never reads it. A scaffolded layout
257
+ * already awaits `cookies()`, so reuse that binding — but an app that removed it must still get a
258
+ * layout that compiles, hence the argument-free fallback. Both forms behave identically outside an
259
+ * `OM_TEST_MODE` environment.
260
+ */
261
+ function resolveArgument(layout: string): string {
262
+ const binding = layout.match(/const\s+(\w+)\s*=\s*await\s+cookies\(\)/)
263
+ return binding ? `{ cookieHeader: ${binding[1]}.toString() }` : ''
264
+ }
265
+
266
+ /**
267
+ * Render the client bootstrap in the backoffice shell. The config is resolved
268
+ * per request (the layout is `force-dynamic`), so RUM toggles per environment
269
+ * without a rebuild.
270
+ *
271
+ * A backoffice layout is the file apps customize most, so this only edits one
272
+ * that still matches the scaffold shape; anything else gets the snippet.
273
+ */
274
+ function patchBackendLayout(appDir: string, options: TelemetryInitOptions): StepResult {
275
+ const path = join(appDir, LAYOUT_FILE)
276
+ if (!existsSync(path)) {
277
+ return { file: LAYOUT_FILE, status: 'manual', detail: 'backend layout not found', manualSnippet: LAYOUT_SNIPPET }
278
+ }
279
+ const content = readFileSync(path, 'utf8')
280
+ if (content.includes('<BrowserTelemetry')) {
281
+ return { file: LAYOUT_FILE, status: 'skipped', detail: 'BrowserTelemetry already rendered' }
282
+ }
283
+ const closingShell = content.lastIndexOf('</AppShell>')
284
+ const returnMatch = content.match(/^([ \t]*)return \(\s*$/m)
285
+ const lastImport = [...content.matchAll(/^import[^\n]*$/gm)].at(-1)
286
+ if (closingShell === -1 || !returnMatch || returnMatch.index === undefined || !lastImport) {
287
+ return {
288
+ file: LAYOUT_FILE,
289
+ status: 'manual',
290
+ detail: 'layout shape not recognized — apply the snippet by hand',
291
+ manualSnippet: LAYOUT_SNIPPET,
292
+ }
293
+ }
294
+
295
+ // Edit back to front so each earlier anchor's offset stays valid.
296
+ const shellIndent = content.slice(0, closingShell).match(/\n([ \t]*)$/)?.[1] ?? ' '
297
+ let next =
298
+ content.slice(0, closingShell) +
299
+ `<BrowserTelemetry config={browserTelemetryConfig} />\n${shellIndent}` +
300
+ content.slice(closingShell)
301
+
302
+ const returnIndent = returnMatch[1]
303
+ next =
304
+ next.slice(0, returnMatch.index) +
305
+ `${returnIndent}// Resolved per request (this layout is force-dynamic), so browser RUM can be\n` +
306
+ `${returnIndent}// toggled per environment without a rebuild. Null keeps the SDK chunk from\n` +
307
+ `${returnIndent}// ever being requested.\n` +
308
+ `${returnIndent}const browserTelemetryConfig = resolveBrowserTelemetryConfig(${resolveArgument(content)})\n\n` +
309
+ next.slice(returnMatch.index)
310
+
311
+ const importInsertAt = (lastImport.index ?? 0) + lastImport[0].length
312
+ next =
313
+ next.slice(0, importInsertAt) +
314
+ `\nimport { BrowserTelemetry } from '@open-mercato/telemetry/browser'` +
315
+ `\nimport { resolveBrowserTelemetryConfig } from '@open-mercato/telemetry/browser/server'` +
316
+ next.slice(importInsertAt)
317
+
162
318
  if (!options.dryRun) writeFileSync(path, next)
163
- return { file: relativePath, status: 'patched', detail: 'appended commented TELEMETRY_* / OTEL_* block' }
319
+ return { file: LAYOUT_FILE, status: 'patched', detail: 'rendered <BrowserTelemetry /> in the backoffice shell' }
164
320
  }
165
321
 
166
322
  function patchInstrumentation(appDir: string, options: TelemetryInitOptions): StepResult {
@@ -427,6 +583,8 @@ export async function runTelemetryInit(args: string[]): Promise<number> {
427
583
  results.push(patchInstrumentation(appDir, options))
428
584
  results.push(await patchNextConfig(appDir, options))
429
585
  results.push(patchDispatcher(appDir, options))
586
+ results.push(patchModules(appDir, options))
587
+ results.push(patchBackendLayout(appDir, options))
430
588
 
431
589
  const icon: Record<StepStatus, string> = { created: '✍️ ', patched: '✅', skipped: '⏭️ ', manual: '⚠️ ' }
432
590
  for (const result of results) {
@@ -2182,6 +2182,11 @@ function buildReusableEnvironment(
2182
2182
  OM_TEST_MODE: '1',
2183
2183
  OM_TEST_EMAIL_CAPTURE_PATH: process.env.OM_TEST_EMAIL_CAPTURE_PATH ?? EPHEMERAL_EMAIL_CAPTURE_PATH,
2184
2184
  OM_TEST_AUTH_RATE_LIMIT_MODE: 'opt-in',
2185
+ // Browser RUM is an environment-wide env switch, so without a per-request opt-in a spec
2186
+ // could only cover the enabled path by booting the OTel web SDK on every page of every
2187
+ // other spec. Same shape as the auth rate-limit escape hatch above: inert unless a request
2188
+ // also carries the `om_test_browser_telemetry=on` cookie, which only TC-TELEMETRY-002 sets.
2189
+ OM_TEST_BROWSER_TELEMETRY_MODE: 'opt-in',
2185
2190
  OM_DISABLE_EMAIL_DELIVERY: '0',
2186
2191
  OM_ENABLE_TEST_CHANNEL_SEEDING: 'true',
2187
2192
  OM_ENABLE_TEST_EMAIL_CAPTURE_DELIVERY: 'true',
@@ -3571,6 +3576,11 @@ export async function startEphemeralEnvironment(options: EphemeralRuntimeOptions
3571
3576
  OM_TEST_MODE: '1',
3572
3577
  OM_TEST_EMAIL_CAPTURE_PATH: process.env.OM_TEST_EMAIL_CAPTURE_PATH ?? EPHEMERAL_EMAIL_CAPTURE_PATH,
3573
3578
  OM_TEST_AUTH_RATE_LIMIT_MODE: 'opt-in',
3579
+ // Browser RUM is an environment-wide env switch, so without a per-request opt-in a spec
3580
+ // could only cover the enabled path by booting the OTel web SDK on every page of every
3581
+ // other spec. Same shape as the auth rate-limit escape hatch above: inert unless a request
3582
+ // also carries the `om_test_browser_telemetry=on` cookie, which only TC-TELEMETRY-002 sets.
3583
+ OM_TEST_BROWSER_TELEMETRY_MODE: 'opt-in',
3574
3584
  OM_ENABLE_TEST_CHANNEL_SEEDING: 'true',
3575
3585
  OM_ENABLE_TEST_EMAIL_CAPTURE_DELIVERY: 'true',
3576
3586
  OM_TEST_SYSTEM_EMAIL_CAPTURE_PATH: EPHEMERAL_SYSTEM_EMAIL_CAPTURE_PATH,