@mantiq/logging 0.0.1
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/README.md +19 -0
- package/package.json +55 -0
- package/src/LogManager.ts +133 -0
- package/src/LoggingServiceProvider.ts +54 -0
- package/src/contracts/Logger.ts +49 -0
- package/src/drivers/ConsoleDriver.ts +45 -0
- package/src/drivers/DailyDriver.ts +109 -0
- package/src/drivers/FileDriver.ts +54 -0
- package/src/drivers/NullDriver.ts +13 -0
- package/src/drivers/StackDriver.ts +24 -0
- package/src/formatters/JsonFormatter.ts +13 -0
- package/src/formatters/LineFormatter.ts +12 -0
- package/src/helpers/log.ts +13 -0
- package/src/index.ts +32 -0
- package/src/testing/LogFake.ts +96 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @mantiq/logging
|
|
2
|
+
|
|
3
|
+
Channel-based logging for MantiqJS — console, file, daily rotation, and stack drivers.
|
|
4
|
+
|
|
5
|
+
Part of [MantiqJS](https://github.com/abdullahkhan/mantiq) — a batteries-included TypeScript web framework for Bun.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @mantiq/logging
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Documentation
|
|
14
|
+
|
|
15
|
+
See the [MantiqJS repository](https://github.com/abdullahkhan/mantiq) for full documentation.
|
|
16
|
+
|
|
17
|
+
## License
|
|
18
|
+
|
|
19
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mantiq/logging",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Channel-based logging",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Abdullah Khan",
|
|
8
|
+
"homepage": "https://github.com/abdullahkhan/mantiq/tree/main/packages/logging",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/abdullahkhan/mantiq.git",
|
|
12
|
+
"directory": "packages/logging"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/abdullahkhan/mantiq/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mantiq",
|
|
19
|
+
"mantiqjs",
|
|
20
|
+
"bun",
|
|
21
|
+
"typescript",
|
|
22
|
+
"framework",
|
|
23
|
+
"logging"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.1.0"
|
|
27
|
+
},
|
|
28
|
+
"main": "./src/index.ts",
|
|
29
|
+
"types": "./src/index.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"bun": "./src/index.ts",
|
|
33
|
+
"default": "./src/index.ts"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src/",
|
|
38
|
+
"package.json",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
44
|
+
"test": "bun test",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"clean": "rm -rf dist"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@mantiq/core": "workspace:*"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"bun-types": "latest",
|
|
53
|
+
"typescript": "^5.7.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { DriverManager } from '@mantiq/core'
|
|
2
|
+
import type { LogLevel, LoggerDriver, LogConfig, ChannelConfig, LogFormatter } from './contracts/Logger.ts'
|
|
3
|
+
import { ConsoleDriver } from './drivers/ConsoleDriver.ts'
|
|
4
|
+
import { FileDriver } from './drivers/FileDriver.ts'
|
|
5
|
+
import { DailyDriver } from './drivers/DailyDriver.ts'
|
|
6
|
+
import { StackDriver } from './drivers/StackDriver.ts'
|
|
7
|
+
import { NullDriver } from './drivers/NullDriver.ts'
|
|
8
|
+
import { LineFormatter } from './formatters/LineFormatter.ts'
|
|
9
|
+
import { JsonFormatter } from './formatters/JsonFormatter.ts'
|
|
10
|
+
|
|
11
|
+
export class LogManager implements DriverManager<LoggerDriver>, LoggerDriver {
|
|
12
|
+
private readonly config: LogConfig
|
|
13
|
+
private readonly channels = new Map<string, LoggerDriver>()
|
|
14
|
+
private readonly customCreators = new Map<string, (config: ChannelConfig) => LoggerDriver>()
|
|
15
|
+
|
|
16
|
+
constructor(config?: Partial<LogConfig>) {
|
|
17
|
+
this.config = {
|
|
18
|
+
default: config?.default ?? 'console',
|
|
19
|
+
channels: config?.channels ?? {},
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── DriverManager ─────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
driver(name?: string): LoggerDriver {
|
|
26
|
+
const channelName = name ?? this.getDefaultDriver()
|
|
27
|
+
|
|
28
|
+
if (!this.channels.has(channelName)) {
|
|
29
|
+
this.channels.set(channelName, this.createDriver(channelName))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return this.channels.get(channelName)!
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
channel(name?: string): LoggerDriver {
|
|
36
|
+
return this.driver(name)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
extend(name: string, factory: (config: ChannelConfig) => LoggerDriver): void {
|
|
40
|
+
this.customCreators.set(name, factory)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getDefaultDriver(): string {
|
|
44
|
+
return this.config.default
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
forgetChannel(name: string): void {
|
|
48
|
+
this.channels.delete(name)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
forgetChannels(): void {
|
|
52
|
+
this.channels.clear()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── LoggerDriver (delegates to default channel) ───────────────────────────
|
|
56
|
+
|
|
57
|
+
log(level: LogLevel, message: string, context?: Record<string, any>): void {
|
|
58
|
+
this.driver().log(level, message, context)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
emergency(message: string, context?: Record<string, any>): void { this.driver().emergency(message, context) }
|
|
62
|
+
alert(message: string, context?: Record<string, any>): void { this.driver().alert(message, context) }
|
|
63
|
+
critical(message: string, context?: Record<string, any>): void { this.driver().critical(message, context) }
|
|
64
|
+
error(message: string, context?: Record<string, any>): void { this.driver().error(message, context) }
|
|
65
|
+
warning(message: string, context?: Record<string, any>): void { this.driver().warning(message, context) }
|
|
66
|
+
notice(message: string, context?: Record<string, any>): void { this.driver().notice(message, context) }
|
|
67
|
+
info(message: string, context?: Record<string, any>): void { this.driver().info(message, context) }
|
|
68
|
+
debug(message: string, context?: Record<string, any>): void { this.driver().debug(message, context) }
|
|
69
|
+
|
|
70
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
private createDriver(name: string): LoggerDriver {
|
|
73
|
+
const channelConfig = this.config.channels[name]
|
|
74
|
+
const driverName = channelConfig?.driver ?? name
|
|
75
|
+
|
|
76
|
+
const custom = this.customCreators.get(driverName)
|
|
77
|
+
if (custom) return custom(channelConfig ?? { driver: driverName })
|
|
78
|
+
|
|
79
|
+
if (!channelConfig && driverName === 'console') {
|
|
80
|
+
return new ConsoleDriver('console', 'debug')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!channelConfig) {
|
|
84
|
+
throw new Error(`Logging channel "${name}" is not configured. Define it in config/logging.ts or use extend().`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const formatter = this.resolveFormatter(channelConfig)
|
|
88
|
+
|
|
89
|
+
switch (driverName) {
|
|
90
|
+
case 'console':
|
|
91
|
+
return new ConsoleDriver(
|
|
92
|
+
name,
|
|
93
|
+
channelConfig.level ?? 'debug',
|
|
94
|
+
formatter,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
case 'file':
|
|
98
|
+
return new FileDriver(
|
|
99
|
+
name,
|
|
100
|
+
channelConfig.path as string ?? 'storage/logs/mantiq.log',
|
|
101
|
+
channelConfig.level ?? 'debug',
|
|
102
|
+
formatter,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
case 'daily':
|
|
106
|
+
return new DailyDriver(
|
|
107
|
+
name,
|
|
108
|
+
channelConfig.path as string ?? 'storage/logs/mantiq.log',
|
|
109
|
+
channelConfig.level ?? 'debug',
|
|
110
|
+
(channelConfig.days as number) ?? 14,
|
|
111
|
+
formatter,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
case 'stack': {
|
|
115
|
+
const channelNames = (channelConfig.channels as string[]) ?? []
|
|
116
|
+
const drivers = channelNames.map((ch) => this.driver(ch))
|
|
117
|
+
return new StackDriver(drivers)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case 'null':
|
|
121
|
+
return new NullDriver()
|
|
122
|
+
|
|
123
|
+
default:
|
|
124
|
+
throw new Error(`Unsupported logging driver: "${driverName}". Use extend() to register custom drivers.`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private resolveFormatter(config: ChannelConfig): LogFormatter | undefined {
|
|
129
|
+
if (config.formatter === 'json') return new JsonFormatter()
|
|
130
|
+
if (config.formatter === 'line') return new LineFormatter()
|
|
131
|
+
return undefined
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ServiceProvider, ConfigRepository } from '@mantiq/core'
|
|
2
|
+
import type { LogConfig } from './contracts/Logger.ts'
|
|
3
|
+
import { LogManager } from './LogManager.ts'
|
|
4
|
+
import { LOGGING_MANAGER } from './helpers/log.ts'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_CONFIG: LogConfig = {
|
|
7
|
+
default: 'stack',
|
|
8
|
+
channels: {
|
|
9
|
+
stack: {
|
|
10
|
+
driver: 'stack',
|
|
11
|
+
channels: ['console', 'daily'],
|
|
12
|
+
},
|
|
13
|
+
console: {
|
|
14
|
+
driver: 'console',
|
|
15
|
+
level: 'debug',
|
|
16
|
+
},
|
|
17
|
+
daily: {
|
|
18
|
+
driver: 'daily',
|
|
19
|
+
path: 'storage/logs/mantiq.log',
|
|
20
|
+
level: 'debug',
|
|
21
|
+
days: 14,
|
|
22
|
+
},
|
|
23
|
+
file: {
|
|
24
|
+
driver: 'file',
|
|
25
|
+
path: 'storage/logs/mantiq.log',
|
|
26
|
+
level: 'debug',
|
|
27
|
+
},
|
|
28
|
+
null: {
|
|
29
|
+
driver: 'null',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class LoggingServiceProvider extends ServiceProvider {
|
|
35
|
+
override register(): void {
|
|
36
|
+
this.app.singleton(LogManager, (c) => {
|
|
37
|
+
const config = c.make(ConfigRepository).get<LogConfig>('logging', DEFAULT_CONFIG)
|
|
38
|
+
|
|
39
|
+
// Resolve relative log paths to absolute from app base path
|
|
40
|
+
const basePath = this.app.basePath_()
|
|
41
|
+
if (config.channels) {
|
|
42
|
+
for (const ch of Object.values(config.channels)) {
|
|
43
|
+
if (typeof ch.path === 'string' && !ch.path.startsWith('/')) {
|
|
44
|
+
ch.path = `${basePath}/${ch.path}`
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return new LogManager(config)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
this.app.alias(LogManager, LOGGING_MANAGER)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type LogLevel = 'emergency' | 'alert' | 'critical' | 'error' | 'warning' | 'notice' | 'info' | 'debug'
|
|
2
|
+
|
|
3
|
+
export const LOG_LEVELS: Record<LogLevel, number> = {
|
|
4
|
+
emergency: 0,
|
|
5
|
+
alert: 1,
|
|
6
|
+
critical: 2,
|
|
7
|
+
error: 3,
|
|
8
|
+
warning: 4,
|
|
9
|
+
notice: 5,
|
|
10
|
+
info: 6,
|
|
11
|
+
debug: 7,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LogEntry {
|
|
15
|
+
level: LogLevel
|
|
16
|
+
message: string
|
|
17
|
+
context: Record<string, any>
|
|
18
|
+
timestamp: Date
|
|
19
|
+
channel: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LogFormatter {
|
|
23
|
+
format(entry: LogEntry): string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LoggerDriver {
|
|
27
|
+
log(level: LogLevel, message: string, context?: Record<string, any>): void
|
|
28
|
+
|
|
29
|
+
emergency(message: string, context?: Record<string, any>): void
|
|
30
|
+
alert(message: string, context?: Record<string, any>): void
|
|
31
|
+
critical(message: string, context?: Record<string, any>): void
|
|
32
|
+
error(message: string, context?: Record<string, any>): void
|
|
33
|
+
warning(message: string, context?: Record<string, any>): void
|
|
34
|
+
notice(message: string, context?: Record<string, any>): void
|
|
35
|
+
info(message: string, context?: Record<string, any>): void
|
|
36
|
+
debug(message: string, context?: Record<string, any>): void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ChannelConfig {
|
|
40
|
+
driver: string
|
|
41
|
+
level?: LogLevel
|
|
42
|
+
formatter?: 'line' | 'json'
|
|
43
|
+
[key: string]: unknown
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface LogConfig {
|
|
47
|
+
default: string
|
|
48
|
+
channels: Record<string, ChannelConfig>
|
|
49
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { LogLevel, LogEntry, LogFormatter, LoggerDriver } from '../contracts/Logger.ts'
|
|
2
|
+
import { LOG_LEVELS } from '../contracts/Logger.ts'
|
|
3
|
+
import { LineFormatter } from '../formatters/LineFormatter.ts'
|
|
4
|
+
|
|
5
|
+
export class ConsoleDriver implements LoggerDriver {
|
|
6
|
+
private readonly minLevel: number
|
|
7
|
+
private readonly formatter: LogFormatter
|
|
8
|
+
private readonly channelName: string
|
|
9
|
+
|
|
10
|
+
constructor(channel: string = 'console', minLevel: LogLevel = 'debug', formatter?: LogFormatter) {
|
|
11
|
+
this.channelName = channel
|
|
12
|
+
this.minLevel = LOG_LEVELS[minLevel]
|
|
13
|
+
this.formatter = formatter ?? new LineFormatter()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
log(level: LogLevel, message: string, context: Record<string, any> = {}): void {
|
|
17
|
+
if (LOG_LEVELS[level] > this.minLevel) return
|
|
18
|
+
|
|
19
|
+
const entry: LogEntry = {
|
|
20
|
+
level,
|
|
21
|
+
message,
|
|
22
|
+
context,
|
|
23
|
+
timestamp: new Date(),
|
|
24
|
+
channel: this.channelName,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const formatted = this.formatter.format(entry)
|
|
28
|
+
|
|
29
|
+
// Use stderr for error-level and above, stdout for the rest
|
|
30
|
+
if (LOG_LEVELS[level] <= LOG_LEVELS.error) {
|
|
31
|
+
process.stderr.write(formatted + '\n')
|
|
32
|
+
} else {
|
|
33
|
+
process.stdout.write(formatted + '\n')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
emergency(message: string, context?: Record<string, any>): void { this.log('emergency', message, context) }
|
|
38
|
+
alert(message: string, context?: Record<string, any>): void { this.log('alert', message, context) }
|
|
39
|
+
critical(message: string, context?: Record<string, any>): void { this.log('critical', message, context) }
|
|
40
|
+
error(message: string, context?: Record<string, any>): void { this.log('error', message, context) }
|
|
41
|
+
warning(message: string, context?: Record<string, any>): void { this.log('warning', message, context) }
|
|
42
|
+
notice(message: string, context?: Record<string, any>): void { this.log('notice', message, context) }
|
|
43
|
+
info(message: string, context?: Record<string, any>): void { this.log('info', message, context) }
|
|
44
|
+
debug(message: string, context?: Record<string, any>): void { this.log('debug', message, context) }
|
|
45
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { appendFile, mkdir, readdir, rm } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join, basename } from 'node:path'
|
|
3
|
+
import type { LogLevel, LogEntry, LogFormatter, LoggerDriver } from '../contracts/Logger.ts'
|
|
4
|
+
import { LOG_LEVELS } from '../contracts/Logger.ts'
|
|
5
|
+
import { LineFormatter } from '../formatters/LineFormatter.ts'
|
|
6
|
+
|
|
7
|
+
export class DailyDriver implements LoggerDriver {
|
|
8
|
+
private readonly basePath: string
|
|
9
|
+
private readonly days: number
|
|
10
|
+
private readonly minLevel: number
|
|
11
|
+
private readonly formatter: LogFormatter
|
|
12
|
+
private readonly channelName: string
|
|
13
|
+
private _lastDate: string = ''
|
|
14
|
+
private _currentPath: string = ''
|
|
15
|
+
private _dirEnsured = false
|
|
16
|
+
private _pruned = false
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
channel: string,
|
|
20
|
+
basePath: string,
|
|
21
|
+
minLevel: LogLevel = 'debug',
|
|
22
|
+
days: number = 14,
|
|
23
|
+
formatter?: LogFormatter,
|
|
24
|
+
) {
|
|
25
|
+
this.channelName = channel
|
|
26
|
+
this.basePath = basePath
|
|
27
|
+
this.days = days
|
|
28
|
+
this.minLevel = LOG_LEVELS[minLevel]
|
|
29
|
+
this.formatter = formatter ?? new LineFormatter()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
log(level: LogLevel, message: string, context: Record<string, any> = {}): void {
|
|
33
|
+
if (LOG_LEVELS[level] > this.minLevel) return
|
|
34
|
+
|
|
35
|
+
const entry: LogEntry = {
|
|
36
|
+
level,
|
|
37
|
+
message,
|
|
38
|
+
context,
|
|
39
|
+
timestamp: new Date(),
|
|
40
|
+
channel: this.channelName,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const line = this.formatter.format(entry) + '\n'
|
|
44
|
+
void this.writeLine(line, entry.timestamp)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async writeLine(line: string, now: Date): Promise<void> {
|
|
48
|
+
const dateStr = now.toISOString().slice(0, 10) // YYYY-MM-DD
|
|
49
|
+
if (dateStr !== this._lastDate) {
|
|
50
|
+
this._lastDate = dateStr
|
|
51
|
+
this._currentPath = this.pathForDate(dateStr)
|
|
52
|
+
this._dirEnsured = false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!this._dirEnsured) {
|
|
56
|
+
await mkdir(dirname(this._currentPath), { recursive: true })
|
|
57
|
+
this._dirEnsured = true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await appendFile(this._currentPath, line)
|
|
61
|
+
|
|
62
|
+
if (!this._pruned) {
|
|
63
|
+
this._pruned = true
|
|
64
|
+
void this.pruneOldFiles()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private pathForDate(dateStr: string): string {
|
|
69
|
+
// e.g., /storage/logs/mantiq-2024-03-19.log
|
|
70
|
+
const dir = dirname(this.basePath)
|
|
71
|
+
const name = basename(this.basePath, '.log')
|
|
72
|
+
return join(dir, `${name}-${dateStr}.log`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async pruneOldFiles(): Promise<void> {
|
|
76
|
+
const dir = dirname(this.basePath)
|
|
77
|
+
const prefix = basename(this.basePath, '.log')
|
|
78
|
+
const cutoff = Date.now() - this.days * 86400000
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const entries = await readdir(dir)
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
// Match files like mantiq-2024-03-19.log
|
|
84
|
+
const match = entry.match(new RegExp(`^${escapeRegExp(prefix)}-(\\d{4}-\\d{2}-\\d{2})\\.log$`))
|
|
85
|
+
if (match) {
|
|
86
|
+
const fileDate = new Date(match[1]! + 'T00:00:00Z')
|
|
87
|
+
if (fileDate.getTime() < cutoff) {
|
|
88
|
+
await rm(join(dir, entry)).catch(() => {})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Pruning is best-effort
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
emergency(message: string, context?: Record<string, any>): void { this.log('emergency', message, context) }
|
|
98
|
+
alert(message: string, context?: Record<string, any>): void { this.log('alert', message, context) }
|
|
99
|
+
critical(message: string, context?: Record<string, any>): void { this.log('critical', message, context) }
|
|
100
|
+
error(message: string, context?: Record<string, any>): void { this.log('error', message, context) }
|
|
101
|
+
warning(message: string, context?: Record<string, any>): void { this.log('warning', message, context) }
|
|
102
|
+
notice(message: string, context?: Record<string, any>): void { this.log('notice', message, context) }
|
|
103
|
+
info(message: string, context?: Record<string, any>): void { this.log('info', message, context) }
|
|
104
|
+
debug(message: string, context?: Record<string, any>): void { this.log('debug', message, context) }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function escapeRegExp(s: string): string {
|
|
108
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
109
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { appendFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import type { LogLevel, LogEntry, LogFormatter, LoggerDriver } from '../contracts/Logger.ts'
|
|
4
|
+
import { LOG_LEVELS } from '../contracts/Logger.ts'
|
|
5
|
+
import { LineFormatter } from '../formatters/LineFormatter.ts'
|
|
6
|
+
|
|
7
|
+
export class FileDriver implements LoggerDriver {
|
|
8
|
+
private readonly path: string
|
|
9
|
+
private readonly minLevel: number
|
|
10
|
+
private readonly formatter: LogFormatter
|
|
11
|
+
private readonly channelName: string
|
|
12
|
+
private _dirEnsured = false
|
|
13
|
+
|
|
14
|
+
constructor(channel: string, path: string, minLevel: LogLevel = 'debug', formatter?: LogFormatter) {
|
|
15
|
+
this.channelName = channel
|
|
16
|
+
this.path = path
|
|
17
|
+
this.minLevel = LOG_LEVELS[minLevel]
|
|
18
|
+
this.formatter = formatter ?? new LineFormatter()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
log(level: LogLevel, message: string, context: Record<string, any> = {}): void {
|
|
22
|
+
if (LOG_LEVELS[level] > this.minLevel) return
|
|
23
|
+
|
|
24
|
+
const entry: LogEntry = {
|
|
25
|
+
level,
|
|
26
|
+
message,
|
|
27
|
+
context,
|
|
28
|
+
timestamp: new Date(),
|
|
29
|
+
channel: this.channelName,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const line = this.formatter.format(entry) + '\n'
|
|
33
|
+
|
|
34
|
+
// Fire-and-forget — logging should never block the request
|
|
35
|
+
void this.writeLine(line)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private async writeLine(line: string): Promise<void> {
|
|
39
|
+
if (!this._dirEnsured) {
|
|
40
|
+
await mkdir(dirname(this.path), { recursive: true })
|
|
41
|
+
this._dirEnsured = true
|
|
42
|
+
}
|
|
43
|
+
await appendFile(this.path, line)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
emergency(message: string, context?: Record<string, any>): void { this.log('emergency', message, context) }
|
|
47
|
+
alert(message: string, context?: Record<string, any>): void { this.log('alert', message, context) }
|
|
48
|
+
critical(message: string, context?: Record<string, any>): void { this.log('critical', message, context) }
|
|
49
|
+
error(message: string, context?: Record<string, any>): void { this.log('error', message, context) }
|
|
50
|
+
warning(message: string, context?: Record<string, any>): void { this.log('warning', message, context) }
|
|
51
|
+
notice(message: string, context?: Record<string, any>): void { this.log('notice', message, context) }
|
|
52
|
+
info(message: string, context?: Record<string, any>): void { this.log('info', message, context) }
|
|
53
|
+
debug(message: string, context?: Record<string, any>): void { this.log('debug', message, context) }
|
|
54
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { LogLevel, LoggerDriver } from '../contracts/Logger.ts'
|
|
2
|
+
|
|
3
|
+
export class NullDriver implements LoggerDriver {
|
|
4
|
+
log(_level: LogLevel, _message: string, _context?: Record<string, any>): void {}
|
|
5
|
+
emergency(_message: string, _context?: Record<string, any>): void {}
|
|
6
|
+
alert(_message: string, _context?: Record<string, any>): void {}
|
|
7
|
+
critical(_message: string, _context?: Record<string, any>): void {}
|
|
8
|
+
error(_message: string, _context?: Record<string, any>): void {}
|
|
9
|
+
warning(_message: string, _context?: Record<string, any>): void {}
|
|
10
|
+
notice(_message: string, _context?: Record<string, any>): void {}
|
|
11
|
+
info(_message: string, _context?: Record<string, any>): void {}
|
|
12
|
+
debug(_message: string, _context?: Record<string, any>): void {}
|
|
13
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { LogLevel, LoggerDriver } from '../contracts/Logger.ts'
|
|
2
|
+
|
|
3
|
+
export class StackDriver implements LoggerDriver {
|
|
4
|
+
private readonly drivers: LoggerDriver[]
|
|
5
|
+
|
|
6
|
+
constructor(drivers: LoggerDriver[]) {
|
|
7
|
+
this.drivers = drivers
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
log(level: LogLevel, message: string, context?: Record<string, any>): void {
|
|
11
|
+
for (const driver of this.drivers) {
|
|
12
|
+
driver.log(level, message, context)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
emergency(message: string, context?: Record<string, any>): void { this.log('emergency', message, context) }
|
|
17
|
+
alert(message: string, context?: Record<string, any>): void { this.log('alert', message, context) }
|
|
18
|
+
critical(message: string, context?: Record<string, any>): void { this.log('critical', message, context) }
|
|
19
|
+
error(message: string, context?: Record<string, any>): void { this.log('error', message, context) }
|
|
20
|
+
warning(message: string, context?: Record<string, any>): void { this.log('warning', message, context) }
|
|
21
|
+
notice(message: string, context?: Record<string, any>): void { this.log('notice', message, context) }
|
|
22
|
+
info(message: string, context?: Record<string, any>): void { this.log('info', message, context) }
|
|
23
|
+
debug(message: string, context?: Record<string, any>): void { this.log('debug', message, context) }
|
|
24
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { LogEntry, LogFormatter } from '../contracts/Logger.ts'
|
|
2
|
+
|
|
3
|
+
export class JsonFormatter implements LogFormatter {
|
|
4
|
+
format(entry: LogEntry): string {
|
|
5
|
+
return JSON.stringify({
|
|
6
|
+
timestamp: entry.timestamp.toISOString(),
|
|
7
|
+
channel: entry.channel,
|
|
8
|
+
level: entry.level,
|
|
9
|
+
message: entry.message,
|
|
10
|
+
...(Object.keys(entry.context).length > 0 ? { context: entry.context } : {}),
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { LogEntry, LogFormatter } from '../contracts/Logger.ts'
|
|
2
|
+
|
|
3
|
+
export class LineFormatter implements LogFormatter {
|
|
4
|
+
format(entry: LogEntry): string {
|
|
5
|
+
const ts = entry.timestamp.toISOString()
|
|
6
|
+
const level = entry.level.toUpperCase().padEnd(9)
|
|
7
|
+
const ctx = Object.keys(entry.context).length > 0
|
|
8
|
+
? ' ' + JSON.stringify(entry.context)
|
|
9
|
+
: ''
|
|
10
|
+
return `[${ts}] ${entry.channel}.${level} ${entry.message}${ctx}`
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Application } from '@mantiq/core'
|
|
2
|
+
import type { LoggerDriver } from '../contracts/Logger.ts'
|
|
3
|
+
import type { LogManager } from '../LogManager.ts'
|
|
4
|
+
|
|
5
|
+
export const LOGGING_MANAGER = Symbol('LogManager')
|
|
6
|
+
|
|
7
|
+
export function log(): LogManager
|
|
8
|
+
export function log(channel: string): LoggerDriver
|
|
9
|
+
export function log(channel?: string): LogManager | LoggerDriver {
|
|
10
|
+
const manager = Application.getInstance().make<LogManager>(LOGGING_MANAGER)
|
|
11
|
+
if (channel === undefined) return manager
|
|
12
|
+
return manager.channel(channel)
|
|
13
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ── Contracts ─────────────────────────────────────────────────────────────────
|
|
2
|
+
export type {
|
|
3
|
+
LogLevel,
|
|
4
|
+
LogEntry,
|
|
5
|
+
LogFormatter,
|
|
6
|
+
LoggerDriver,
|
|
7
|
+
ChannelConfig,
|
|
8
|
+
LogConfig,
|
|
9
|
+
} from './contracts/Logger.ts'
|
|
10
|
+
export { LOG_LEVELS } from './contracts/Logger.ts'
|
|
11
|
+
|
|
12
|
+
// ── Core ──────────────────────────────────────────────────────────────────────
|
|
13
|
+
export { LogManager } from './LogManager.ts'
|
|
14
|
+
export { LoggingServiceProvider } from './LoggingServiceProvider.ts'
|
|
15
|
+
|
|
16
|
+
// ── Formatters ────────────────────────────────────────────────────────────────
|
|
17
|
+
export { LineFormatter } from './formatters/LineFormatter.ts'
|
|
18
|
+
export { JsonFormatter } from './formatters/JsonFormatter.ts'
|
|
19
|
+
|
|
20
|
+
// ── Drivers ───────────────────────────────────────────────────────────────────
|
|
21
|
+
export { ConsoleDriver } from './drivers/ConsoleDriver.ts'
|
|
22
|
+
export { FileDriver } from './drivers/FileDriver.ts'
|
|
23
|
+
export { DailyDriver } from './drivers/DailyDriver.ts'
|
|
24
|
+
export { StackDriver } from './drivers/StackDriver.ts'
|
|
25
|
+
export { NullDriver } from './drivers/NullDriver.ts'
|
|
26
|
+
|
|
27
|
+
// ── Testing ───────────────────────────────────────────────────────────────────
|
|
28
|
+
export { LogFake } from './testing/LogFake.ts'
|
|
29
|
+
export type { LoggedMessage } from './testing/LogFake.ts'
|
|
30
|
+
|
|
31
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
32
|
+
export { log, LOGGING_MANAGER } from './helpers/log.ts'
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { LogLevel, LoggerDriver } from '../contracts/Logger.ts'
|
|
2
|
+
import { LOG_LEVELS } from '../contracts/Logger.ts'
|
|
3
|
+
|
|
4
|
+
export interface LoggedMessage {
|
|
5
|
+
level: LogLevel
|
|
6
|
+
message: string
|
|
7
|
+
context: Record<string, any>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class LogFake implements LoggerDriver {
|
|
11
|
+
private readonly logged: LoggedMessage[] = []
|
|
12
|
+
|
|
13
|
+
log(level: LogLevel, message: string, context: Record<string, any> = {}): void {
|
|
14
|
+
this.logged.push({ level, message, context })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
emergency(message: string, context?: Record<string, any>): void { this.log('emergency', message, context ?? {}) }
|
|
18
|
+
alert(message: string, context?: Record<string, any>): void { this.log('alert', message, context ?? {}) }
|
|
19
|
+
critical(message: string, context?: Record<string, any>): void { this.log('critical', message, context ?? {}) }
|
|
20
|
+
error(message: string, context?: Record<string, any>): void { this.log('error', message, context ?? {}) }
|
|
21
|
+
warning(message: string, context?: Record<string, any>): void { this.log('warning', message, context ?? {}) }
|
|
22
|
+
notice(message: string, context?: Record<string, any>): void { this.log('notice', message, context ?? {}) }
|
|
23
|
+
info(message: string, context?: Record<string, any>): void { this.log('info', message, context ?? {}) }
|
|
24
|
+
debug(message: string, context?: Record<string, any>): void { this.log('debug', message, context ?? {}) }
|
|
25
|
+
|
|
26
|
+
// ── Assertions ────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
assertLogged(level: LogLevel, message?: string | RegExp, count?: number): void {
|
|
29
|
+
const matches = this.matching(level, message)
|
|
30
|
+
if (matches.length === 0) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Expected log at level [${level}]${message ? ` matching "${message}"` : ''} but none was found.\n` +
|
|
33
|
+
`Logged messages: ${JSON.stringify(this.logged, null, 2)}`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
if (count !== undefined && matches.length !== count) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Expected ${count} log(s) at level [${level}]${message ? ` matching "${message}"` : ''} but found ${matches.length}.`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
assertNotLogged(level: LogLevel, message?: string | RegExp): void {
|
|
44
|
+
const matches = this.matching(level, message)
|
|
45
|
+
if (matches.length > 0) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Unexpected log at level [${level}]${message ? ` matching "${message}"` : ''} was found.\n` +
|
|
48
|
+
`Match: ${JSON.stringify(matches[0])}`,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
assertNothingLogged(): void {
|
|
54
|
+
if (this.logged.length > 0) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Expected no logs but ${this.logged.length} were recorded.\n` +
|
|
57
|
+
`First: ${JSON.stringify(this.logged[0])}`,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
assertLoggedCount(count: number): void {
|
|
63
|
+
if (this.logged.length !== count) {
|
|
64
|
+
throw new Error(`Expected ${count} log(s) but ${this.logged.length} were recorded.`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Inspection ────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
all(): LoggedMessage[] {
|
|
71
|
+
return [...this.logged]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
forLevel(level: LogLevel): LoggedMessage[] {
|
|
75
|
+
return this.logged.filter((m) => m.level === level)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
hasLogged(level: LogLevel, message?: string | RegExp): boolean {
|
|
79
|
+
return this.matching(level, message).length > 0
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
reset(): void {
|
|
83
|
+
this.logged.length = 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
private matching(level: LogLevel, message?: string | RegExp): LoggedMessage[] {
|
|
89
|
+
return this.logged.filter((m) => {
|
|
90
|
+
if (m.level !== level) return false
|
|
91
|
+
if (message === undefined) return true
|
|
92
|
+
if (typeof message === 'string') return m.message === message
|
|
93
|
+
return message.test(m.message)
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|