@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.
- package/.turbo/turbo-build.log +3 -1
- package/dist/agentic/guides/module-facts.json +1164 -142
- package/dist/agentic/guides/module-facts.v2.json +1164 -142
- package/dist/agentic/guides/modules/agent_orchestrator/index.md +1 -1
- package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
- package/dist/agentic/guides/modules/api_docs/index.md +1 -1
- package/dist/agentic/guides/modules/api_keys/index.md +1 -1
- package/dist/agentic/guides/modules/attachments/index.md +1 -1
- package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
- package/dist/agentic/guides/modules/auth/index.md +1 -1
- package/dist/agentic/guides/modules/business_rules/index.md +1 -1
- package/dist/agentic/guides/modules/catalog/index.md +1 -1
- package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
- package/dist/agentic/guides/modules/channel_discord/index.md +1 -1
- package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
- package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
- package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
- package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
- package/dist/agentic/guides/modules/channel_resend/index.md +1 -1
- package/dist/agentic/guides/modules/channel_ses/index.md +1 -1
- package/dist/agentic/guides/modules/checkout/index.md +1 -1
- package/dist/agentic/guides/modules/communication_channels/index.md +1 -1
- package/dist/agentic/guides/modules/configs/index.md +1 -1
- package/dist/agentic/guides/modules/content/index.md +1 -1
- package/dist/agentic/guides/modules/currencies/index.md +1 -1
- package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
- package/dist/agentic/guides/modules/customers/index.md +1 -1
- package/dist/agentic/guides/modules/dashboards/index.md +1 -1
- package/dist/agentic/guides/modules/data_sync/active-extension-bindings.md +1 -1
- package/dist/agentic/guides/modules/data_sync/index.md +1 -1
- package/dist/agentic/guides/modules/design_system/index.md +1 -1
- package/dist/agentic/guides/modules/devices/index.md +1 -1
- package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
- package/dist/agentic/guides/modules/directory/index.md +1 -1
- package/dist/agentic/guides/modules/documents/index.md +1 -1
- package/dist/agentic/guides/modules/entities/index.md +1 -1
- package/dist/agentic/guides/modules/eudr/index.md +1 -1
- package/dist/agentic/guides/modules/events/index.md +1 -1
- package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
- package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
- package/dist/agentic/guides/modules/generators/index.md +1 -1
- package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
- package/dist/agentic/guides/modules/integrations/index.md +1 -1
- package/dist/agentic/guides/modules/messages/index.md +1 -1
- package/dist/agentic/guides/modules/notifications/index.md +1 -1
- package/dist/agentic/guides/modules/onboarding/index.md +1 -1
- package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
- package/dist/agentic/guides/modules/perspectives/index.md +1 -1
- package/dist/agentic/guides/modules/phone_calls/index.md +1 -1
- package/dist/agentic/guides/modules/planner/index.md +1 -1
- package/dist/agentic/guides/modules/portal/index.md +1 -1
- package/dist/agentic/guides/modules/progress/index.md +1 -1
- package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
- package/dist/agentic/guides/modules/query_index/index.md +1 -1
- package/dist/agentic/guides/modules/record_locks/index.md +1 -1
- package/dist/agentic/guides/modules/resources/index.md +1 -1
- package/dist/agentic/guides/modules/sales/index.md +1 -1
- package/dist/agentic/guides/modules/scheduler/index.md +1 -1
- package/dist/agentic/guides/modules/search/index.md +1 -1
- package/dist/agentic/guides/modules/security/index.md +1 -1
- package/dist/agentic/guides/modules/seeds/index.md +1 -1
- package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
- package/dist/agentic/guides/modules/sso/index.md +1 -1
- package/dist/agentic/guides/modules/staff/host-extension-points.md +1 -1
- package/dist/agentic/guides/modules/staff/index.md +10 -10
- package/dist/agentic/guides/modules/staff/umes-diagnostics.md +4 -0
- package/dist/agentic/guides/modules/staff/umes-hosts.md +36 -0
- package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
- package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
- package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
- package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
- package/dist/agentic/guides/modules/telemetry/index.md +12 -0
- package/dist/agentic/guides/modules/telemetry/owned-contract-module-metadata.md +11 -0
- package/dist/agentic/guides/modules/tillio/index.md +1 -1
- package/dist/agentic/guides/modules/translations/index.md +1 -1
- package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
- package/dist/agentic/guides/modules/webhooks/index.md +1 -1
- package/dist/agentic/guides/modules/wms/index.md +1 -1
- package/dist/agentic/guides/modules/workflows/index.md +1 -1
- package/dist/agentic/guides/reference-module-facts.json +1 -1
- package/dist/agentic/guides/upstream/manifest.json +1 -1
- package/dist/agentic/shared/ai/harness/README.md +2 -2
- package/dist/agentic/shared/ai/harness/RELEASE.md +4 -4
- package/dist/agentic/shared/ai/harness/cases.json +77 -0
- package/dist/agentic/shared/ai/harness/cases.schema.json +4 -4
- package/dist/agentic/shared/ai/harness/validators.json +1 -1
- package/dist/lib/telemetry-init.js +109 -4
- package/dist/lib/telemetry-init.js.map +2 -2
- package/dist/lib/testing/integration.js +10 -0
- package/dist/lib/testing/integration.js.map +2 -2
- package/package.json +6 -6
- package/src/lib/__tests__/telemetry-init.test.ts +174 -4
- package/src/lib/telemetry-init.ts +166 -8
- 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.
|
|
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.
|
|
64
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
65
|
-
"@open-mercato/telemetry": "0.8.1-develop.
|
|
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.
|
|
76
|
+
"@open-mercato/shared": "0.8.1-develop.7295.1.d0e0e33014"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
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}
|
|
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
|
|
13
|
-
* dispatcher. When a file's shape is
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
-
|
|
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:
|
|
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,
|