# @jintianxiayu/logger

`@jintianxiayu/logger` 是一个面向 Node.js 应用的 Winston 日志封装。它提供命名日志器、YAML/对象配置、
Console 与轮转文件输出、结构化脱敏、异步 `traceId` 上下文，以及可等待日志刷新的显式关闭流程。

## 目录

- [要求与安装](#要求与安装)
- [快速开始](#快速开始)
- [配置](#配置)
- [日志与上下文](#日志与上下文)
- [输出格式](#输出格式)
- [敏感字段脱敏](#敏感字段脱敏)
- [进程错误与关闭](#进程错误与关闭)
- [公共 API](#公共-api)
- [常见问题](#常见问题)
- [开发](#开发)
- [许可证](#许可证)

## 要求与安装

- Node.js 20 或更高版本
- CommonJS 运行时；包内包含 TypeScript 类型声明

```bash
pnpm add @jintianxiayu/logger
```

也可以使用 `npm install @jintianxiayu/logger`。本仓库自身使用 pnpm 11。

## 快速开始

下面的示例显式关闭进程错误接管，适合由应用或框架统一处理未捕获异常的场景：

```ts
import { LoggerContext, LoggerFactory } from '@jintianxiayu/logger';

async function main(): Promise<void> {
    LoggerFactory.init({
        root: {
            console: {
                enabled: true,
                format: 'json',
            },
        },
        processErrors: {
            uncaughtException: false,
            unhandledRejection: false,
            exitOnError: false,
        },
    });

    const logger = LoggerFactory.getLogger('orders');

    await LoggerContext.withContext({ traceId: 'order-20260902-001' }, async () => {
        logger.info('Order created', {
            orderId: 'order-001',
            password: 'masked-before-output',
        });
    });

    await LoggerFactory.shutdown();
}

main().catch((error: unknown) => {
    process.stderr.write(`${String(error)}\n`);
    process.exitCode = 1;
});
```

CommonJS 调用方可以使用同一公共入口：

```js
const { LoggerFactory, LoggerContext } = require('@jintianxiayu/logger');
```

## 配置

### 配置来源与优先级

`LoggerFactory.init()` 接受配置对象或 YAML 文件路径：

```ts
LoggerFactory.init({ root: { level: 'debug' } });
LoggerFactory.init('./config/logger.yaml');
```

配置来源按以下顺序选择：

1. 传给 `LoggerFactory.init(source)` 的对象或路径；
2. `LOGGER_CONFIG_PATH` 环境变量指定的 YAML 路径；
3. 内置默认配置。

相对路径以 `process.cwd()` 为基准。显式来源无效时会直接抛错，不会回退到环境变量或默认值。
首次成功初始化后，后续 `init()` 调用保持幂等，不会重新读取或切换配置。未显式调用 `init()` 时，
第一次 `getLogger()` 会按相同规则延迟初始化。

### 多环境级别 Profile

在同一份 `logger.yaml` 中定义环境差异：

```yaml
root:
    level: info
profiles:
    dev:
        loggers:
            orders:
                level: debug
    qa:
        loggers:
            orders:
                level: debug
    prod:
        loggers:
            orders:
                level: info
```

在启动进程前设置 `LOGGER_CONFIG_PATH` 为该文件路径，并设置 `LOGGER_PROFILE=dev`、`qa` 或 `prod`。
应用照常调用 `LoggerFactory.init()` 和 `LoggerFactory.getLogger('orders')`：dev/qa 输出 debug，prod 从 info 开始输出。

配置来源仍按上节选择；选定文档后，`LOGGER_PROFILE` 选择该文档内的策略，包括显式传给 `init()` 的对象或路径。
合并顺序为内置 root、基础 root、基础命名 logger、所选 Profile 的命名 logger level。
Profile 中新声明的 logger 继承基础 root 的其他字段，未匹配的 logger 及 root 保持原策略。

- `profiles` 可选，类型为 `Record<string, LoggerLevelProfile>`。每个 Profile 仅允许可选 `loggers` 映射；
  每个覆盖项为 `LoggerLevelOverride`，仅含必填 `level: 'debug' | 'info' | 'warn' | 'error'`。
- 名称区分大小写、非空且不能含首尾空白；logger 名称按完整字符串匹配，支持 `@jintianxiayu/cache-decorator`，不支持通配符。
- 不设置 `LOGGER_PROFILE` 时使用基础配置，不根据 `NODE_ENV` 自动选择。空字符串、首尾空白、未知 Profile 均导致初始化失败。
- 空 Profile（如 `prod: {}`）表示沿用基础配置；覆盖项 `{ orders: {} }` 因缺少 level 而非法。
- 全部 Profile 都会校验，包括未选中的策略。未知字段、重复 YAML 键或非法级别会导致初始化失败；不支持在 Profile 中配置 root、输出或脱敏等其他字段。
- 配置只在首次成功初始化时固化；修改文件、环境变量或再次调用 `init()` 不会热更新。首次 `getLogger()` 的延迟初始化遵循相同规则。

应在任何依赖首次获取 logger 前提供配置；依赖可能在导入时获取 logger，因此建议在进程启动环境中提供上述变量。
先升级所有读取此文件的消费者，再启用 `profiles`：旧版会拒绝该新字段。回滚旧版时恢复旧格式配置并清除 `LOGGER_PROFILE`；
仅在新版停用 Profile 时，清除该变量即可恢复基础策略。

### 配置项说明

以下基础配置项都是可选的（Profile 覆盖项的 level 必填）。`root` 在内置默认值上合并，`loggers.<name>` 再深度继承合并后的 `root`，只覆盖
自己显式声明的字段。

#### 顶层配置

| 配置项          | 类型                                 | 默认值              | 说明                                                              |
| --------------- | ------------------------------------ | ------------------- | ----------------------------------------------------------------- |
| `root`          | `LoggerOptions`                      | 内置根配置          | 定义所有日志器共享的级别、调用位置和输出方式                      |
| `profiles`      | `Record<string, LoggerLevelProfile>` | `{}`                | 通过 LOGGER_PROFILE 选择的命名日志器级别策略                      |
| `loggers`       | `Record<string, LoggerOptions>`      | `{}`                | 按名称覆盖 `root`；名称必须非空且不含首尾空白，并且区分大小写     |
| `masking`       | `SensitiveMaskingConfig`             | 启用内置策略        | 定义应用于所有日志器和输出通道的元数据脱敏规则                    |
| `processErrors` | 配置对象                             | 三个开关均为 `true` | 控制 Winston 对未捕获异常、未处理拒绝和进程退出的处理；字段见下表 |

`getLogger()` 会移除传入名称的首尾空白，并为同一个规范化名称返回同一实例。例如 `database` 与
`database` 返回同一个日志器，而 `Database` 是另一个名称。

#### 日志器配置

以下配置可用于 `root` 和任意 `loggers.<name>`。命名日志器未声明的值继承 `root`。

| 配置项               | 类型                             | `root` 默认值 | 说明                                                       |
| -------------------- | -------------------------------- | ------------- | ---------------------------------------------------------- |
| `level`              | `debug \| info \| warn \| error` | `info`        | 最低输出级别；例如 `info` 会输出 `info`、`warn` 和 `error` |
| `captureLogPosition` | `boolean`                        | `true`        | 是否允许采集调用位置；只有启用的输出实际需要位置时才会采集 |
| `console`            | `ConsoleConfig`                  | 见下表        | 控制标准输出的启用状态、格式、颜色和模板                   |
| `file`               | `FileConfig`                     | 见下表        | 控制轮转文件输出、目录、文件名和保留策略                   |

#### Console 配置

| 配置项            | 类型            | `root` 默认值   | 说明                                                         |
| ----------------- | --------------- | --------------- | ------------------------------------------------------------ |
| `console.enabled` | `boolean`       | `true`          | 是否启用 Console 输出                                        |
| `console.colors`  | `boolean`       | `false`         | 是否为 Plain 输出的日志级别添加 ANSI 颜色；JSON 输出忽略该项 |
| `console.format`  | `plain \| json` | `plain`         | Console 输出格式；JSON 始终输出无 ANSI 颜色的单行对象        |
| `console.pattern` | `string`        | 内置 Plain 模板 | Plain 输出模板；仅在最终格式为 `plain` 时校验和使用          |

内置 Plain 模板为：

```text
%{timestamp} %{level} [%{name}] [%{traceId}] %{log_position}: %{message} %{meta}
```

模板支持的占位符及缺失值行为见[输出格式](#输出格式)。

#### File 配置

| 配置项             | 类型               | `root` 默认值   | 说明                                                                    |
| ------------------ | ------------------ | --------------- | ----------------------------------------------------------------------- |
| `file.enabled`     | `boolean`          | `false`         | 是否启用轮转文件输出                                                    |
| `file.format`      | `plain \| json`    | `json`          | 文件内容格式；JSON 为单行对象，Plain 使用 `file.pattern`                |
| `file.pattern`     | `string`           | 内置 Plain 模板 | Plain 文件模板；仅在最终格式为 `plain` 时校验和使用                     |
| `file.dirname`     | `string`           | `./logs`        | 日志目录；相对路径以 `process.cwd()` 为基准解析                         |
| `file.filename`    | `string`           | `app.log`       | 轮转文件名；未包含 `%DATE%` 时会在扩展名前自动插入日期占位符            |
| `file.datePattern` | `string`           | `YYYY-MM-DD`    | 传给轮转文件传输的日期格式                                              |
| `file.maxSize`     | `number \| string` | `10m`           | 单个文件的大小上限；接受正数字节数或带可选 `k`、`m`、`g` 单位的正数文本 |
| `file.maxFiles`    | `number \| string` | `7d`            | 保留的文件数或天数；接受正整数、数字文本，或 `7d` 形式的天数文本        |

例如，默认文件名最终会生成类似 `app-2026-09-02.log` 的文件。同一物理文件目标不能混用不同输出格式，
也不能声明冲突的轮转配置，否则初始化失败。

#### 脱敏配置

| 配置项            | 类型                   | 默认值 | 说明                                                                       |
| ----------------- | ---------------------- | ------ | -------------------------------------------------------------------------- |
| `masking.enabled` | `boolean`              | `true` | 是否对元数据递归应用内置和自定义字段策略                                   |
| `masking.fields`  | `SensitiveFieldConfig` | `{}`   | 字符串新增或覆盖掩码模板；`false` 取消指定字段的策略；字段匹配不区分大小写 |

`SensitiveFieldConfig` 的定义为 `Record<string, string | false>`。

自定义模板支持的语法和内置敏感字段见[敏感字段脱敏](#敏感字段脱敏)。

#### 进程错误配置

| 配置项                             | 类型      | 默认值 | 说明                                    |
| ---------------------------------- | --------- | ------ | --------------------------------------- |
| `processErrors.uncaughtException`  | `boolean` | `true` | 是否让根输出处理未捕获异常              |
| `processErrors.unhandledRejection` | `boolean` | `true` | 是否让根输出处理未处理的 Promise 拒绝   |
| `processErrors.exitOnError`        | `boolean` | `true` | 是否由 Winston 在处理进程错误后退出进程 |

启用 `uncaughtException` 或 `unhandledRejection` 时，`root.console` 与 `root.file` 必须至少启用一个。
如果应用框架自行管理致命错误和退出流程，应显式关闭相应选项。

路径、文件名、日期格式、Plain 模板和脱敏模板等字符串配置不能为空。未知字段、错误类型、无效级别或格式、
错误模板和冲突文件目标都会在初始化阶段被拒绝。

## 日志与上下文

日志器支持四个级别：`debug`、`info`、`warn` 和 `error`。

```ts
const logger = LoggerFactory.getLogger('database');

logger.debug('Query started', { sqlId: 'query-001' });
logger.info('Query completed', { durationMs: 12 });
logger.warn('Pool utilization is high', { utilization: 0.9 });
logger.error('Query failed', new Error('connection lost'));
```

`message` 必须是字符串；运行时收到非字符串消息会抛出 `TypeError`。一个普通对象元数据参数会直接成为
`meta`；其他参数组合会放入 `meta.args`。`Error`、日期、循环引用和常见非 JSON 值会先转换为稳定的
可序列化结构，元数据处理失败时会降级为安全占位值，不中断主日志调用。

`LoggerContext.withContext()` 使用异步上下文隔离并继承嵌套值：

```ts
await LoggerContext.withContext({ traceId: 'request-001', tenant: 'north' }, async () => {
    logger.info('Handling request');
    const tenant = LoggerContext.get<string>('tenant');
});
```

只有非空字符串 `traceId` 会自动进入日志事件；其他上下文字段可通过 `LoggerContext.get()` 读取，但不会
自动输出。并发异步调用链之间的上下文相互隔离，回调完成、抛错或拒绝后会恢复父级上下文。

## 输出格式

JSON 输出为单行对象，固定包含 `timestamp`、`level`、`name` 和 `message`，并按实际数据添加
`traceId`、`logPosition` 与 `meta`。JSON 输出不会包含 ANSI 颜色。

Plain 输出可以通过 `pattern` 组合以下占位符：

| 占位符            | 输出内容                                             | 缺失值与特殊行为                                        |
| ----------------- | ---------------------------------------------------- | ------------------------------------------------------- |
| `%{timestamp}`    | 日志事件生成时的 ISO 8601 UTC 时间                   | 始终存在                                                |
| `%{level}`        | `debug`、`info`、`warn` 或 `error`                   | Console 的 Plain 输出启用 `colors` 时会包含 ANSI 颜色码 |
| `%{name}`         | `LoggerFactory.getLogger(name)` 对应的命名日志器名称 | 进程异常和未处理拒绝使用 `root`                         |
| `%{traceId}`      | 当前 `LoggerContext` 中的非空字符串 `traceId`        | 未设置或不是非空字符串时输出 `-`                        |
| `%{log_position}` | 发起日志调用的规范化源码位置，格式为 `file:line`     | 未采集到位置时输出 `-`；不包含列号                      |
| `%{message}`      | 传给日志方法的字符串消息                             | 始终存在；消息中的占位符文本不会再次展开                |
| `%{meta}`         | 完成规范化和脱敏后的元数据 JSON                      | 没有元数据时输出 `-`；序列化失败时输出安全占位对象      |

占位符名称区分大小写，可以与任意普通文本组合并重复使用。未知占位符或未闭合的 `%{...}` 会在
初始化阶段触发配置错误。只有 `pattern` 本身会进行占位符替换，`message` 和 `meta` 中的同名文本均按
原始业务数据输出。

缺失的可选值在 Plain 输出中显示为 `-`。当 `captureLogPosition` 启用且输出格式需要调用位置时，
`logPosition` 使用 `file:line`，不包含列号。

调用位置统一使用 `/` 作为路径分隔符。项目内文件相对于 Logger 首次成功初始化时的工作目录输出；
依赖文件从最后一个 `node_modules` 目录段之后输出，因此 pnpm 物理存储目录不会进入日志。例如：

```text
@jintianxiayu/http-client-decorator/dist/middlewares/debug.js:17
```

规范化后的路径部分超过 120 个字符且至少包含六段时，中间目录压缩为 `...`，保留前三段和
最后两段，例如 `src/modules/order/.../services/handler.js:42`。压缩结果允许碰撞，不附加哈希；
包名、目录名和文件名不会被逐段截断。无法识别项目或依赖归属时保留规范化后的原始路径。

Console 和 File 可以分别选择 `plain` 或 `json`。文件输出使用日期/大小轮转；同一物理文件目标不能
混用不同格式或冲突的轮转配置。

## 敏感字段脱敏

脱敏在所有输出通道之前递归执行，字段名匹配不区分大小写，并且不会修改调用方传入的对象。

内置策略包括：

- `password`、`token`、`authorization`、`cookie` 等凭证字段完整替换为 `********`；
- `phone`、`creditCard`、`bankAccount`、`idNumber` 等号码字段只保留末四位；
- `email` 在格式有效时保留前两个字符和域名，否则完整遮盖。

自定义模板支持普通文本以及 `{firstN}`、`{lastN}`、`{domain}`，其中 `N` 是正整数。动态模板遇到过短值、
复杂对象或无效邮箱时会使用完整掩码。模板语法在初始化阶段校验。

当业务字段与内置字段同名、但确定不承载敏感信息时，可以显式取消该字段的脱敏：

```yaml
masking:
    enabled: true
    fields:
        password: false
        account: '***{last4}'
```

配置对象使用相同语义：

```ts
LoggerFactory.init({
    masking: {
        fields: {
            password: false,
            account: '***{last4}',
        },
    },
});
```

`false` 必须是布尔值且不能加引号；`'false'` 是合法的字符串模板，会把匹配值替换为文本 `false`。
退出规则按完整字段名大小写不敏感地匹配，因此 `password: false` 同时匹配 `Password`，但不会连带取消
`passwd`、`pwd` 等其他内置别名，也不会按前缀匹配 `passwordHash`。

字段策略是应用级配置，对所有命名 Logger、Console/File 输出和任意嵌套层级生效。如果退出字段的值是对象，
该对象仍会递归复制并检查内部字段；例如外层 `password` 退出后，内部 `token` 仍按内置策略脱敏。

包含字段值 `false` 的配置需要支持该能力的 Logger 版本。旧版会在初始化阶段将其作为非字符串模板拒绝，
因此部署时应先升级所有读取该配置的 Logger 实例，再启用字段退出；回滚旧版前应先移除这些布尔值。

> 脱敏只处理元数据字段，不解析 `message` 文本。不要把令牌、密码或个人信息拼接进日志消息。
> 显式退出可能使原始值进入全部日志输出；仅应在确认该字段名不承载敏感信息，并评估所有同名字段来源后使用。

## 进程错误与关闭

默认配置会让 Winston 捕获未处理的异常和 Promise 拒绝，并启用 `exitOnError`。如果应用框架已有自己的
致命错误处理和退出流程，应在初始化配置中显式关闭相应选项。启用任一进程错误处理器时，根日志器必须
至少启用一个 Console 或 File 输出。

本库不注册 `SIGINT` 或 `SIGTERM` 处理器。应用应在自己的生命周期钩子中显式等待关闭：

```ts
await LoggerFactory.shutdown({ timeout: 5_000 });
```

- 默认超时为 5 秒，`timeout` 必须是正整数毫秒值；
- 多次调用共享同一个关闭结果，不会重复结束 Winston 流；
- 关闭会等待 Winston `finish`，并等待轮转文件流关闭；日志器、传输或文件流报错以及超时都会拒绝 Promise；
- 开始关闭后，已有日志器的新写入会被忽略，且不能再初始化或获取日志器；默认工厂在同一进程中不能重新打开。

`shutdown()` 只能确认本库交给 Winston 传输的日志已完成本地关闭流程，不代表外部日志采集或存储平台已经持久化。

## 公共 API

包根入口只导出以下运行时符号：

| 符号                                          | 用途                                                |
| --------------------------------------------- | --------------------------------------------------- |
| `LoggerFactory.init(source?)`                 | 使用对象、YAML 路径、环境变量或默认值初始化单例工厂 |
| `LoggerFactory.getLogger(name)`               | 获取缓存的命名日志器；必要时延迟初始化              |
| `LoggerFactory.shutdown(options?)`            | 停止接收新日志并等待当前传输完成关闭                |
| `LoggerContext.withContext(values, callback)` | 在同步或异步回调范围内合并上下文                    |
| `LoggerContext.get(key)`                      | 读取当前异步调用链中的上下文字段                    |

`LoggerFactory.getLogger()` 返回的日志器实现 `LoggerInterface`：

```ts
interface LoggerInterface {
    debug(message: string, ...meta: unknown[]): void;
    info(message: string, ...meta: unknown[]): void;
    warn(message: string, ...meta: unknown[]): void;
    error(message: string, ...meta: unknown[]): void;
}
```

公开类型还包括 `ConsoleConfig`、`FileConfig`、`LoggerConfig`、`LoggerInterface`、`LoggerOptions`、
`LoggerLevelOverride`、`LoggerLevelProfile`、`LogLevelName`、`SensitiveFieldConfig`、`SensitiveMaskingConfig` 和 `ShutdownOptions`。内部加载器、格式器、
传输工厂和生命周期错误类不属于包根公共 API，请勿从 `dist/` 子路径导入。

## 常见问题

### 配置文件没有生效

确认 `LoggerFactory.init()` 只在首次成功初始化前调用。已经初始化的工厂不会热加载配置。相对配置路径和
日志目录都以进程启动时的 `process.cwd()` 为基准。

### 关闭时超时或报错

`shutdown()` 会等待控制台/文件传输及轮转文件流。先检查磁盘权限、日志目录、被其他进程占用的文件，
以及传入的超时时间。调用方必须处理关闭 Promise 的拒绝并决定最终退出策略。

### 日志里仍出现敏感信息

确认敏感值位于元数据对象中，并检查 `masking.enabled` 是否为 `true`。消息字符串不会被字段脱敏器解析；
自定义字段名按大小写不敏感规则匹配。

## 开发

```bash
pnpm install
pnpm run typecheck
pnpm run build
pnpm run lint
pnpm test
```

实现边界、格式与关闭流程的内部说明见 [DESIGN.md](./DESIGN.md)。

## 许可证

MIT
