log.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import path from "path"
  2. import fs from "fs/promises"
  3. import { createWriteStream } from "fs"
  4. import * as Global from "../global"
  5. import z from "zod"
  6. import { Glob } from "./glob"
  7. export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
  8. export type Level = z.infer<typeof Level>
  9. const levelPriority: Record<Level, number> = {
  10. DEBUG: 0,
  11. INFO: 1,
  12. WARN: 2,
  13. ERROR: 3,
  14. }
  15. const keep = 10
  16. let level: Level = "INFO"
  17. function shouldLog(input: Level): boolean {
  18. return levelPriority[input] >= levelPriority[level]
  19. }
  20. export type Logger = {
  21. debug(message?: any, extra?: Record<string, any>): void
  22. info(message?: any, extra?: Record<string, any>): void
  23. error(message?: any, extra?: Record<string, any>): void
  24. warn(message?: any, extra?: Record<string, any>): void
  25. tag(key: string, value: string): Logger
  26. clone(): Logger
  27. time(
  28. message: string,
  29. extra?: Record<string, any>,
  30. ): {
  31. stop(): void
  32. [Symbol.dispose](): void
  33. }
  34. }
  35. const loggers = new Map<string, Logger>()
  36. export const Default = create({ service: "default" })
  37. export interface Options {
  38. print: boolean
  39. dev?: boolean
  40. level?: Level
  41. }
  42. let logpath = ""
  43. export function file() {
  44. return logpath
  45. }
  46. let write = (msg: any) => {
  47. process.stderr.write(msg)
  48. return msg.length
  49. }
  50. export async function init(options: Options) {
  51. if (options.level) level = options.level
  52. void cleanup(Global.Path.log)
  53. if (options.print) return
  54. logpath = path.join(
  55. Global.Path.log,
  56. options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
  57. )
  58. await fs.truncate(logpath).catch(() => {})
  59. const stream = createWriteStream(logpath, { flags: "a" })
  60. write = async (msg: any) => {
  61. return new Promise((resolve, reject) => {
  62. stream.write(msg, (err) => {
  63. if (err) reject(err)
  64. else resolve(msg.length)
  65. })
  66. })
  67. }
  68. }
  69. async function cleanup(dir: string) {
  70. const files = (
  71. await Glob.scan("????-??-??T??????.log", {
  72. cwd: dir,
  73. absolute: false,
  74. include: "file",
  75. }).catch(() => [])
  76. )
  77. .filter((file) => path.basename(file) === file)
  78. .sort()
  79. if (files.length <= keep) return
  80. const doomed = files.slice(0, -keep)
  81. await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
  82. }
  83. function formatError(error: Error, depth = 0): string {
  84. const result = error.message
  85. return error.cause instanceof Error && depth < 10
  86. ? result + " Caused by: " + formatError(error.cause, depth + 1)
  87. : result
  88. }
  89. let last = Date.now()
  90. export function create(tags?: Record<string, any>) {
  91. tags = tags || {}
  92. const service = tags["service"]
  93. if (service && typeof service === "string") {
  94. const cached = loggers.get(service)
  95. if (cached) {
  96. return cached
  97. }
  98. }
  99. function build(message: any, extra?: Record<string, any>) {
  100. const prefix = Object.entries({
  101. ...tags,
  102. ...extra,
  103. })
  104. .filter(([_, value]) => value !== undefined && value !== null)
  105. .map(([key, value]) => {
  106. const prefix = `${key}=`
  107. if (value instanceof Error) return prefix + formatError(value)
  108. if (typeof value === "object") return prefix + JSON.stringify(value)
  109. return prefix + value
  110. })
  111. .join(" ")
  112. const next = new Date()
  113. const diff = next.getTime() - last
  114. last = next.getTime()
  115. return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
  116. }
  117. const result: Logger = {
  118. debug(message?: any, extra?: Record<string, any>) {
  119. if (shouldLog("DEBUG")) {
  120. write("DEBUG " + build(message, extra))
  121. }
  122. },
  123. info(message?: any, extra?: Record<string, any>) {
  124. if (shouldLog("INFO")) {
  125. write("INFO " + build(message, extra))
  126. }
  127. },
  128. error(message?: any, extra?: Record<string, any>) {
  129. if (shouldLog("ERROR")) {
  130. write("ERROR " + build(message, extra))
  131. }
  132. },
  133. warn(message?: any, extra?: Record<string, any>) {
  134. if (shouldLog("WARN")) {
  135. write("WARN " + build(message, extra))
  136. }
  137. },
  138. tag(key: string, value: string) {
  139. if (tags) tags[key] = value
  140. return result
  141. },
  142. clone() {
  143. return create({ ...tags })
  144. },
  145. time(message: string, extra?: Record<string, any>) {
  146. const now = Date.now()
  147. result.info(message, { status: "started", ...extra })
  148. function stop() {
  149. result.info(message, {
  150. status: "completed",
  151. duration: Date.now() - now,
  152. ...extra,
  153. })
  154. }
  155. return {
  156. stop,
  157. [Symbol.dispose]() {
  158. stop()
  159. },
  160. }
  161. },
  162. }
  163. if (service && typeof service === "string") {
  164. loggers.set(service, result)
  165. }
  166. return result
  167. }