@open-mercato/shared 0.6.7-develop.6828.1.ab1620a63e → 0.6.7-develop.6834.1.e76f4b8cbc
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 +1 -1
- package/AGENTS.md +1 -1
- package/dist/lib/bootstrap/dynamicLoader.js +54 -14
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/di/container.js +12 -1
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/i18n/server.js +4 -4
- package/dist/lib/i18n/server.js.map +3 -3
- package/dist/lib/logger/extension.js +29 -0
- package/dist/lib/logger/extension.js.map +7 -0
- package/dist/lib/logger/index.js +47 -3
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +5 -1
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/telemetry/runtime.js +35 -0
- package/dist/lib/telemetry/runtime.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
- package/src/lib/bootstrap/dynamicLoader.ts +111 -32
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +17 -1
- package/src/lib/di/container.ts +19 -1
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
- package/src/lib/i18n/server.ts +6 -2
- package/src/lib/logger/__tests__/logger.test.ts +77 -0
- package/src/lib/logger/extension.ts +59 -0
- package/src/lib/logger/index.ts +59 -1
- package/src/lib/modules/registry.ts +10 -0
- package/src/lib/telemetry/runtime.ts +72 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BootstrapData } from './types'
|
|
2
|
+
import type { AppDiRegistrar } from '../di/container'
|
|
2
3
|
import { findAppRoot, type AppRoot } from './appResolver'
|
|
3
4
|
import { registerEntityIds } from '../encryption/entityIds'
|
|
4
5
|
import { createLogger } from '../logger'
|
|
@@ -41,22 +42,29 @@ class GeneratedFileNotFoundError extends Error {
|
|
|
41
42
|
* list, which would silently reintroduce #4623.
|
|
42
43
|
*/
|
|
43
44
|
export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugin[] {
|
|
44
|
-
// Plugin to resolve @/ alias
|
|
45
|
+
// Plugin to resolve the @/ alias the way the app tsconfig maps it:
|
|
46
|
+
// `@/.mercato/*` to the app root, every other `@/*` to the app's src/ directory.
|
|
45
47
|
const aliasPlugin: import('esbuild').Plugin = {
|
|
46
48
|
name: 'alias-resolver',
|
|
47
49
|
setup(build) {
|
|
48
|
-
// Resolve @/ alias to app root
|
|
49
50
|
build.onResolve({ filter: /^@\// }, (args) => {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
const rest = args.path.slice('@/'.length)
|
|
52
|
+
const bases = rest.startsWith('.mercato/')
|
|
53
|
+
? [path.join(appRoot, rest)]
|
|
54
|
+
: [path.join(appRoot, 'src', rest), path.join(appRoot, rest)]
|
|
55
|
+
for (const base of bases) {
|
|
56
|
+
if (fs.existsSync(base) && fs.statSync(base).isFile()) {
|
|
57
|
+
return { path: base }
|
|
58
|
+
}
|
|
59
|
+
for (const suffix of ['.ts', '.tsx', '/index.ts', '/index.tsx']) {
|
|
60
|
+
if (fs.existsSync(base + suffix)) {
|
|
61
|
+
return { path: base + suffix }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
54
64
|
}
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
return { path: resolved }
|
|
65
|
+
// Nothing matched — hand esbuild the literal mapping so it reports the
|
|
66
|
+
// missing file against the path the app author actually wrote.
|
|
67
|
+
return { path: path.join(appRoot, rest) }
|
|
60
68
|
})
|
|
61
69
|
},
|
|
62
70
|
}
|
|
@@ -296,14 +304,33 @@ function cacheIsValid(
|
|
|
296
304
|
&& dependenciesAreValid(appRoot, metadata.dependencies)
|
|
297
305
|
}
|
|
298
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Options for `compileAndImport`.
|
|
309
|
+
*
|
|
310
|
+
* Both paths default to the generated-registry layout (`<appRoot>/.mercato/generated/<file>.ts`
|
|
311
|
+
* compiled to a `.mjs` sibling). Sources that live elsewhere in the app — `src/di.ts` — MUST pass
|
|
312
|
+
* both explicitly: the default app root is derived by walking three directories up from the source,
|
|
313
|
+
* which only holds inside `.mercato/generated`.
|
|
314
|
+
*/
|
|
315
|
+
type CompileAndImportOptions = {
|
|
316
|
+
appRoot?: string
|
|
317
|
+
outFile?: string
|
|
318
|
+
allowRecovery?: boolean
|
|
319
|
+
}
|
|
320
|
+
|
|
299
321
|
/**
|
|
300
322
|
* Compile a TypeScript file to JavaScript using esbuild bundler.
|
|
301
323
|
* This bundles the file and all its dependencies, handling JSON imports properly.
|
|
302
|
-
* The compiled file is written next to the source file with a .mjs extension
|
|
324
|
+
* The compiled file is written next to the source file with a .mjs extension unless
|
|
325
|
+
* `outFile` says otherwise.
|
|
303
326
|
*/
|
|
304
|
-
async function compileAndImport(
|
|
305
|
-
|
|
306
|
-
|
|
327
|
+
async function compileAndImport(
|
|
328
|
+
tsPath: string,
|
|
329
|
+
options: CompileAndImportOptions = {},
|
|
330
|
+
): Promise<Record<string, unknown>> {
|
|
331
|
+
const allowRecovery = options.allowRecovery ?? true
|
|
332
|
+
const jsPath = options.outFile ?? tsPath.replace(/\.ts$/, '.mjs')
|
|
333
|
+
const appRoot = options.appRoot ?? path.dirname(path.dirname(path.dirname(tsPath)))
|
|
307
334
|
const appTsconfig = path.join(appRoot, 'tsconfig.json')
|
|
308
335
|
const metadataPath = cacheMetadataPath(jsPath)
|
|
309
336
|
|
|
@@ -322,6 +349,7 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
|
|
|
322
349
|
const needsCompile = !cacheIsValid(appRoot, jsPath, metadataPath, expectedInputHash)
|
|
323
350
|
|
|
324
351
|
if (needsCompile) {
|
|
352
|
+
fs.mkdirSync(path.dirname(jsPath), { recursive: true })
|
|
325
353
|
// Dynamically import esbuild only when needed
|
|
326
354
|
const esbuild = await import('esbuild')
|
|
327
355
|
|
|
@@ -367,7 +395,7 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
|
|
|
367
395
|
throw error
|
|
368
396
|
}
|
|
369
397
|
|
|
370
|
-
return compileAndImport(tsPath, false)
|
|
398
|
+
return compileAndImport(tsPath, { ...options, allowRecovery: false })
|
|
371
399
|
}
|
|
372
400
|
}
|
|
373
401
|
|
|
@@ -405,20 +433,7 @@ async function loadOptionalGeneratedModule(
|
|
|
405
433
|
}
|
|
406
434
|
}
|
|
407
435
|
|
|
408
|
-
|
|
409
|
-
* Dynamically load bootstrap data from a resolved app directory.
|
|
410
|
-
*
|
|
411
|
-
* IMPORTANT: This only works in unbundled contexts (CLI, tsx).
|
|
412
|
-
* Do NOT use this in Next.js bundled code - use static imports instead.
|
|
413
|
-
*
|
|
414
|
-
* For CLI context, we skip loading modules.generated.ts which has Next.js dependencies.
|
|
415
|
-
* CLI commands are discovered separately via the CLI module system.
|
|
416
|
-
*
|
|
417
|
-
* @param appRoot - Optional explicit app root path. If not provided, will search from cwd.
|
|
418
|
-
* @returns The loaded bootstrap data
|
|
419
|
-
* @throws Error if app root cannot be found or generated files are missing
|
|
420
|
-
*/
|
|
421
|
-
export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData> {
|
|
436
|
+
function resolveAppRootOrThrow(appRoot?: string): AppRoot {
|
|
422
437
|
const resolved: AppRoot | null = appRoot
|
|
423
438
|
? {
|
|
424
439
|
generatedDir: path.join(appRoot, '.mercato', 'generated'),
|
|
@@ -435,6 +450,68 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
|
|
|
435
450
|
)
|
|
436
451
|
}
|
|
437
452
|
|
|
453
|
+
return resolved
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Load the app-level DI registrar (`src/di.ts`) for the dynamic bootstrap path.
|
|
458
|
+
*
|
|
459
|
+
* The Next.js runtime imports `@/di` statically from its own `src/bootstrap.ts` and hands the
|
|
460
|
+
* registrar to `createBootstrap`. Worker, scheduler and CLI processes bootstrap through
|
|
461
|
+
* `bootstrapFromAppRoot` instead, where the `@/` alias does not exist — so without this the app's
|
|
462
|
+
* DI registrations silently never ran there, and every request container paid a failed
|
|
463
|
+
* `import('@/di')` resolution (the compatibility fallback in `lib/di/container.ts`).
|
|
464
|
+
*
|
|
465
|
+
* An absent `src/di.ts` is the supported case and resolves to `null` quietly. A file that exists
|
|
466
|
+
* but cannot be compiled, imported, or does not export `register` is reported at error level and
|
|
467
|
+
* still resolves to `null`, so a broken app DI module degrades the same way a broken generated
|
|
468
|
+
* registry does (#4327, #4491) instead of taking the whole process down.
|
|
469
|
+
*/
|
|
470
|
+
async function loadAppDiRegistrar(appDir: string): Promise<AppDiRegistrar | null> {
|
|
471
|
+
const tsPath = path.join(appDir, 'src', 'di.ts')
|
|
472
|
+
if (!fs.existsSync(tsPath)) {
|
|
473
|
+
logger.debug('App-level DI module not present, skipping its registrations', { filePath: tsPath })
|
|
474
|
+
return null
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
const appDiModule = await compileAndImport(tsPath, {
|
|
479
|
+
appRoot: appDir,
|
|
480
|
+
outFile: path.join(appDir, '.mercato', 'generated', 'app-di.compiled.mjs'),
|
|
481
|
+
})
|
|
482
|
+
const register = appDiModule.register
|
|
483
|
+
if (typeof register !== 'function') {
|
|
484
|
+
logger.error('App-level DI module exports no register(); its registrations are skipped', {
|
|
485
|
+
filePath: tsPath,
|
|
486
|
+
})
|
|
487
|
+
return null
|
|
488
|
+
}
|
|
489
|
+
return register as AppDiRegistrar
|
|
490
|
+
} catch (error) {
|
|
491
|
+
logger.error('Failed to load the app-level DI module; its registrations are skipped', {
|
|
492
|
+
filePath: tsPath,
|
|
493
|
+
err: error,
|
|
494
|
+
})
|
|
495
|
+
return null
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Dynamically load bootstrap data from a resolved app directory.
|
|
501
|
+
*
|
|
502
|
+
* IMPORTANT: This only works in unbundled contexts (CLI, tsx).
|
|
503
|
+
* Do NOT use this in Next.js bundled code - use static imports instead.
|
|
504
|
+
*
|
|
505
|
+
* For CLI context, we skip loading modules.generated.ts which has Next.js dependencies.
|
|
506
|
+
* CLI commands are discovered separately via the CLI module system.
|
|
507
|
+
*
|
|
508
|
+
* @param appRoot - Optional explicit app root path. If not provided, will search from cwd.
|
|
509
|
+
* @returns The loaded bootstrap data
|
|
510
|
+
* @throws Error if app root cannot be found or generated files are missing
|
|
511
|
+
*/
|
|
512
|
+
export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData> {
|
|
513
|
+
const resolved = resolveAppRootOrThrow(appRoot)
|
|
514
|
+
|
|
438
515
|
const { generatedDir } = resolved
|
|
439
516
|
|
|
440
517
|
ensureMikroOrmV7GeneratedCacheCompatibility(resolved.appDir)
|
|
@@ -506,8 +583,10 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
|
|
|
506
583
|
*/
|
|
507
584
|
export async function bootstrapFromAppRoot(appRoot?: string): Promise<BootstrapData> {
|
|
508
585
|
const { createBootstrap, waitForAsyncRegistration } = await import('./factory.js')
|
|
509
|
-
const
|
|
510
|
-
const
|
|
586
|
+
const resolved = resolveAppRootOrThrow(appRoot)
|
|
587
|
+
const data = await loadBootstrapData(resolved.appDir)
|
|
588
|
+
const appDiRegistrar = await loadAppDiRegistrar(resolved.appDir)
|
|
589
|
+
const bootstrap = createBootstrap(data, appDiRegistrar ? { appDiRegistrar } : {})
|
|
511
590
|
bootstrap()
|
|
512
591
|
// In CLI context, wait for async registrations (UI widgets, search configs, etc.)
|
|
513
592
|
await waitForAsyncRegistration()
|
|
@@ -84,9 +84,10 @@ jest.mock(
|
|
|
84
84
|
)
|
|
85
85
|
|
|
86
86
|
const mockWarn = jest.fn()
|
|
87
|
+
const mockDebug = jest.fn()
|
|
87
88
|
jest.mock('../../logger', () => ({
|
|
88
89
|
createLogger: () => ({
|
|
89
|
-
debug:
|
|
90
|
+
debug: (...args: unknown[]) => mockDebug(...args),
|
|
90
91
|
info: jest.fn(),
|
|
91
92
|
warn: (...args: unknown[]) => mockWarn(...args),
|
|
92
93
|
error: jest.fn(),
|
|
@@ -107,6 +108,7 @@ describe('app-level DI override hook when @/di is absent', () => {
|
|
|
107
108
|
registerAppDiRegistrar(null)
|
|
108
109
|
registerDiRegistrars([])
|
|
109
110
|
mockWarn.mockClear()
|
|
111
|
+
mockDebug.mockClear()
|
|
110
112
|
})
|
|
111
113
|
|
|
112
114
|
it('creates the container without warning', async () => {
|
|
@@ -117,6 +119,20 @@ describe('app-level DI override hook when @/di is absent', () => {
|
|
|
117
119
|
expect(mockWarn).not.toHaveBeenCalled()
|
|
118
120
|
})
|
|
119
121
|
|
|
122
|
+
// Worker and CLI processes create one request container per job against built package
|
|
123
|
+
// output, where the app's `@/` alias does not exist. Retrying the doomed import per
|
|
124
|
+
// container repeats a failed module resolution — and its log line — once per job.
|
|
125
|
+
it('stops retrying the unresolvable @/di import after the first container', async () => {
|
|
126
|
+
await createRequestContainer()
|
|
127
|
+
await createRequestContainer()
|
|
128
|
+
await createRequestContainer()
|
|
129
|
+
|
|
130
|
+
const unresolvableLogs = mockDebug.mock.calls.filter(
|
|
131
|
+
([message]) => message === 'App-level DI override module (@/di) not resolvable; skipping',
|
|
132
|
+
)
|
|
133
|
+
expect(unresolvableLogs).toHaveLength(1)
|
|
134
|
+
})
|
|
135
|
+
|
|
120
136
|
it('warns once when @/di fails because a nested app alias is missing', async () => {
|
|
121
137
|
const nestedAliasError = Object.assign(new Error("Cannot find module '@/di/helpers'"), {
|
|
122
138
|
code: 'MODULE_NOT_FOUND',
|
package/src/lib/di/container.ts
CHANGED
|
@@ -26,6 +26,11 @@ const GLOBAL_KEY = '__openMercatoDiRegistrars__'
|
|
|
26
26
|
const APP_DI_REGISTRAR_KEY = '__openMercatoAppDiRegistrar__'
|
|
27
27
|
const APP_DI_LOAD_WARNING_KEY = '__openMercatoAppDiLoadWarningEmitted__'
|
|
28
28
|
const APP_DI_REGISTER_WARNING_KEY = '__openMercatoAppDiRegisterWarningEmitted__'
|
|
29
|
+
// Set once the legacy `@/di` fallback proves the specifier is unresolvable in this process.
|
|
30
|
+
// Worker/CLI processes run against built package output where the app's `@/` alias does not
|
|
31
|
+
// exist, so retrying the import per request container only repeats a failed module resolution
|
|
32
|
+
// (and its log line) once per job.
|
|
33
|
+
const APP_DI_MODULE_UNRESOLVABLE_KEY = '__openMercatoAppDiModuleUnresolvable__'
|
|
29
34
|
// Phase 5 — process-scoped bootstrap cache. The cache/event-bus/encryption
|
|
30
35
|
// services bootstrap() creates are inherently process-scoped (they hold
|
|
31
36
|
// state across requests). Caching them on globalThis after the first
|
|
@@ -120,6 +125,9 @@ export function registerDiRegistrars(registrars: DiRegistrar[]) {
|
|
|
120
125
|
// Force re-bootstrap on HMR — module subscribers may have changed.
|
|
121
126
|
;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = null
|
|
122
127
|
;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined
|
|
128
|
+
// An app that gains a src/di.ts mid-session reloads through here, so the negative
|
|
129
|
+
// resolution result must not outlive the reload.
|
|
130
|
+
;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = undefined
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
export function getDiRegistrars(): DiRegistrar[] {
|
|
@@ -145,6 +153,15 @@ export function resetBootstrapCache(): void {
|
|
|
145
153
|
;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined
|
|
146
154
|
;(globalThis as Record<string, unknown>)[APP_DI_LOAD_WARNING_KEY] = undefined
|
|
147
155
|
;(globalThis as Record<string, unknown>)[APP_DI_REGISTER_WARNING_KEY] = undefined
|
|
156
|
+
;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isAppDiModuleUnresolvable(): boolean {
|
|
160
|
+
return (globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] === true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function markAppDiModuleUnresolvable(): void {
|
|
164
|
+
;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = true
|
|
148
165
|
}
|
|
149
166
|
|
|
150
167
|
function isAppDiModuleNotFound(error: unknown): boolean {
|
|
@@ -267,7 +284,7 @@ export async function createRequestContainer(): Promise<AppContainer> {
|
|
|
267
284
|
} catch (error) {
|
|
268
285
|
logger.error('App-level DI registrar failed', { err: error })
|
|
269
286
|
}
|
|
270
|
-
} else {
|
|
287
|
+
} else if (!isAppDiModuleUnresolvable()) {
|
|
271
288
|
// Backward-compatible fallback for apps that have not adopted explicit wiring.
|
|
272
289
|
try {
|
|
273
290
|
// @ts-ignore - @/di only exists in app context, not in packages
|
|
@@ -286,6 +303,7 @@ export async function createRequestContainer(): Promise<AppContainer> {
|
|
|
286
303
|
}
|
|
287
304
|
} catch (err) {
|
|
288
305
|
if (isAppDiModuleNotFound(err)) {
|
|
306
|
+
markAppDiModuleUnresolvable()
|
|
289
307
|
logger.debug('App-level DI override module (@/di) not resolvable; skipping', { err })
|
|
290
308
|
} else {
|
|
291
309
|
warnAppDiFailureOnce(
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { invalidateDictionaryCache, loadDictionary, registerModules, resolveTranslations } from '../server'
|
|
2
|
+
import type { Module } from '../../../modules/registry'
|
|
3
|
+
|
|
4
|
+
const REGISTRY_GLOBAL_KEY = '__openMercatoModulesRegistry__'
|
|
5
|
+
|
|
6
|
+
describe('loadDictionary without a bootstrapped module registry', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
delete (globalThis as Record<string, unknown>)[REGISTRY_GLOBAL_KEY]
|
|
9
|
+
invalidateDictionaryCache()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('resolves the app dictionary instead of throwing a bootstrap error', async () => {
|
|
13
|
+
await expect(loadDictionary('en')).resolves.toEqual(expect.any(Object))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('lets a route handler translate its response before bootstrap runs', async () => {
|
|
17
|
+
const { translate } = await resolveTranslations()
|
|
18
|
+
|
|
19
|
+
expect(translate('storage_s3.upload.errors.unsupported', 'Unsupported file type')).toBe('Unsupported file type')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('picks up module translations once bootstrap registers them', async () => {
|
|
23
|
+
await loadDictionary('en')
|
|
24
|
+
|
|
25
|
+
registerModules([{ id: 'demo', translations: { en: { 'demo.hello': 'Hello' } } }] satisfies Module[])
|
|
26
|
+
|
|
27
|
+
await expect(loadDictionary('en')).resolves.toMatchObject({ 'demo.hello': 'Hello' })
|
|
28
|
+
})
|
|
29
|
+
})
|
package/src/lib/i18n/server.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { defaultLocale, locales, type Locale } from './config'
|
|
|
2
2
|
import type { Dict } from './context'
|
|
3
3
|
import { resolveForcedLocale, resolveLocaleFromAcceptLanguage } from './locale'
|
|
4
4
|
import { createFallbackTranslator, createTranslator } from './translate'
|
|
5
|
-
import {
|
|
5
|
+
import { tryGetModules } from '../modules/registry'
|
|
6
6
|
import { loadAppDictionary } from './app-dictionaries'
|
|
7
7
|
import { getCachedDictionary, setCachedDictionary } from './dictionary-cache'
|
|
8
8
|
|
|
@@ -61,7 +61,11 @@ export async function loadDictionary(locale: Locale): Promise<Dict> {
|
|
|
61
61
|
// Load from registry instead of @/ import (works in standalone packages)
|
|
62
62
|
const baseRaw = await loadAppDictionary(locale)
|
|
63
63
|
const merged: Dict = { ...flattenDictionary(baseRaw) }
|
|
64
|
-
|
|
64
|
+
// Route handlers translate their responses, so they resolve a dictionary even
|
|
65
|
+
// when they are exercised in isolation without a bootstrapped registry. The
|
|
66
|
+
// app dictionary alone is the right degraded answer there — `registerModules`
|
|
67
|
+
// invalidates this cache, so a later bootstrap still gets the merged result.
|
|
68
|
+
const modules = tryGetModules() ?? []
|
|
65
69
|
for (const m of modules) {
|
|
66
70
|
const dict = m.translations?.[locale]
|
|
67
71
|
if (dict) Object.assign(merged, flattenDictionary(dict))
|
|
@@ -3,7 +3,9 @@ import {
|
|
|
3
3
|
createLogger,
|
|
4
4
|
getLogLevel,
|
|
5
5
|
isLevelEnabled,
|
|
6
|
+
registerLoggerExtension,
|
|
6
7
|
resetLogLevelCache,
|
|
8
|
+
resetLoggerExtension,
|
|
7
9
|
resetLoggerRegistry,
|
|
8
10
|
} from '../index'
|
|
9
11
|
import { resolveLevel, type LogLevel } from '../level'
|
|
@@ -65,6 +67,7 @@ function resetLoggerState(): void {
|
|
|
65
67
|
resetLoggerRegistry()
|
|
66
68
|
resetServerLoggerCache()
|
|
67
69
|
resetLogPrettyCache()
|
|
70
|
+
resetLoggerExtension()
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
function forcePinoTransport(): void {
|
|
@@ -241,6 +244,80 @@ describe('structured logging facade', () => {
|
|
|
241
244
|
})
|
|
242
245
|
})
|
|
243
246
|
|
|
247
|
+
describe('process-wide logger extension', () => {
|
|
248
|
+
beforeEach(() => {
|
|
249
|
+
forcePinoTransport()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('keeps one local line and observes one correlated record with child bindings', () => {
|
|
253
|
+
const fake = createFakePinoHarness()
|
|
254
|
+
mockPinoLoader(fake.factory)
|
|
255
|
+
const records: unknown[] = []
|
|
256
|
+
registerLoggerExtension({
|
|
257
|
+
enrich: () => ({ trace_id: 'trace-1', span_id: 'span-1' }),
|
|
258
|
+
emit: (record) => records.push(record),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
createLogger('orders')
|
|
262
|
+
.child({ module: 'sales' })
|
|
263
|
+
.info('Order placed', { orderId: 'o-1' })
|
|
264
|
+
|
|
265
|
+
expect(fake.calls).toEqual([{
|
|
266
|
+
level: 'info',
|
|
267
|
+
args: [{
|
|
268
|
+
orderId: 'o-1',
|
|
269
|
+
trace_id: 'trace-1',
|
|
270
|
+
span_id: 'span-1',
|
|
271
|
+
}, 'Order placed'],
|
|
272
|
+
}])
|
|
273
|
+
expect(records).toHaveLength(1)
|
|
274
|
+
expect(records[0]).toMatchObject({
|
|
275
|
+
level: 'info',
|
|
276
|
+
namespace: 'orders',
|
|
277
|
+
message: 'Order placed',
|
|
278
|
+
fields: {
|
|
279
|
+
module: 'sales',
|
|
280
|
+
orderId: 'o-1',
|
|
281
|
+
trace_id: 'trace-1',
|
|
282
|
+
span_id: 'span-1',
|
|
283
|
+
},
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('applies the shared level gate to local and remote output', () => {
|
|
288
|
+
process.env.OM_LOG_LEVEL = 'warn'
|
|
289
|
+
resetLogLevelCache()
|
|
290
|
+
const fake = createFakePinoHarness()
|
|
291
|
+
mockPinoLoader(fake.factory)
|
|
292
|
+
const emit = jest.fn()
|
|
293
|
+
registerLoggerExtension({ emit })
|
|
294
|
+
const logger = createLogger('gated-extension')
|
|
295
|
+
|
|
296
|
+
logger.info('quiet')
|
|
297
|
+
logger.warn('visible')
|
|
298
|
+
|
|
299
|
+
expect(fake.calls.map((call) => call.level)).toEqual(['warn'])
|
|
300
|
+
expect(emit).toHaveBeenCalledTimes(1)
|
|
301
|
+
expect(emit.mock.calls[0][0]).toMatchObject({ level: 'warn', message: 'visible' })
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('isolates extension failures from application logging', () => {
|
|
305
|
+
const fake = createFakePinoHarness()
|
|
306
|
+
mockPinoLoader(fake.factory)
|
|
307
|
+
registerLoggerExtension({
|
|
308
|
+
enrich: () => {
|
|
309
|
+
throw new Error('context failed')
|
|
310
|
+
},
|
|
311
|
+
emit: () => {
|
|
312
|
+
throw new Error('sink failed')
|
|
313
|
+
},
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
expect(() => createLogger('resilient-extension').error('Still local')).not.toThrow()
|
|
317
|
+
expect(fake.calls).toEqual([{ level: 'error', args: [{}, 'Still local'] }])
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
244
321
|
describe('isomorphism', () => {
|
|
245
322
|
beforeEach(() => {
|
|
246
323
|
forcePinoTransport()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { LogLevel } from './level'
|
|
2
|
+
|
|
3
|
+
export type LoggerExtensionRecord = {
|
|
4
|
+
level: LogLevel
|
|
5
|
+
namespace: string
|
|
6
|
+
message: string
|
|
7
|
+
fields: Record<string, unknown>
|
|
8
|
+
time: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type LoggerExtension = {
|
|
12
|
+
/**
|
|
13
|
+
* Add process-local context (for example trace/span ids) to both the local
|
|
14
|
+
* line and the optional remote sink.
|
|
15
|
+
*/
|
|
16
|
+
enrich?(): Record<string, unknown> | undefined
|
|
17
|
+
/**
|
|
18
|
+
* Observe a record after the local transport writes it. Implementations must
|
|
19
|
+
* never throw into application code.
|
|
20
|
+
*/
|
|
21
|
+
emit?(record: LoggerExtensionRecord): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const GLOBAL_KEY = Symbol.for('@open-mercato/shared.loggerExtension')
|
|
25
|
+
|
|
26
|
+
type LoggerExtensionStore = {
|
|
27
|
+
active?: LoggerExtension
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function store(): LoggerExtensionStore {
|
|
31
|
+
const globalStore = globalThis as unknown as Record<symbol, LoggerExtensionStore | undefined>
|
|
32
|
+
let current = globalStore[GLOBAL_KEY]
|
|
33
|
+
if (!current) {
|
|
34
|
+
current = {}
|
|
35
|
+
globalStore[GLOBAL_KEY] = current
|
|
36
|
+
}
|
|
37
|
+
return current
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Register the single process-wide logger extension. The returned disposer only
|
|
42
|
+
* clears the extension when it is still the active registration.
|
|
43
|
+
*/
|
|
44
|
+
export function registerLoggerExtension(extension: LoggerExtension): () => void {
|
|
45
|
+
store().active = extension
|
|
46
|
+
return () => {
|
|
47
|
+
const current = store()
|
|
48
|
+
if (current.active === extension) current.active = undefined
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getLoggerExtension(): LoggerExtension | undefined {
|
|
53
|
+
return store().active
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Test-only: clear the process-wide extension. */
|
|
57
|
+
export function resetLoggerExtension(): void {
|
|
58
|
+
store().active = undefined
|
|
59
|
+
}
|
package/src/lib/logger/index.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { selectTransport } from './transport'
|
|
2
|
+
import { getLoggerExtension } from './extension'
|
|
3
|
+
import { isLevelEnabled, type LogLevel } from './level'
|
|
2
4
|
|
|
3
5
|
export type { LogLevel } from './level'
|
|
4
6
|
export { getLogLevel, isLevelEnabled, resetLogLevelCache, OM_LOG_LEVEL_ENV } from './level'
|
|
5
7
|
export { resetServerLoggerCache, OM_LOG_DESTINATION_ENV } from './transport.server'
|
|
6
8
|
export { resetLogPrettyCache, OM_LOG_PRETTY_ENV } from './transport.pretty'
|
|
9
|
+
export {
|
|
10
|
+
getLoggerExtension,
|
|
11
|
+
registerLoggerExtension,
|
|
12
|
+
resetLoggerExtension,
|
|
13
|
+
} from './extension'
|
|
14
|
+
export type { LoggerExtension, LoggerExtensionRecord } from './extension'
|
|
7
15
|
|
|
8
16
|
export type LogBindings = Record<string, unknown>
|
|
9
17
|
|
|
@@ -18,11 +26,61 @@ export interface Logger {
|
|
|
18
26
|
|
|
19
27
|
const loggerRegistry = new Map<string, Logger>()
|
|
20
28
|
|
|
29
|
+
function createExtendedLogger(
|
|
30
|
+
namespace: string,
|
|
31
|
+
transport: Logger,
|
|
32
|
+
bindings: LogBindings = {},
|
|
33
|
+
): Logger {
|
|
34
|
+
const emit = (
|
|
35
|
+
level: LogLevel,
|
|
36
|
+
msg: string,
|
|
37
|
+
fields?: LogBindings,
|
|
38
|
+
): void => {
|
|
39
|
+
if (!isLevelEnabled(level)) return
|
|
40
|
+
|
|
41
|
+
const extension = getLoggerExtension()
|
|
42
|
+
let context: LogBindings = {}
|
|
43
|
+
try {
|
|
44
|
+
context = extension?.enrich?.() ?? {}
|
|
45
|
+
} catch {
|
|
46
|
+
// Observability must never alter application behavior.
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const mergedFields = { ...fields, ...context }
|
|
50
|
+
transport[level](msg, mergedFields)
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
extension?.emit?.({
|
|
54
|
+
level,
|
|
55
|
+
namespace,
|
|
56
|
+
message: msg,
|
|
57
|
+
fields: { ...bindings, ...mergedFields },
|
|
58
|
+
time: Date.now(),
|
|
59
|
+
})
|
|
60
|
+
} catch {
|
|
61
|
+
// Remote logging is best-effort; the local line has already been written.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
debug: (msg, fields) => emit('debug', msg, fields),
|
|
67
|
+
info: (msg, fields) => emit('info', msg, fields),
|
|
68
|
+
warn: (msg, fields) => emit('warn', msg, fields),
|
|
69
|
+
error: (msg, fields) => emit('error', msg, fields),
|
|
70
|
+
child: (childBindings) =>
|
|
71
|
+
createExtendedLogger(
|
|
72
|
+
namespace,
|
|
73
|
+
transport.child(childBindings),
|
|
74
|
+
{ ...bindings, ...childBindings },
|
|
75
|
+
),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
21
79
|
/** Create (or reuse) a namespaced logger. `namespace` is attached as `name`. */
|
|
22
80
|
export function createLogger(namespace: string): Logger {
|
|
23
81
|
const existing = loggerRegistry.get(namespace)
|
|
24
82
|
if (existing) return existing
|
|
25
|
-
const created = selectTransport(namespace)
|
|
83
|
+
const created = createExtendedLogger(namespace, selectTransport(namespace))
|
|
26
84
|
loggerRegistry.set(namespace, created)
|
|
27
85
|
return created
|
|
28
86
|
}
|
|
@@ -69,3 +69,13 @@ export function getModules(): Module[] {
|
|
|
69
69
|
}
|
|
70
70
|
return modules
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Non-throwing counterpart of `getModules()` for call sites that have a
|
|
75
|
+
* meaningful degraded behavior when bootstrap has not run — route unit tests
|
|
76
|
+
* exercise a single handler without `registerModules()`, and a hard throw there
|
|
77
|
+
* turns an unrelated assertion into a bootstrap error.
|
|
78
|
+
*/
|
|
79
|
+
export function tryGetModules(): Module[] | null {
|
|
80
|
+
return getGlobalModules()
|
|
81
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export type TelemetryTraceCarrier = Record<string, string>
|
|
2
|
+
|
|
3
|
+
export type TelemetryRuntime = {
|
|
4
|
+
/**
|
|
5
|
+
* True only when the active SDK may safely use the process-global W3C
|
|
6
|
+
* propagator for cross-boundary extraction.
|
|
7
|
+
*/
|
|
8
|
+
canUseGlobalTracePropagation(): boolean
|
|
9
|
+
captureTraceContext(): TelemetryTraceCarrier
|
|
10
|
+
continueTrace<T>(
|
|
11
|
+
carrier: TelemetryTraceCarrier | undefined,
|
|
12
|
+
name: string,
|
|
13
|
+
fn: () => T,
|
|
14
|
+
options?: { kind?: 'internal' | 'server' | 'client' | 'producer' | 'consumer' },
|
|
15
|
+
): T
|
|
16
|
+
recordHttpDuration(method: string, route: string, status: number, startedAt: number): void
|
|
17
|
+
reportError(
|
|
18
|
+
error: unknown,
|
|
19
|
+
context?: {
|
|
20
|
+
module?: string
|
|
21
|
+
attributes?: Record<string, string | number | boolean | undefined>
|
|
22
|
+
},
|
|
23
|
+
): void
|
|
24
|
+
shutdown(): Promise<void>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const GLOBAL_KEY = Symbol.for('@open-mercato/shared.telemetryRuntime')
|
|
28
|
+
const ENABLED_BACKENDS = new Set(['console', 'signoz', 'newrelic', 'otlp'])
|
|
29
|
+
|
|
30
|
+
type TelemetryRuntimeStore = {
|
|
31
|
+
active?: TelemetryRuntime
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function store(): TelemetryRuntimeStore {
|
|
35
|
+
const globalStore = globalThis as unknown as Record<symbol, TelemetryRuntimeStore | undefined>
|
|
36
|
+
let current = globalStore[GLOBAL_KEY]
|
|
37
|
+
if (!current) {
|
|
38
|
+
current = {}
|
|
39
|
+
globalStore[GLOBAL_KEY] = current
|
|
40
|
+
}
|
|
41
|
+
return current
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* This check is intentionally owned by shared code so hosts can decide whether
|
|
46
|
+
* to dynamically import the telemetry package without evaluating that package.
|
|
47
|
+
*/
|
|
48
|
+
export function isTelemetryBackendEnabled(raw?: string): boolean {
|
|
49
|
+
const value = raw ?? (
|
|
50
|
+
typeof process === 'undefined'
|
|
51
|
+
? undefined
|
|
52
|
+
: process.env.TELEMETRY_BACKEND
|
|
53
|
+
)
|
|
54
|
+
return ENABLED_BACKENDS.has((value ?? '').trim().toLowerCase())
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function registerTelemetryRuntime(runtime: TelemetryRuntime): () => void {
|
|
58
|
+
store().active = runtime
|
|
59
|
+
return () => {
|
|
60
|
+
const current = store()
|
|
61
|
+
if (current.active === runtime) current.active = undefined
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getTelemetryRuntime(): TelemetryRuntime | undefined {
|
|
66
|
+
return store().active
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Test-only: clear the process-wide telemetry bridge. */
|
|
70
|
+
export function resetTelemetryRuntime(): void {
|
|
71
|
+
store().active = undefined
|
|
72
|
+
}
|