@wippy-fe/log 0.0.19

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/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@wippy-fe/log",
3
+ "version": "0.0.19",
4
+ "description": "Logging layer for Wippy micro-frontend platform",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.mjs",
10
+ "types": "./dist/index.d.mts"
11
+ },
12
+ "./default-receiver": {
13
+ "import": "./dist/default-receiver.mjs",
14
+ "types": "./dist/default-receiver.d.mts"
15
+ },
16
+ "./logger": {
17
+ "import": "./dist/logger.mjs",
18
+ "types": "./dist/logger.d.mts"
19
+ },
20
+ "./sentry-transport": {
21
+ "import": "./dist/sentry-transport.mjs",
22
+ "types": "./dist/sentry-transport.d.mts"
23
+ },
24
+ "./console-transport": {
25
+ "import": "./dist/console-transport.mjs",
26
+ "types": "./dist/console-transport.d.mts"
27
+ },
28
+ "./gelf-transport": {
29
+ "import": "./dist/gelf-transport.mjs",
30
+ "types": "./dist/gelf-transport.d.mts"
31
+ }
32
+ },
33
+ "main": "dist/index.mjs",
34
+ "module": "dist/index.mjs",
35
+ "types": "dist/index.d.mts",
36
+ "scripts": {
37
+ "build:types": "tsup"
38
+ },
39
+ "peerDependencies": {
40
+ "@sentry/browser": ">=8.0.0"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@sentry/browser": {
44
+ "optional": true
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,68 @@
1
+ /* eslint-disable no-console -- this IS the console transport */
2
+ import type { LogTransport, NormalizedLogEntry } from './types'
3
+ import { LogLevel, reconstructError } from './types'
4
+
5
+ export interface ConsoleTransportOptions {
6
+ collapsed?: boolean
7
+ }
8
+
9
+ function formatPrefix(entry: NormalizedLogEntry): string {
10
+ const { source } = entry
11
+ if (source.resourceId)
12
+ return `[${source.layer}:${source.resourceType ?? 'unknown'}:${source.resourceId}]`
13
+ return `[${source.layer}]`
14
+ }
15
+
16
+ function collectMetadata(entry: NormalizedLogEntry): Record<string, unknown> | null {
17
+ const meta: Record<string, unknown> = {}
18
+ if (entry.callerStack)
19
+ meta.callerStack = entry.callerStack
20
+ if (entry.extra)
21
+ meta.extra = entry.extra
22
+ if (Object.keys(entry.tags).length > 0)
23
+ meta.tags = entry.tags
24
+ if (Object.keys(entry.contexts).length > 0)
25
+ meta.contexts = entry.contexts
26
+ if (Object.keys(meta).length === 0)
27
+ return null
28
+ return meta
29
+ }
30
+
31
+ export function consoleTransport(options?: ConsoleTransportOptions): LogTransport {
32
+ return {
33
+ name: 'console',
34
+ handle(entry: NormalizedLogEntry) {
35
+ const prefix = formatPrefix(entry)
36
+ const message = `${prefix} ${entry.message}`
37
+ const meta = collectMetadata(entry)
38
+
39
+ if (entry.subAction === 'captureException' && entry.error) {
40
+ const err = reconstructError(entry.error)
41
+
42
+ if (options?.collapsed && entry.breadcrumbs.length > 0) {
43
+ console.groupCollapsed(message)
44
+ console.error(err)
45
+ if (meta)
46
+ console.log(meta)
47
+ console.log('Breadcrumbs:', entry.breadcrumbs)
48
+ console.groupEnd()
49
+ }
50
+ else {
51
+ console.error(message, err, ...(meta ? [meta] : []))
52
+ }
53
+ return
54
+ }
55
+
56
+ const args: unknown[] = meta ? [message, meta] : [message]
57
+
58
+ if (entry.level <= LogLevel.Error)
59
+ console.error(...args)
60
+ else if (entry.level <= LogLevel.Warning)
61
+ console.warn(...args)
62
+ else if (entry.level <= LogLevel.Info)
63
+ console.info(...args)
64
+ else
65
+ console.debug(...args)
66
+ },
67
+ }
68
+ }
@@ -0,0 +1,27 @@
1
+ import type { LogReceiverHandle, LogTransport } from './types'
2
+ import { consoleTransport } from './console-transport'
3
+ import { createReceiverCore } from './receiver-core'
4
+
5
+ export function createDefaultLogReceiver(env: Record<string, string>): LogReceiverHandle {
6
+ const transports: LogTransport[] = [consoleTransport()]
7
+
8
+ if (env.PUBLIC_SENTRY_DSN) {
9
+ const dsn = env.PUBLIC_SENTRY_DSN
10
+ import('./sentry-transport').then(({ sentryTransport }) => {
11
+ transports.push(sentryTransport({ dsn }))
12
+ }).catch((e) => {
13
+ console.warn('[@wippy-fe/log] Sentry transport unavailable:', e)
14
+ })
15
+ }
16
+
17
+ if (env.PUBLIC_GELF_URL) {
18
+ const url = env.PUBLIC_GELF_URL
19
+ import('./gelf-transport').then(({ gelfTransport }) => {
20
+ transports.push(gelfTransport({ url }))
21
+ }).catch((e) => {
22
+ console.warn('[@wippy-fe/log] GELF transport unavailable:', e)
23
+ })
24
+ }
25
+
26
+ return createReceiverCore(transports)
27
+ }
@@ -0,0 +1,93 @@
1
+ import type { LogTransport, NormalizedLogEntry } from './types'
2
+
3
+ export interface GelfTransportOptions {
4
+ url: string
5
+ host?: string
6
+ facility?: string
7
+ }
8
+
9
+ const BATCH_INTERVAL = 1000
10
+ const MAX_BATCH_SIZE = 20
11
+
12
+ export function gelfTransport(options: GelfTransportOptions): LogTransport {
13
+ const { url, host = 'wippy', facility } = options
14
+ let batch: object[] = []
15
+ let timer: ReturnType<typeof setTimeout> | null = null
16
+
17
+ function sendBatch(): void {
18
+ if (batch.length === 0)
19
+ return
20
+ const entries = batch
21
+ batch = []
22
+ timer = null
23
+
24
+ for (const entry of entries) {
25
+ fetch(url, {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ body: JSON.stringify(entry),
29
+ }).catch(() => {})
30
+ }
31
+ }
32
+
33
+ function scheduleSend(): void {
34
+ if (!timer)
35
+ timer = setTimeout(sendBatch, BATCH_INTERVAL)
36
+ }
37
+
38
+ function toGelf(entry: NormalizedLogEntry): object {
39
+ const gelf: Record<string, unknown> = {
40
+ version: '1.1',
41
+ host,
42
+ short_message: entry.message,
43
+ timestamp: entry.timestamp / 1000,
44
+ level: entry.level,
45
+ _wippy_layer: entry.source.layer,
46
+ }
47
+
48
+ if (facility)
49
+ gelf.facility = facility
50
+ if (entry.source.resourceId)
51
+ gelf._wippy_resourceId = entry.source.resourceId
52
+ if (entry.source.resourceType)
53
+ gelf._wippy_resourceType = entry.source.resourceType
54
+ if (entry.source.nestingDepth != null)
55
+ gelf._wippy_nestingDepth = entry.source.nestingDepth
56
+
57
+ if (entry.error?.stack)
58
+ gelf.full_message = entry.error.stack
59
+
60
+ for (const [key, value] of Object.entries(entry.tags))
61
+ gelf[`_tag_${key}`] = value
62
+
63
+ if (entry.extra) {
64
+ for (const [key, value] of Object.entries(entry.extra))
65
+ gelf[`_extra_${key}`] = typeof value === 'string' ? value : JSON.stringify(value)
66
+ }
67
+
68
+ return gelf
69
+ }
70
+
71
+ return {
72
+ name: 'gelf',
73
+ handle(entry: NormalizedLogEntry) {
74
+ batch.push(toGelf(entry))
75
+ if (batch.length >= MAX_BATCH_SIZE)
76
+ sendBatch()
77
+ else
78
+ scheduleSend()
79
+ },
80
+ async flush() {
81
+ if (timer) {
82
+ clearTimeout(timer)
83
+ timer = null
84
+ }
85
+ sendBatch()
86
+ },
87
+ destroy() {
88
+ if (timer)
89
+ clearTimeout(timer)
90
+ batch = []
91
+ },
92
+ }
93
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { LogReceiverHandle, LogReceiverOptions } from './types'
2
+ import { createReceiverCore } from './receiver-core'
3
+
4
+ export type {
5
+ Breadcrumb,
6
+ CmdLogPayload,
7
+ CmdLogSubAction,
8
+ CreateLoggerOptions,
9
+ LoggerApi,
10
+ LogLevelValue,
11
+ LogReceiverHandle,
12
+ LogReceiverOptions,
13
+ LogSource,
14
+ LogTransport,
15
+ NormalizedLogEntry,
16
+ } from './types'
17
+
18
+ export { LogLevel } from './types'
19
+
20
+ export function createLogReceiver(options: LogReceiverOptions): LogReceiverHandle {
21
+ return createReceiverCore(options.transports, options.context)
22
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { CmdLogSubAction, CreateLoggerOptions, LoggerApi, LogLevelValue, LogSource } from './types'
2
+ import { captureCallerStack, LogLevel, serializeError } from './types'
3
+
4
+ const DEFAULT_MESSAGE_TYPE = '@gen2-chat'
5
+
6
+ // Frames: caller → info/warn/error → logWithStack → send
7
+ const LOG_FRAMES_TO_SKIP = 3
8
+ // Frames: caller → captureException/captureMessage → send
9
+ const CAPTURE_FRAMES_TO_SKIP = 2
10
+
11
+ export function createLogger(options: CreateLoggerOptions): LoggerApi {
12
+ const { source, messageType = DEFAULT_MESSAGE_TYPE } = options
13
+ const target = options.target ?? window.parent
14
+
15
+ function send(subAction: CmdLogSubAction, data: Record<string, unknown>, callerStack?: string): void {
16
+ target?.postMessage(JSON.stringify({
17
+ type: messageType,
18
+ action: 'cmd-log',
19
+ subAction,
20
+ data,
21
+ source,
22
+ timestamp: Date.now(),
23
+ callerStack,
24
+ }), '*')
25
+ }
26
+
27
+ function log(level: LogLevelValue, message: string, data?: Record<string, unknown>): void {
28
+ send('log', { message, level, extra: data })
29
+ }
30
+
31
+ function logWithStack(level: LogLevelValue, message: string, data?: Record<string, unknown>): void {
32
+ send('log', { message, level, extra: data }, captureCallerStack(LOG_FRAMES_TO_SKIP))
33
+ }
34
+
35
+ return {
36
+ debug: (message, data) => log(LogLevel.Debug, message, data),
37
+ info: (message, data) => logWithStack(LogLevel.Info, message, data),
38
+ warn: (message, data) => logWithStack(LogLevel.Warning, message, data),
39
+ error: (message, data) => logWithStack(LogLevel.Error, message, data),
40
+
41
+ captureException(error, extra) {
42
+ send('captureException', { ...serializeError(error), extra }, captureCallerStack(CAPTURE_FRAMES_TO_SKIP))
43
+ },
44
+
45
+ captureMessage(message, level, extra) {
46
+ send('captureMessage', { message, level: level ?? LogLevel.Info, extra }, captureCallerStack(CAPTURE_FRAMES_TO_SKIP))
47
+ },
48
+
49
+ addBreadcrumb(breadcrumb) {
50
+ send('addBreadcrumb', { ...breadcrumb, timestamp: breadcrumb.timestamp ?? Date.now() })
51
+ },
52
+
53
+ setContext(name, data) {
54
+ send('setContext', { name, data })
55
+ },
56
+
57
+ setTag(key, value) {
58
+ send('setTag', { key, value })
59
+ },
60
+ }
61
+ }
62
+
63
+ export function createChildLogger(source: Omit<LogSource, 'layer'>): LoggerApi {
64
+ return createLogger({ source: { layer: 'child', ...source } })
65
+ }
66
+
67
+ export function createHostLogger(): LoggerApi {
68
+ return createLogger({ source: { layer: 'host' } })
69
+ }
@@ -0,0 +1,150 @@
1
+ import type { Breadcrumb, LogReceiverHandle, LogSource, LogTransport, NormalizedLogEntry } from './types'
2
+ import { LogLevel } from './types'
3
+
4
+ interface SourceState {
5
+ breadcrumbs: Breadcrumb[]
6
+ contexts: Record<string, Record<string, unknown>>
7
+ tags: Record<string, string>
8
+ }
9
+
10
+ const MAX_BREADCRUMBS = 100
11
+ const DEFAULT_MESSAGE_TYPE = '@gen2-chat'
12
+
13
+ function getSourceKey(source: LogSource): string {
14
+ return `${source.layer}:${source.resourceId ?? 'unknown'}`
15
+ }
16
+
17
+ function isValidSource(source: unknown): source is LogSource {
18
+ return source != null
19
+ && typeof source === 'object'
20
+ && 'layer' in source
21
+ && (source.layer === 'child' || source.layer === 'host')
22
+ }
23
+
24
+ export function createReceiverCore(
25
+ transports: LogTransport[],
26
+ globalContext?: Record<string, unknown>,
27
+ messageType: string = DEFAULT_MESSAGE_TYPE,
28
+ ): LogReceiverHandle {
29
+ const sourceStates = new Map<string, SourceState>()
30
+
31
+ function getState(sourceKey: string): SourceState {
32
+ let state = sourceStates.get(sourceKey)
33
+ if (!state) {
34
+ state = { breadcrumbs: [], contexts: {}, tags: {} }
35
+ sourceStates.set(sourceKey, state)
36
+ }
37
+ return state
38
+ }
39
+
40
+ function dispatch(entry: NormalizedLogEntry): void {
41
+ for (const transport of transports)
42
+ transport.handle(entry)
43
+ }
44
+
45
+ function handleMessage(event: MessageEvent): void {
46
+ if (typeof event.data !== 'string')
47
+ return
48
+
49
+ let msg: any
50
+ try {
51
+ msg = JSON.parse(event.data)
52
+ }
53
+ catch {
54
+ return
55
+ }
56
+
57
+ if (msg.type !== messageType || msg.action !== 'cmd-log')
58
+ return
59
+
60
+ const { subAction, data, source, timestamp, callerStack } = msg
61
+ if (!data || typeof data !== 'object')
62
+ return
63
+
64
+ const resolvedSource: LogSource = isValidSource(source)
65
+ ? source
66
+ : { layer: 'child' }
67
+ const sourceKey = getSourceKey(resolvedSource)
68
+ const state = getState(sourceKey)
69
+
70
+ switch (subAction) {
71
+ case 'addBreadcrumb': {
72
+ state.breadcrumbs.push(data as Breadcrumb)
73
+ if (state.breadcrumbs.length > MAX_BREADCRUMBS)
74
+ state.breadcrumbs.shift()
75
+ break
76
+ }
77
+ case 'setContext': {
78
+ const name = data.name as string | undefined
79
+ if (!name)
80
+ break
81
+ if (data.data === null)
82
+ delete state.contexts[name]
83
+ else
84
+ state.contexts[name] = data.data as Record<string, unknown>
85
+ break
86
+ }
87
+ case 'setTag': {
88
+ const key = data.key as string | undefined
89
+ if (!key)
90
+ break
91
+ if (data.value === null)
92
+ delete state.tags[key]
93
+ else
94
+ state.tags[key] = data.value as string
95
+ break
96
+ }
97
+ case 'log':
98
+ case 'captureException':
99
+ case 'captureMessage': {
100
+ const entry: NormalizedLogEntry = {
101
+ subAction,
102
+ level: data.level ?? LogLevel.Info,
103
+ message: data.message ?? '',
104
+ timestamp: timestamp ?? Date.now(),
105
+ source: resolvedSource,
106
+ breadcrumbs: [...state.breadcrumbs],
107
+ contexts: {
108
+ ...(globalContext ? { global: globalContext } : {}),
109
+ ...state.contexts,
110
+ },
111
+ tags: { ...state.tags },
112
+ }
113
+ if (callerStack)
114
+ entry.callerStack = callerStack
115
+ if (data.extra)
116
+ entry.extra = data.extra as Record<string, unknown>
117
+ if (subAction === 'captureException') {
118
+ entry.error = {
119
+ name: (data.name as string) ?? 'Error',
120
+ message: (data.message as string) ?? '',
121
+ stack: data.stack as string | undefined,
122
+ }
123
+ entry.level = data.level ?? LogLevel.Error
124
+ }
125
+ dispatch(entry)
126
+ break
127
+ }
128
+ }
129
+ }
130
+
131
+ window.addEventListener('message', handleMessage)
132
+
133
+ function onUnload(): void {
134
+ for (const transport of transports)
135
+ transport.flush?.()
136
+ }
137
+ window.addEventListener('beforeunload', onUnload)
138
+
139
+ return {
140
+ destroy() {
141
+ window.removeEventListener('message', handleMessage)
142
+ window.removeEventListener('beforeunload', onUnload)
143
+ for (const transport of transports) {
144
+ transport.flush?.()
145
+ transport.destroy?.()
146
+ }
147
+ sourceStates.clear()
148
+ },
149
+ }
150
+ }
@@ -0,0 +1,112 @@
1
+ import type { LogTransport, NormalizedLogEntry } from './types'
2
+ import { LogLevel, reconstructError } from './types'
3
+
4
+ export interface SentryTransportOptions {
5
+ dsn: string
6
+ environment?: string
7
+ release?: string
8
+ tracesSampleRate?: number
9
+ }
10
+
11
+ type SentryModule = typeof import('@sentry/browser')
12
+
13
+ let sentryInitialized = false
14
+ let Sentry: SentryModule | null = null
15
+
16
+ async function ensureSentry(options: SentryTransportOptions): Promise<SentryModule | null> {
17
+ if (sentryInitialized)
18
+ return Sentry
19
+ try {
20
+ Sentry = await import('@sentry/browser')
21
+ Sentry.init({
22
+ dsn: options.dsn,
23
+ environment: options.environment,
24
+ release: options.release,
25
+ tracesSampleRate: options.tracesSampleRate ?? 0,
26
+ })
27
+ sentryInitialized = true
28
+ }
29
+ catch (e) {
30
+ console.warn('[@wippy-fe/log] Failed to load @sentry/browser:', e)
31
+ }
32
+ return Sentry
33
+ }
34
+
35
+ function toSentryLevel(level: number): 'fatal' | 'error' | 'warning' | 'info' | 'debug' {
36
+ if (level <= LogLevel.Alert)
37
+ return 'fatal'
38
+ if (level <= LogLevel.Error)
39
+ return 'error'
40
+ if (level <= LogLevel.Warning)
41
+ return 'warning'
42
+ if (level <= LogLevel.Info)
43
+ return 'info'
44
+ return 'debug'
45
+ }
46
+
47
+ export function sentryTransport(options: SentryTransportOptions): LogTransport {
48
+ const ready = ensureSentry(options)
49
+
50
+ return {
51
+ name: 'sentry',
52
+ handle(entry: NormalizedLogEntry) {
53
+ ready.then((sentry) => {
54
+ if (!sentry)
55
+ return
56
+
57
+ sentry.withScope((scope) => {
58
+ scope.setTag('wippy.layer', entry.source.layer)
59
+ if (entry.source.resourceId)
60
+ scope.setTag('wippy.resourceId', entry.source.resourceId)
61
+ if (entry.source.resourceType)
62
+ scope.setTag('wippy.resourceType', entry.source.resourceType)
63
+ if (entry.source.nestingDepth != null)
64
+ scope.setTag('wippy.nestingDepth', String(entry.source.nestingDepth))
65
+
66
+ for (const [key, value] of Object.entries(entry.tags))
67
+ scope.setTag(key, value)
68
+
69
+ scope.setContext('wippy', { ...entry.source })
70
+ for (const [name, data] of Object.entries(entry.contexts))
71
+ scope.setContext(name, data)
72
+
73
+ for (const bc of entry.breadcrumbs) {
74
+ sentry.addBreadcrumb({
75
+ category: bc.category,
76
+ message: bc.message,
77
+ data: bc.data,
78
+ level: toSentryLevel(bc.level ?? LogLevel.Info),
79
+ type: bc.type,
80
+ timestamp: bc.timestamp ? bc.timestamp / 1000 : undefined,
81
+ })
82
+ }
83
+
84
+ if (entry.extra)
85
+ scope.setExtras(entry.extra)
86
+
87
+ if (entry.callerStack)
88
+ scope.setExtra('callerStack', entry.callerStack)
89
+
90
+ if (entry.subAction === 'captureException' && entry.error) {
91
+ const err = reconstructError(entry.error)
92
+ if (!err.stack && entry.callerStack)
93
+ err.stack = entry.callerStack
94
+ sentry.captureException(err)
95
+ }
96
+ else {
97
+ sentry.captureMessage(entry.message, toSentryLevel(entry.level))
98
+ }
99
+ })
100
+ })
101
+ },
102
+ async flush() {
103
+ const sentry = await ready
104
+ if (sentry)
105
+ await sentry.flush(2000)
106
+ },
107
+ destroy() {
108
+ if (Sentry)
109
+ Sentry.close()
110
+ },
111
+ }
112
+ }
package/src/types.ts ADDED
@@ -0,0 +1,17 @@
1
+ export {
2
+ type Breadcrumb,
3
+ captureCallerStack,
4
+ type CmdLogPayload,
5
+ type CmdLogSubAction,
6
+ type CreateLoggerOptions,
7
+ type LoggerApi,
8
+ LogLevel,
9
+ type LogLevelValue,
10
+ type LogReceiverHandle,
11
+ type LogReceiverOptions,
12
+ type LogSource,
13
+ type LogTransport,
14
+ type NormalizedLogEntry,
15
+ reconstructError,
16
+ serializeError,
17
+ } from '../../../src/shared/logging/types'