@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.
@@ -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 to app root (works for @app modules)
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 resolved = path.join(appRoot, args.path.slice(2))
51
- // Try with .ts extension if base path doesn't exist
52
- if (!fs.existsSync(resolved) && fs.existsSync(resolved + '.ts')) {
53
- return { path: resolved + '.ts' }
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
- // Also check for /index.ts if it's a directory
56
- if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory() && fs.existsSync(path.join(resolved, 'index.ts'))) {
57
- return { path: path.join(resolved, 'index.ts') }
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(tsPath: string, allowRecovery: boolean = true): Promise<Record<string, unknown>> {
305
- const jsPath = tsPath.replace(/\.ts$/, '.mjs')
306
- const appRoot = path.dirname(path.dirname(path.dirname(tsPath)))
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 data = await loadBootstrapData(appRoot)
510
- const bootstrap = createBootstrap(data)
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: jest.fn(),
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',
@@ -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
+ })
@@ -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 { getModules } from '../modules/registry'
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
- const modules = getModules()
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
+ }
@@ -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
+ }