├── .claude └── settings.local.json ├── .cursorrules ├── .github ├── FUNDING.yaml ├── copilot-instructions.md └── workflows │ └── main.yaml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── .windsurfrules ├── .zed └── settings.json ├── CHANGES.md ├── CLAUDE.md ├── LICENSE ├── README.md ├── cspell.json ├── deno.json ├── docs ├── .gitignore ├── .vitepress │ ├── config.mts │ └── theme │ │ ├── custom.css │ │ └── index.mts ├── bun.lock ├── changelog.md ├── index.md ├── intro.md ├── manual │ ├── categories.md │ ├── config.md │ ├── contexts.md │ ├── filters.md │ ├── formatters.md │ ├── install.md │ ├── levels.md │ ├── library.md │ ├── redaction.md │ ├── sinks.md │ ├── start.md │ ├── struct.md │ └── testing.md ├── package.json ├── public │ └── logtape.svg └── screenshots ├── file ├── README.md ├── deno.json ├── dnt.ts ├── filesink.base.ts ├── filesink.deno.ts ├── filesink.jsr.ts ├── filesink.node.ts ├── filesink.test.ts └── mod.ts ├── logtape ├── README.md ├── config.test.ts ├── config.ts ├── context.test.ts ├── context.ts ├── deno.json ├── dnt.ts ├── filter.test.ts ├── filter.ts ├── fixtures.ts ├── formatter.test.ts ├── formatter.ts ├── level.test.ts ├── level.ts ├── logger.test.ts ├── logger.ts ├── mod.ts ├── nodeUtil.cjs ├── nodeUtil.ts ├── record.ts ├── sink.test.ts └── sink.ts ├── redaction ├── README.md ├── deno.json ├── dnt.ts ├── field.test.ts ├── field.ts ├── mod.ts ├── pattern.test.ts └── pattern.ts ├── screenshots ├── terminal-console.png └── web-console.png └── scripts ├── check_versions.ts └── update_versions.ts /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "enableAllProjectMcpServers": false 3 | } 4 | -------------------------------------------------------------------------------- /.cursorrules: -------------------------------------------------------------------------------- 1 | .github/copilot-instructions.md -------------------------------------------------------------------------------- /.github/FUNDING.yaml: -------------------------------------------------------------------------------- 1 | github: dahlia 2 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # LogTape Development Guidelines for AI Assistants 2 | 3 | This document provides comprehensive instructions for AI coding assistants (like 4 | GitHub Copilot, Claude, etc.) working with the LogTape codebase. Follow these 5 | guidelines to ensure your contributions align with project standards. 6 | 7 | ## Project Overview 8 | 9 | LogTape is a zero-dependency logging library for JavaScript and TypeScript that 10 | works across multiple runtimes (Deno, Node.js, Bun, browsers, edge functions). 11 | Key features include: 12 | 13 | - Structured logging with hierarchical categories 14 | - Template literal support 15 | - Extensible sink system 16 | - Cross-runtime compatibility 17 | - Library-friendly design 18 | 19 | ## Codebase Structure 20 | 21 | The project is organized as a workspace with multiple modules: 22 | 23 | - **logtape/**: Core logging functionality 24 | - **file/**: File-based logging sink 25 | - **redaction/**: Functionality for redacting sensitive information 26 | 27 | Each module follows a similar structure with: 28 | 29 | - `mod.ts`: Main entry point exposing the public API 30 | - `*.ts`: Implementation files 31 | - `*.test.ts`: Test files matching their respective implementation 32 | - `dnt.ts`: Deno-to-Node packaging configuration 33 | 34 | ## Coding Conventions 35 | 36 | ### TypeScript Standards 37 | 38 | 1. **Strict TypeScript**: The project uses strict TypeScript. All code must be 39 | properly typed. 40 | 2. **Explicit Types**: Prefer explicit type annotations for function parameters 41 | and return types. 42 | 3. **Interfaces vs Types**: Use `interface` for public APIs and `type` for 43 | complex types. 44 | 4. **Readonly**: Use `readonly` for immutable properties. 45 | 5. **Type Guards**: Use type guards for runtime type checking. 46 | 47 | ### Naming Conventions 48 | 49 | 1. **Modules**: Use camelCase for filenames and import specifiers. 50 | 2. **Classes/Interfaces**: Use PascalCase. 51 | 3. **Variables/Functions/Methods**: Use camelCase. 52 | 4. **Constants**: Use camelCase for constants (NOT ALL_CAPS). 53 | 5. **Private Members**: Prefix with `_` (e.g., `_privateMethod`). 54 | 55 | ### Code Style 56 | 57 | 1. **Formatting**: The project uses `deno fmt` for formatting. 58 | 2. **Comments**: Use JSDoc for all public APIs. 59 | 3. **Line Length**: Keep lines under 80 characters when possible. 60 | 4. **Import Organization**: Organize imports alphabetically. 61 | 5. **Error Handling**: Always handle errors explicitly, never swallow them. 62 | 63 | ### Branch Structure 64 | 65 | - **main**: Contains new features for the next major/minor version 66 | - **X.Y-maintenance**: Contains bug fixes for the next patch version of a 67 | specific release (e.g., `0.9-maintenance`) 68 | 69 | ## Testing 70 | 71 | ### Test Framework 72 | 73 | The project uses Deno's built-in testing capabilities with the `@std/assert` 74 | library. 75 | 76 | ### Test Organization 77 | 78 | 1. Each implementation file has a corresponding `*.test.ts` file. 79 | 2. Tests are organized using Deno's `test()` function with steps. 80 | 3. Each test should focus on a single piece of functionality. 81 | 82 | ### Test Structure 83 | 84 | ```typescript 85 | Deno.test("ComponentName.methodName()", async (t) => { 86 | // Setup code 87 | 88 | await t.step("test case description", () => { 89 | // Test code 90 | assertEquals(actual, expected); 91 | }); 92 | 93 | await t.step("tear down", () => { 94 | // Cleanup code 95 | }); 96 | }); 97 | ``` 98 | 99 | ### Running Tests 100 | 101 | ```bash 102 | # Run all tests 103 | deno task test 104 | 105 | # Run tests with coverage 106 | deno task coverage 107 | 108 | # Run tests across all runtimes 109 | deno task test-all 110 | ``` 111 | 112 | ## Development Workflow 113 | 114 | ### Checking Code 115 | 116 | Before submitting changes, run: 117 | 118 | ```bash 119 | deno task check 120 | ``` 121 | 122 | This runs: 123 | 124 | - `deno check **/*.ts`: Type checking 125 | - `deno lint`: Linting 126 | - `deno fmt --check`: Format checking 127 | - `deno run --allow-read scripts/check_versions.ts`: Version consistency check 128 | 129 | ### Git Hooks 130 | 131 | The project uses git hooks: 132 | 133 | - **pre-commit**: Runs `deno task check` to verify code quality 134 | - **pre-push**: Runs `deno task check` and `deno task test` to verify 135 | functionality 136 | 137 | To install hooks: 138 | 139 | ```bash 140 | deno task hooks:install 141 | ``` 142 | 143 | ### CI/CD 144 | 145 | The project uses GitHub Actions for: 146 | 147 | - Running tests across multiple platforms (macOS, Ubuntu, Windows) 148 | - Checking code style and types 149 | - Generating test coverage reports 150 | - Publishing to JSR and npm 151 | 152 | ## Documentation 153 | 154 | ### Code Documentation 155 | 156 | - Use JSDoc comments for all public APIs 157 | - Document parameters, return types, and exceptions 158 | - Include examples for complex functionality 159 | 160 | Example: 161 | 162 | ````typescript 163 | /** 164 | * Creates a logger for the specified category. 165 | * 166 | * @param category The category for the logger. 167 | * @returns A logger instance. 168 | * 169 | * @example 170 | * ```ts 171 | * const logger = getLogger("my-app"); 172 | * logger.info("Hello, {name}!", { name: "world" }); 173 | * ``` 174 | */ 175 | export function getLogger(category?: Category | string): Logger { 176 | // Implementation 177 | } 178 | ```` 179 | 180 | ### User Documentation 181 | 182 | User documentation is available at https://logtape.org/ and is structured as: 183 | 184 | - Installation guides 185 | - Quick start 186 | - Manual sections (categories, levels, filters, formatters, sinks) 187 | - Advanced usage (library usage, testing, structured logging) 188 | 189 | When adding or changing functionality, update both the code documentation and 190 | user documentation as needed. 191 | 192 | ## Changelog Guidelines 193 | 194 | The project maintains a detailed changelog in `CHANGES.md` that follows specific 195 | principles and formatting: 196 | 197 | ### Changelog Principles 198 | 199 | 1. **User-Focused Changes**: Document changes from the user's perspective, not 200 | implementation details. Focus on what users of the library will experience, 201 | not how it was implemented. 202 | 203 | 2. **API Documentation**: Clearly document all API changes, including: 204 | - Additions of new functions, types, interfaces, or constants 205 | - Changes to existing API types or signatures (include both old and new 206 | types) 207 | - Deprecation notices 208 | - Removals or relocations of APIs between packages 209 | 210 | 3. **Attribution**: Include attribution to contributors where applicable, with 211 | links to their PRs or issues. 212 | 213 | 4. **Versioning**: Each version has its own section with release date (when 214 | applicable). 215 | 216 | ### When to Update the Changelog 217 | 218 | Update the changelog when: 219 | 220 | - Adding, changing, or removing public APIs 221 | - Fixing bugs that affect user behavior 222 | - Making performance improvements that users would notice 223 | - Changing behavior of existing functionality 224 | - Moving code between packages 225 | 226 | Do NOT update the changelog for: 227 | 228 | - Internal implementation changes that don't affect users 229 | - Documentation-only changes 230 | - Test-only changes 231 | - Build system changes 232 | 233 | ### Changelog Format 234 | 235 | 1. **Structure**: 236 | - Top-level heading for the project name 237 | - Second-level heading for each version number 238 | - Version status ("To be released" or "Released on DATE") 239 | - Bulleted list of changes 240 | 241 | 2. **Entry Format**: 242 | - Use `-` for list items 243 | - Nest related sub-items with indentation 244 | - Link issue/PR numbers using `[[#XX]]` or `[[#XX] by Contributor Name]` 245 | - For API changes, include the full type signature changes 246 | 247 | 3. **Order**: 248 | - Group related changes together 249 | - List additions first, then changes, then fixes 250 | 251 | ### Example Entry 252 | 253 | ``` 254 | Version X.Y.Z 255 | ------------- 256 | 257 | Released on Month Day, Year. 258 | 259 | - Added `newFunction()` function to perform X. [[#42]] 260 | 261 | - Changed the type of the `existingFunction()` function to 262 | `(param: string) => number` (was `(param: string) => void`). 263 | 264 | - Fixed a bug where X happened when Y was expected. [[#43], [#44] by Contributor] 265 | ``` 266 | 267 | ## Cross-Runtime Compatibility 268 | 269 | LogTape supports multiple JavaScript runtimes: 270 | 271 | - Deno 272 | - Node.js 273 | - Bun 274 | - Browsers 275 | - Edge functions 276 | 277 | Ensure new code works across all supported environments. Use the 278 | `@david/which-runtime` library to detect runtime-specific behavior when 279 | necessary. 280 | 281 | ## Package Management 282 | 283 | The project is published to: 284 | 285 | - JSR (JavaScript Registry) 286 | - npm 287 | 288 | Version consistency is important—all packages should have matching versions. 289 | 290 | ## Best Practices 291 | 292 | 1. **Zero Dependencies**: Avoid adding external dependencies. 293 | 2. **Performance**: Consider performance implications, especially for logging 294 | operations. 295 | 3. **Error Handling**: Ensure logging errors don't crash applications. 296 | 4. **Backward Compatibility**: Maintain compatibility with existing APIs. 297 | 5. **Security**: Be careful with sensitive data in logs. 298 | 299 | ## Specific Component Guidelines 300 | 301 | ### Loggers 302 | 303 | - Loggers should follow the hierarchical category pattern 304 | - Support both eager and lazy evaluation modes 305 | - Properly handle context properties 306 | 307 | ### Sinks 308 | 309 | - Keep sinks simple and focused on a single responsibility 310 | - Handle errors gracefully 311 | - Consider performance implications 312 | 313 | ### Formatters 314 | 315 | - Make formatters customizable 316 | - Support both plain text and structured formats 317 | - Consider output readability 318 | 319 | By following these guidelines, you'll help maintain the quality and consistency 320 | of the LogTape codebase. 321 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | strategy: 7 | matrix: 8 | os: [macos-latest, ubuntu-latest, windows-latest] 9 | fail-fast: false 10 | permissions: 11 | contents: read 12 | issues: read 13 | checks: write 14 | pull-requests: write 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - run: | 18 | git config --global core.autocrlf false 19 | git config --global core.eol lf 20 | - uses: actions/checkout@v4 21 | - uses: denoland/setup-deno@v2 22 | with: 23 | deno-version: v2.x 24 | - uses: oven-sh/setup-bun@v2 25 | with: 26 | bun-version: latest 27 | - run: deno task test --coverage=.cov --junit-path=.test-report.xml 28 | - uses: EnricoMi/publish-unit-test-result-action@v2 29 | if: runner.os == 'Linux' && always() 30 | with: 31 | check_name: "Test Results (Linux)" 32 | files: .test-report.xml 33 | - uses: EnricoMi/publish-unit-test-result-action/macos@v2 34 | if: runner.os == 'macOS' && always() 35 | with: 36 | check_name: "Test Results (macOS)" 37 | files: .test-report.xml 38 | - uses: EnricoMi/publish-unit-test-result-action/windows@v2 39 | if: runner.os == 'Windows' && always() 40 | with: 41 | check_name: "Test Results (Windows)" 42 | files: .test-report.xml 43 | - if: '!cancelled()' 44 | uses: codecov/test-results-action@v1 45 | with: 46 | token: ${{ secrets.CODECOV_TOKEN }} 47 | files: .test-report.xml 48 | - run: deno coverage --lcov .cov > .cov.lcov 49 | - uses: codecov/codecov-action@v4 50 | with: 51 | token: ${{ secrets.CODECOV_TOKEN }} 52 | slug: dahlia/logtape 53 | file: .cov.lcov 54 | - run: deno task test-all:bun 55 | - run: deno task check 56 | 57 | publish: 58 | needs: [test] 59 | permissions: 60 | contents: read 61 | id-token: write 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: actions/checkout@v4 65 | - uses: denoland/setup-deno@v2 66 | with: 67 | deno-version: v2.x 68 | - if: github.ref_type == 'branch' 69 | run: | 70 | v="$(jq \ 71 | --raw-output \ 72 | --arg build "$GITHUB_RUN_NUMBER" \ 73 | --arg commit "${GITHUB_SHA::8}" \ 74 | '.version + "-dev." + $build + "+" + $commit' \ 75 | logtape/deno.json)" 76 | deno run --allow-read --allow-write scripts/update_versions.ts "$v" 77 | deno task check:versions 78 | - if: github.ref_type == 'tag' 79 | run: | 80 | set -ex 81 | [[ "$(jq -r .version logtape/deno.json)" = "$GITHUB_REF_NAME" ]] 82 | deno task check:versions 83 | - if: github.event_name == 'push' 84 | run: | 85 | set -ex 86 | npm config set //registry.npmjs.org/:_authToken "$NPM_AUTH_TOKEN" 87 | jq -r '.workspace | .[]' deno.json | while read pkg; do 88 | pushd "$pkg" 89 | pkgname="$(jq -r '.name + "@" + .version' deno.json)" 90 | if npm view "$pkgname"; then 91 | echo "Package $pkgname already exists, skipping publish." 92 | popd 93 | continue 94 | fi 95 | deno task dnt 96 | cd npm 97 | if [[ "$GITHUB_REF_TYPE" = "tag" ]]; then 98 | npm publish --provenance --access public 99 | else 100 | npm publish --provenance --access public --tag dev 101 | fi 102 | popd 103 | done 104 | env: 105 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 106 | - if: github.event_name == 'pull_request' 107 | run: deno task publish --dry-run --allow-dirty 108 | - if: github.event_name == 'push' 109 | run: deno task publish --allow-dirty 110 | 111 | publish-docs: 112 | if: github.event_name == 'push' 113 | needs: [publish] 114 | runs-on: ubuntu-latest 115 | permissions: 116 | id-token: write 117 | pages: write 118 | deployments: write 119 | environment: 120 | name: github-pages 121 | url: ${{ steps.deployment.outputs.page_url }} 122 | steps: 123 | - uses: actions/checkout@v4 124 | - uses: oven-sh/setup-bun@v2 125 | with: 126 | bun-version: latest 127 | - run: | 128 | set -ex 129 | bun install 130 | if [[ "$GITHUB_REF_TYPE" = "tag" ]]; then 131 | bun add -D "@logtape/logtape@$GITHUB_REF_NAME" 132 | bun add -D "@logtape/file@$GITHUB_REF_NAME" 133 | bun add -D "@logtape/redaction@$GITHUB_REF_NAME" 134 | bun add -D @logtape/otel@latest 135 | EXTRA_NAV_TEXT=Unstable \ 136 | EXTRA_NAV_LINK="$UNSTABLE_DOCS_URL" \ 137 | SITEMAP_HOSTNAME="$STABLE_DOCS_URL" \ 138 | bun run build 139 | else 140 | bun add -D @logtape/logtape@dev 141 | bun add -D @logtape/file@dev 142 | bun add -D @logtape/redaction@dev 143 | bun add -D @logtape/otel@dev 144 | EXTRA_NAV_TEXT=Stable \ 145 | EXTRA_NAV_LINK="$STABLE_DOCS_URL" \ 146 | SITEMAP_HOSTNAME="$UNSTABLE_DOCS_URL" \ 147 | bun run build 148 | fi 149 | env: 150 | STABLE_DOCS_URL: ${{ vars.STABLE_DOCS_URL }} 151 | UNSTABLE_DOCS_URL: ${{ vars.UNSTABLE_DOCS_URL }} 152 | PLAUSIBLE_DOMAIN: ${{ secrets.PLAUSIBLE_DOMAIN }} 153 | working-directory: ${{ github.workspace }}/docs/ 154 | - uses: actions/upload-pages-artifact@v3 155 | with: 156 | path: docs/.vitepress/dist 157 | - id: deployment 158 | if: github.ref_type == 'tag' 159 | uses: actions/deploy-pages@v4 160 | - if: github.ref_type == 'branch' 161 | uses: nwtgck/actions-netlify@v3.0 162 | with: 163 | publish-dir: docs/.vitepress/dist 164 | production-branch: main 165 | github-token: ${{ github.token }} 166 | enable-pull-request-comment: false 167 | enable-commit-comment: false 168 | env: 169 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 170 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 171 | timeout-minutes: 2 172 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cov/ 2 | .dnt-import-map.json 3 | coverage/ 4 | npm/ 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "denoland.vscode-deno", 4 | "streetsidesoftware.code-spell-checker" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.unstable": [ 4 | "fs" 5 | ], 6 | "editor.detectIndentation": false, 7 | "editor.indentSize": 2, 8 | "editor.insertSpaces": true, 9 | "files.eol": "\n", 10 | "files.insertFinalNewline": true, 11 | "files.trimFinalNewlines": true, 12 | "[javascript]": { 13 | "editor.defaultFormatter": "denoland.vscode-deno", 14 | "editor.formatOnSave": true, 15 | "editor.codeActionsOnSave": { 16 | "source.sortImports": "always" 17 | } 18 | }, 19 | "[json]": { 20 | "editor.defaultFormatter": "vscode.json-language-features", 21 | "editor.formatOnSave": true 22 | }, 23 | "[jsonc]": { 24 | "editor.defaultFormatter": "vscode.json-language-features", 25 | "editor.formatOnSave": true 26 | }, 27 | "[typescript]": { 28 | "editor.defaultFormatter": "denoland.vscode-deno", 29 | "editor.formatOnSave": true, 30 | "editor.codeActionsOnSave": { 31 | "source.sortImports": "always" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.windsurfrules: -------------------------------------------------------------------------------- 1 | .github/copilot-instructions.md -------------------------------------------------------------------------------- /.zed/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno": { 3 | "enable": true, 4 | "unstable": true 5 | }, 6 | "ensure_final_newline_on_save": true, 7 | "format_on_save": "on", 8 | "formatter": "language_server", 9 | "languages": { 10 | "JavaScript": { 11 | "language_servers": [ 12 | "deno", 13 | "!typescript-language-server", 14 | "!vtsls", 15 | "!biome", 16 | "..." 17 | ] 18 | }, 19 | "TypeScript": { 20 | "language_servers": [ 21 | "deno", 22 | "!typescript-language-server", 23 | "!vtsls", 24 | "!biome", 25 | "..." 26 | ] 27 | }, 28 | "TSX": { 29 | "language_servers": [ 30 | "deno", 31 | "!typescript-language-server", 32 | "!vtsls", 33 | "!biome", 34 | "..." 35 | ] 36 | } 37 | }, 38 | "show_wrap_guides": true, 39 | "wrap_guides": [ 40 | 80 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | .github/copilot-instructions.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2024 Hong Minhee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | logtape/README.md -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "words": [ 3 | "Codecov", 4 | "consolemock", 5 | "deno", 6 | "denoland", 7 | "filesink", 8 | "hongminhee", 9 | "logtape", 10 | "npmjs", 11 | "popd", 12 | "pushd", 13 | "runtimes", 14 | "strikethrough", 15 | "supercategory", 16 | "twoslash", 17 | "typeof", 18 | "venv" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspace": [ 3 | "./logtape", 4 | "./file", 5 | "./redaction" 6 | ], 7 | "imports": { 8 | "@david/which-runtime": "jsr:@david/which-runtime@^0.2.1", 9 | "@deno/dnt": "jsr:@deno/dnt@^0.41.1", 10 | "@std/assert": "jsr:@std/assert@^0.222.1", 11 | "@std/async": "jsr:@std/async@^0.222.1", 12 | "@std/collections": "jsr:@std/collections@^1.0.10", 13 | "@std/fs": "jsr:@std/fs@^0.223.0", 14 | "@std/path": "jsr:@std/path@^1.0.2", 15 | "@std/semver": "jsr:@std/semver@^1.0.5", 16 | "@std/testing": "jsr:@std/testing@^0.222.1", 17 | "consolemock": "npm:consolemock@^1.1.0" 18 | }, 19 | "unstable": [ 20 | "fs" 21 | ], 22 | "lock": false, 23 | "exclude": [ 24 | ".cov/", 25 | ".github/", 26 | "coverage/", 27 | "docs/" 28 | ], 29 | "tasks": { 30 | "check": { 31 | "command": "deno check **/*.ts && deno lint && deno fmt --check", 32 | "dependencies": [ 33 | "check:versions" 34 | ] 35 | }, 36 | "check:versions": "deno run --allow-read scripts/check_versions.ts", 37 | "test": "deno test --allow-read --allow-write", 38 | "coverage": "rm -rf coverage && deno task test --coverage && deno coverage --html coverage", 39 | "test-all:bun": "deno task --recursive test:bun", 40 | "test-all": { 41 | "dependencies": [ 42 | "test", 43 | "test-all:bun" 44 | ] 45 | }, 46 | "publish": { 47 | "command": "deno publish", 48 | "dependencies": [ 49 | "check", 50 | "test" 51 | ] 52 | }, 53 | "hooks:install": "deno run --allow-read=deno.json,.git/hooks/ --allow-write=.git/hooks/ jsr:@hongminhee/deno-task-hooks", 54 | "hooks:pre-commit": { 55 | "dependencies": [ 56 | "check" 57 | ] 58 | }, 59 | "hooks:pre-push": { 60 | "dependencies": [ 61 | "check", 62 | "test" 63 | ] 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | .jsr-cache-otel.json 2 | .jsr-cache-file.json 3 | .jsr-cache.json 4 | .vitepress/cache/ 5 | .vitepress/dist/ 6 | node_modules/ 7 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { transformerTwoslash } from "@shikijs/vitepress-twoslash"; 2 | import { jsrRef } from "markdown-it-jsr-ref"; 3 | import { defineConfig } from "vitepress"; 4 | import llmstxt from "vitepress-plugin-llms"; 5 | 6 | const jsrRefVersion = 7 | process.env.CI === "true" && process.env.GITHUB_REF_TYPE === "tag" 8 | ? "stable" 9 | : "unstable"; 10 | 11 | const jsrRef_logtape = await jsrRef({ 12 | package: "@logtape/logtape", 13 | version: jsrRefVersion, 14 | cachePath: ".jsr-cache.json", 15 | }); 16 | 17 | const jsrRef_file = await jsrRef({ 18 | package: "@logtape/file", 19 | version: jsrRefVersion, 20 | cachePath: ".jsr-cache-file.json", 21 | }); 22 | 23 | const jsrRef_redaction = await jsrRef({ 24 | package: "@logtape/redaction", 25 | version: jsrRefVersion, 26 | cachePath: ".jsr-cache-file.json", 27 | }); 28 | 29 | let extraNav: { text: string; link: string }[] = []; 30 | if (process.env.EXTRA_NAV_TEXT && process.env.EXTRA_NAV_LINK) { 31 | extraNav = [ 32 | { 33 | text: process.env.EXTRA_NAV_TEXT, 34 | link: process.env.EXTRA_NAV_LINK, 35 | }, 36 | ]; 37 | } 38 | 39 | const head: [string, Record][] = [ 40 | ["link", { rel: "icon", href: "/logtape.svg" }], 41 | ]; 42 | if (process.env.PLAUSIBLE_DOMAIN) { 43 | head.push( 44 | [ 45 | "script", 46 | { 47 | defer: "defer", 48 | "data-domain": process.env.PLAUSIBLE_DOMAIN, 49 | src: "https://plausible.io/js/script.outbound-links.js", 50 | }, 51 | ], 52 | ); 53 | } 54 | 55 | const MANUAL = { 56 | text: "Manual", 57 | items: [ 58 | { text: "Installation", link: "/manual/install" }, 59 | { text: "Quick start", link: "/manual/start" }, 60 | { text: "Configuration", link: "/manual/config" }, 61 | { text: "Categories", link: "/manual/categories" }, 62 | { text: "Severity levels", link: "/manual/levels" }, 63 | { text: "Structured logging", link: "/manual/struct" }, 64 | { text: "Contexts", link: "/manual/contexts" }, 65 | { text: "Sinks", link: "/manual/sinks" }, 66 | { text: "Filters", link: "/manual/filters" }, 67 | { text: "Text formatters", link: "/manual/formatters" }, 68 | { text: "Data redaction", link: "/manual/redaction" }, 69 | { text: "Using in libraries", link: "/manual/library" }, 70 | { text: "Testing", link: "/manual/testing" }, 71 | ], 72 | }; 73 | 74 | // https://vitepress.dev/reference/site-config 75 | export default defineConfig({ 76 | title: "LogTape", 77 | description: 78 | "Simple logging library with zero dependencies for Deno, Node.js, Bun, browsers, and edge functions", 79 | cleanUrls: true, 80 | themeConfig: { 81 | // https://vitepress.dev/reference/default-theme-config 82 | nav: [ 83 | { text: "Home", link: "/" }, 84 | { text: "What is LogTape?", link: "/intro" }, 85 | MANUAL, 86 | { text: "API reference", link: "https://jsr.io/@logtape/logtape" }, 87 | ...extraNav, 88 | ], 89 | 90 | sidebar: [ 91 | { text: "What is LogTape?", link: "/intro" }, 92 | { text: "Changelog", link: "/changelog" }, 93 | MANUAL, 94 | ], 95 | 96 | outline: { 97 | level: [2, 4], 98 | }, 99 | 100 | socialLinks: [ 101 | { 102 | icon: { 103 | svg: 104 | 'JSR', 105 | }, 106 | link: "https://jsr.io/@logtape/logtape", 107 | ariaLabel: "JSR", 108 | }, 109 | { icon: "npm", link: "https://www.npmjs.com/package/@logtape/logtape" }, 110 | { icon: "github", link: "https://github.com/dahlia/logtape" }, 111 | ], 112 | 113 | search: { 114 | provider: "local", 115 | }, 116 | 117 | editLink: { 118 | pattern: "https://github.com/dahlia/logtape/edit/main/docs/:path", 119 | }, 120 | }, 121 | head: head, 122 | markdown: { 123 | codeTransformers: [ 124 | transformerTwoslash({ 125 | twoslashOptions: { 126 | compilerOptions: { 127 | lib: ["dom", "dom.iterable", "esnext"], 128 | types: [ 129 | "dom", 130 | "dom.iterable", 131 | "esnext", 132 | "@teidesu/deno-types/full", 133 | ], 134 | }, 135 | }, 136 | }), 137 | ], 138 | config(md) { 139 | md.use(jsrRef_logtape); 140 | md.use(jsrRef_file); 141 | md.use(jsrRef_redaction); 142 | }, 143 | }, 144 | sitemap: { 145 | hostname: process.env.SITEMAP_HOSTNAME, 146 | }, 147 | vite: { 148 | plugins: [ 149 | llmstxt(), 150 | ], 151 | }, 152 | 153 | async transformHead(context) { 154 | return [ 155 | [ 156 | "meta", 157 | { property: "og:title", content: context.title }, 158 | ], 159 | [ 160 | "meta", 161 | { property: "og:description", content: context.description }, 162 | ], 163 | ]; 164 | }, 165 | }); 166 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --vp-c-brand-1: var(--vp-c-yellow-1); 3 | --vp-c-brand-2: var(--vp-c-yellow-2); 4 | --vp-c-brand-3: var(--vp-c-yellow-3); 5 | --vp-c-brand-soft: var(--vp-c-yellow-soft); 6 | } -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.mts: -------------------------------------------------------------------------------- 1 | import TwoslashFloatingVue from "@shikijs/vitepress-twoslash/client"; 2 | import type { EnhanceAppContext } from "vitepress"; 3 | import Theme from "vitepress/theme"; 4 | 5 | import "@shikijs/vitepress-twoslash/style.css"; 6 | import './custom.css' 7 | 8 | export default { 9 | extends: Theme, 10 | enhanceApp({ app }: EnhanceAppContext) { 11 | app.use(TwoslashFloatingVue); 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "LogTape" 7 | text: "Simple logging library with
zero dependencies" 8 | tagline: For every JavaScript runtime 9 | image: 10 | src: /logtape.svg 11 | alt: LogTape 12 | actions: 13 | - theme: brand 14 | text: Install 15 | link: /manual/install 16 | - theme: alt 17 | text: What is LogTape? 18 | link: /intro 19 | - theme: alt 20 | text: Quick start 21 | link: /manual/start 22 | 23 | features: 24 | - icon: 🈚 25 | title: Zero dependencies 26 | details: LogTape has zero dependencies. You can use LogTape without worrying about the dependencies of LogTape. 27 | - icon: 📚 28 | title: Library support 29 | details: LogTape is designed to be used in libraries as well as applications. You can use LogTape in libraries to provide logging capabilities to users of the libraries. 30 | link: /manual/library 31 | - icon: 🔌 32 | title: Runtime diversity 33 | details: >- 34 | LogTape supports Deno, Node.js, Bun, 36 | edge functions, and browsers. You can use LogTape in various environments 37 | without changing the code. 38 | link: /manual/install 39 | - icon: 🗃️ 40 | title: Structured logging 41 | details: You can log messages with structured data. 42 | link: /manual/struct 43 | - icon: 🌲 44 | title: Hierarchical categories 45 | details: LogTape uses a hierarchical category system to manage loggers. You can control the verbosity of log messages by setting the log level of loggers at different levels of the category hierarchy. 46 | link: /manual/categories 47 | - icon: ✒️ 48 | title: Template literals 49 | details: LogTape supports template literals for log messages. You can use template literals to log messages with placeholders and values. 50 | link: /manual/start#how-to-log 51 | --- 52 | 53 | 54 | -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | What is LogTape? 2 | =============== 3 | 4 | LogTape is a logging library for JavaScript and TypeScript. It provides a 5 | simple and flexible logging system that is easy to use and easy to extend. 6 | The highlights of LogTape are: 7 | 8 | - *Zero dependencies*: LogTape has zero dependencies. You can use LogTape 9 | without worrying about the dependencies of LogTape. 10 | 11 | - [*Library support*](./manual/library.md): LogTape is designed to be used 12 | in libraries as well as applications. You can use LogTape in libraries 13 | to provide logging capabilities to users of the libraries. 14 | 15 | - [*Runtime diversity*](./manual/install.md): LogTape supports [Deno], 16 | [Node.js], [Bun], edge functions, and browsers. You can use LogTape in 17 | various environments without changing the code. 18 | 19 | - [*Structured logging*](./manual/start.md#structured-logging): You can log 20 | messages with structured data. 21 | 22 | - [*Hierarchical categories*](./manual/categories.md): LogTape uses 23 | a hierarchical category system to manage loggers. You can control 24 | the verbosity of log messages by setting the log level of loggers at 25 | different levels of the category hierarchy. 26 | 27 | - [*Template literals*](./manual/start.md#how-to-log): LogTape supports 28 | template literals for log messages. You can use template literals to log 29 | messages with placeholders and values. 30 | 31 | - [*Built-in data redaction*](./manual/redaction.md): LogTape provides robust 32 | capabilities to redact sensitive information from logs using pattern-based 33 | or field-based approaches. 34 | 35 | - [*Dead simple sinks*](./manual/sinks.md): You can easily add your own sinks 36 | to LogTape. 37 | 38 | ![](./screenshots/web-console.png) 39 | ![](./screenshots/terminal-console.png) 40 | 41 | [Deno]: https://deno.com/ 42 | [Node.js]: https://nodejs.org/ 43 | [Bun]: https://bun.sh/ 44 | -------------------------------------------------------------------------------- /docs/manual/categories.md: -------------------------------------------------------------------------------- 1 | Categories 2 | ========== 3 | 4 | LogTape uses a hierarchical category system to manage loggers. A category is 5 | a list of strings. For example, `["my-app", "my-module"]` is a category. 6 | 7 | When you log a message, it is dispatched to all loggers whose categories are 8 | prefixes of the category of the logger. For example, if you log a message 9 | with the category `["my-app", "my-module", "my-submodule"]`, it is dispatched 10 | to loggers whose categories are `["my-app", "my-module"]` and `["my-app"]`. 11 | 12 | This behavior allows you to control the verbosity of log messages by setting 13 | the `~LoggerConfig.lowestLevel` of loggers at different levels of the category 14 | hierarchy. 15 | 16 | Here's an example of setting log levels for different categories: 17 | 18 | ~~~~ typescript{10-11} twoslash 19 | import { getFileSink } from "@logtape/file"; 20 | import { configure, getConsoleSink } from "@logtape/logtape"; 21 | 22 | await configure({ 23 | sinks: { 24 | console: getConsoleSink(), 25 | file: getFileSink("app.log"), 26 | }, 27 | loggers: [ 28 | { category: ["my-app"], lowestLevel: "info", sinks: ["file"] }, 29 | { category: ["my-app", "my-module"], lowestLevel: "debug", sinks: ["console"] }, 30 | ], 31 | }) 32 | ~~~~ 33 | 34 | 35 | Sink inheritance and overriding 36 | ------------------------------- 37 | 38 | When you configure a logger, you can specify multiple sinks for the logger. 39 | The logger inherits the sinks from its parent loggers. If a logger has multiple 40 | sinks, the logger sends log messages to all of its sinks. 41 | 42 | For example, the following configuration sets up two sinks, `a` and `b`, and 43 | configures two loggers. The logger `["my-app"]` sends log messages to sink `a`, 44 | and the logger `["my-app", "my-module"]` sends log messages to sink both `a` and 45 | `b`: 46 | 47 | ~~~~ typescript twoslash 48 | import { type LogRecord, configure, getLogger } from "@logtape/logtape"; 49 | 50 | const a: LogRecord[] = []; 51 | const b: LogRecord[] = []; 52 | 53 | await configure({ 54 | sinks: { 55 | a: a.push.bind(a), 56 | b: b.push.bind(b), 57 | }, 58 | loggers: [ 59 | { category: ["my-app"], sinks: ["a"] }, 60 | { category: ["my-app", "my-module"], sinks: ["b"] }, 61 | ], 62 | }); 63 | 64 | getLogger(["my-app"]).info("foo"); 65 | // a = [{ message: "foo", ... }] 66 | // b = [] 67 | 68 | getLogger(["my-app", "my-module"]).info("bar"); 69 | // a = [{ message: "foo", ... }, { message: "bar", ... }] 70 | // b = [ { message: "bar", ... }] 71 | ~~~~ 72 | 73 | 74 | You can override the sinks inherited from the parent loggers by specifying 75 | `parentSinks: "override"` in the logger configuration. This is useful when you 76 | want to replace the inherited sinks with a different set of sinks: 77 | 78 | ~~~~ typescript twoslash 79 | import { type LogRecord, configure, getLogger } from "@logtape/logtape"; 80 | 81 | const a: LogRecord[] = []; 82 | const b: LogRecord[] = []; 83 | 84 | await configure({ 85 | sinks: { 86 | a: a.push.bind(a), 87 | b: b.push.bind(b), 88 | }, 89 | loggers: [ 90 | { category: ["my-app"], sinks: ["a"] }, 91 | { 92 | category: ["my-app", "my-module"], 93 | sinks: ["b"], 94 | parentSinks: "override", // [!code highlight] 95 | }, 96 | ], 97 | }); 98 | 99 | getLogger(["my-app"]).info("foo"); 100 | // a = [{ message: "foo", ... }] 101 | // b = [] 102 | 103 | getLogger(["my-app", "my-module"]).info("bar"); 104 | // a = [{ message: "foo", ... }] 105 | // b = [{ message: "bar", ... }] 106 | ~~~~ 107 | 108 | 109 | Child loggers 110 | ------------- 111 | 112 | You can get a child logger from a parent logger by calling `~Logger.getChild()`: 113 | 114 | ~~~~ typescript twoslash 115 | import { getLogger } from "@logtape/logtape"; 116 | // ---cut-before--- 117 | const logger = getLogger(["my-app"]); 118 | const childLogger = logger.getChild("my-module"); 119 | // equivalent: const childLogger = getLogger(["my-app", "my-module"]); 120 | ~~~~ 121 | 122 | The `~Logger.getChild()` method can take an array of strings as well: 123 | 124 | ~~~~ typescript twoslash 125 | import { getLogger } from "@logtape/logtape"; 126 | // ---cut-before--- 127 | const logger = getLogger(["my-app"]); 128 | const childLogger = logger.getChild(["my-module", "foo"]); 129 | // equivalent: const childLogger = getLogger(["my-app", "my-module", "foo"]); 130 | ~~~~ 131 | 132 | 133 | Meta logger 134 | ----------- 135 | 136 | The meta logger is a special logger in LogTape designed to handle internal 137 | logging within the LogTape system itself. It serves as a mechanism for LogTape 138 | to report its own operational status, errors, and important events. This is 139 | particularly useful for debugging issues with LogTape configuration or for 140 | monitoring the health of your logging system. 141 | 142 | It is logged to the category `["logtape", "meta"]`, and it is automatically 143 | enabled when you call `configure()` without specifying the meta logger. 144 | To disable the meta logger, you can set the `sinks` property of the meta logger 145 | to an empty array. 146 | 147 | > [!NOTE] 148 | > On sink errors, the meta logger is used to log the error messages, but these 149 | > messages are not logged to the same sink that caused the error. This is to 150 | > prevent infinite loops of error messages when a sink error is caused by the 151 | > sink itself. 152 | 153 | > [!TIP] 154 | > Consider using a separate sink for the meta logger. This ensures that 155 | > if there's an issue with your main sink, you can still receive meta logs about 156 | > the issue: 157 | > 158 | > ~~~~ typescript twoslash 159 | > // @noErrors: 2307 160 | > import { type Sink } from "@logtape/logtape"; 161 | > /** 162 | > * A hypothetical function to get your main sink. 163 | > * @returns The main sink. 164 | > */ 165 | > function getYourMainSink(): Sink { 166 | > return 0 as unknown as Sink; 167 | > } 168 | > // ---cut-before--- 169 | > import { configure, getConsoleSink } from "@logtape/logtape"; 170 | > import { getYourMainSink } from "./your-main-sink.ts"; 171 | > 172 | > await configure({ 173 | > sinks: { 174 | > console: getConsoleSink(), 175 | > main: getYourMainSink(), 176 | > }, 177 | > filters: {}, 178 | > loggers: [ 179 | > { category: ["logtape", "meta"], sinks: ["console"] }, 180 | > { category: ["your-app"], sinks: ["main"] }, 181 | > ], 182 | > }); 183 | -------------------------------------------------------------------------------- /docs/manual/config.md: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | > [!WARNING] 5 | > If you are authoring a library, you should not set up LogTape in the library 6 | > itself. It is up to the application to set up LogTape. 7 | > 8 | > See also [*Using in libraries*](./library.md). 9 | 10 | Setting up LogTape for your application is a crucial step in implementing 11 | effective logging. The `configure()` function is your main tool for this task. 12 | Let's explore how to use it to tailor LogTape to your specific needs. 13 | 14 | At its core, configuring LogTape involves three main components: 15 | 16 | - [*Sinks*](./sinks.md): Where your logs will be sent 17 | - [*Filters*](./filters.md): Rules for which logs should be processed 18 | - *Loggers*: The logging instances for different parts of your application 19 | 20 | Here's a simple configuration to get you started: 21 | 22 | ~~~~ typescript twoslash 23 | import { configure, getConsoleSink } from "@logtape/logtape"; 24 | 25 | await configure({ 26 | sinks: { 27 | console: getConsoleSink(), 28 | }, 29 | loggers: [ 30 | { 31 | category: "my-app", 32 | lowestLevel: "info", 33 | sinks: ["console"], 34 | }, 35 | ], 36 | }); 37 | ~~~~ 38 | 39 | This setup will log all `"info"` level and above messages from the `["my-app"]` 40 |  [category](./categories.md) to the console. 41 | 42 | > [!TIP] 43 | > Want to avoid the `await`? Check out the 44 | > [*Synchronous configuration*](#synchronous-configuration) section. 45 | 46 | 47 | Crafting your configuration 48 | --------------------------- 49 | 50 | > [!NOTE] 51 | > The `configure()` is an asynchronous function. Always use `await` or handle 52 | > the returned `Promise` appropriately. 53 | 54 | 55 | ### Setting up sinks 56 | 57 | [Sinks](./sinks.md) determine where your logs end up. You can have multiple 58 | sinks for different purposes: 59 | 60 | ~~~~ typescript twoslash 61 | // @noErrors: 2345 62 | import { getFileSink } from "@logtape/file"; 63 | import { configure, getConsoleSink } from "@logtape/logtape"; 64 | 65 | await configure({ 66 | sinks: { 67 | console: getConsoleSink(), 68 | file: getFileSink("app.log"), 69 | errorFile: getFileSink("error.log"), 70 | }, 71 | // ... rest of configuration 72 | }); 73 | ~~~~ 74 | 75 | ### Defining Filters 76 | 77 | [Filters](./filters.md) allow you to fine-tune which logs are processed. They 78 | can be based on log levels, content, or custom logic: 79 | 80 | ~~~~ typescript twoslash 81 | // @noErrors: 2345 82 | import { configure } from "@logtape/logtape"; 83 | // ---cut-before--- 84 | await configure({ 85 | // ... sinks configuration 86 | filters: { 87 | noDebug(record) { 88 | return record.level !== "debug"; 89 | }, 90 | onlyErrors(record) { 91 | return record.level === "error" || record.level === "fatal"; 92 | }, 93 | containsUserData(record) { 94 | return record.message.some( 95 | part => typeof part === "string" && part.includes("user") 96 | ); 97 | }, 98 | }, 99 | // ... loggers configuration 100 | }); 101 | ~~~~ 102 | 103 | ### Configuring loggers 104 | 105 | Loggers are where you bring everything together. You can set up different 106 | loggers for different parts of your application: 107 | 108 | ~~~~ typescript twoslash 109 | // @noErrors: 2345 110 | import { configure } from "@logtape/logtape"; 111 | // ---cut-before--- 112 | await configure({ 113 | // ... sinks and filters configuration 114 | loggers: [ 115 | { 116 | category: "my-app", 117 | lowestLevel: "info", 118 | sinks: ["console", "file"], 119 | }, 120 | { 121 | category: ["my-app", "database"], 122 | lowestLevel: "debug", 123 | sinks: ["file"], 124 | filters: ["noDebug"], 125 | }, 126 | { 127 | category: ["my-app", "user-service"], 128 | lowestLevel: "info", 129 | sinks: ["console", "file"], 130 | filters: ["containsUserData"], 131 | }, 132 | ], 133 | }); 134 | ~~~~ 135 | 136 | For severity levels, 137 | see [*Configuring severity levels*](./levels.md#configuring-severity-levels). 138 | 139 | > [!NOTE] 140 | > By default, loggers inherit the sinks of their ascendants. You can override 141 | > them by specifying the `parentSinks: "override"` option in the logger. 142 | 143 | > [!WARNING] 144 | > Defining loggers with the same category is disallowed. If there are 145 | > duplicate categories, LogTape will throw a `ConfigError` when you call 146 | > `configure()`. 147 | 148 | ### Disposal of resources 149 | 150 | If sinks or filters implement the `Disposal` or `AsyncDisposal` interface, 151 | they will be properly disposed when 152 | [resetting the configuration](#reconfiguration) or when the application exits. 153 | 154 | 155 | Advanced configuration techniques 156 | --------------------------------- 157 | 158 | ### Using environment variables 159 | 160 | It's often useful to change your logging configuration based on the environment. 161 | Here's how you might do that: 162 | 163 | ::: code-group 164 | 165 | ~~~~ typescript{1,6,11-12} twoslash [Deno] 166 | import { getFileSink } from "@logtape/file"; 167 | import { configure, getConsoleSink } from "@logtape/logtape"; 168 | // ---cut-before--- 169 | const isDevelopment = Deno.env.get("DENO_DEPLOYMENT_ID") == null; 170 | 171 | await configure({ 172 | sinks: { 173 | console: getConsoleSink(), 174 | file: getFileSink(isDevelopment ? "dev.log" : "prod.log"), 175 | }, 176 | loggers: [ 177 | { 178 | category: "my-app", 179 | level: isDevelopment ? "debug" : "info", 180 | sinks: isDevelopment ? ["console", "file"] : ["file"], 181 | }, 182 | ], 183 | }); 184 | ~~~~ 185 | 186 | ~~~~ typescript{1,6,11-12} twoslash [Node.js] 187 | import "@types/node"; 188 | import process from "node:process"; 189 | import { getFileSink } from "@logtape/file"; 190 | import { configure, getConsoleSink } from "@logtape/logtape"; 191 | // ---cut-before--- 192 | const isDevelopment = process.env.NODE_ENV === "development"; 193 | 194 | await configure({ 195 | sinks: { 196 | console: getConsoleSink(), 197 | file: getFileSink(isDevelopment ? "dev.log" : "prod.log"), 198 | }, 199 | loggers: [ 200 | { 201 | category: "my-app", 202 | level: isDevelopment ? "debug" : "info", 203 | sinks: isDevelopment ? ["console", "file"] : ["file"], 204 | }, 205 | ], 206 | }); 207 | ~~~~ 208 | 209 | ::: 210 | 211 | ### Reconfiguration 212 | 213 | Remember that calling `configure()` with `reset: true` option will reset any 214 | existing configuration. If you need to change the configuration at runtime, 215 | you can call `configure()` again with `reset: true` and the new settings: 216 | 217 | ~~~~ typescript twoslash 218 | import { type Config, configure } from "@logtape/logtape"; 219 | const initialConfig = {} as unknown as Config; 220 | // ---cut-before--- 221 | // Initial configuration 222 | await configure(initialConfig); 223 | 224 | // Later in your application... 225 | await configure({ 226 | reset: true, 227 | ...initialConfig, 228 | loggers: [ 229 | ...initialConfig.loggers, 230 | { 231 | category: "new-feature", 232 | level: "debug", 233 | sinks: ["console"], 234 | }, 235 | ], 236 | }); 237 | ~~~~ 238 | 239 | Or you can explicitly call `reset()` to clear the existing configuration: 240 | 241 | ~~~~ typescript twoslash 242 | import { type Config } from "@logtape/logtape"; 243 | const initialConfig = {} as unknown as Config; 244 | // ---cut-before--- 245 | import { configure, reset } from "@logtape/logtape"; 246 | 247 | await configure(initialConfig); 248 | 249 | // Later in your application... 250 | 251 | reset(); 252 | ~~~~ 253 | 254 | 255 | Synchronous configuration 256 | ------------------------- 257 | 258 | *This API is available since LogTape 0.9.0.* 259 | 260 | If you prefer to configure LogTape synchronously, you can use 261 | the `configureSync()` function instead: 262 | 263 | ~~~~ typescript twoslash 264 | import { configureSync, getConsoleSink } from "@logtape/logtape"; 265 | 266 | configureSync({ 267 | sinks: { 268 | console: getConsoleSink(), 269 | }, 270 | loggers: [ 271 | { 272 | category: "my-app", 273 | lowestLevel: "info", 274 | sinks: ["console"], 275 | }, 276 | ], 277 | }); 278 | ~~~~ 279 | 280 | > [!CAUTION] 281 | > However, be aware that synchronous configuration has some limitations: 282 | > You cannot use sinks or filters that require asynchronous disposal, i.e., 283 | > those that implement the `AsyncDisposal` interface. For example, among 284 | > the built-in sinks, [stream sinks](./sinks.md#stream-sink) requires 285 | > asynchronous disposal. 286 | > 287 | > That said, you still can use sinks or filters that require synchronous 288 | > disposal, i.e., those that implement the `Disposal` interface. 289 | 290 | Likewise, you can use `resetSync()` to reset the configuration synchronously: 291 | 292 | ~~~~ typescript twoslash 293 | import { resetSync } from "@logtape/logtape"; 294 | 295 | resetSync(); 296 | ~~~~ 297 | 298 | > [!CAUTION] 299 | > The `configure()`–`reset()` and `configureSync()`–`resetSync()` APIs have 300 | > to be paired and should not be mixed. If you use `configure()` to set up 301 | > LogTape, you should use `reset()` to reset it. If you use `configureSync()`, 302 | > you should use `resetSync()`. 303 | 304 | 305 | Best practices 306 | -------------- 307 | 308 | 1. *Configure early*: Set up your LogTape configuration early in your 309 | application's lifecycle, ideally before any logging calls are made. 310 | 2. [*Use categories wisely*](./categories.md): Create a logical hierarchy with 311 | your categories to make filtering and management easier. 312 | 3. *Configure for different environments*: Have different configurations for 313 | development, testing, and production. 314 | 4. *Don't overuse [filters](./filters.md)*: While powerful, too many filters can 315 | make your logging system complex and hard to maintain. 316 | 5. *Monitor performance*: Be mindful of the performance impact of your logging, 317 | especially in production environments. 318 | -------------------------------------------------------------------------------- /docs/manual/contexts.md: -------------------------------------------------------------------------------- 1 | Contexts 2 | ======== 3 | 4 | Explicit contexts 5 | ----------------- 6 | 7 | *Explicit contexts are available since LogTape 0.5.0.* 8 | 9 | LogTape provides a context system to reuse the same properties across log 10 | messages. A context is a key-value map. You can set a context for a logger 11 | and log messages `~Logger.with()` the context. Here's an example of setting 12 | a context for a logger: 13 | 14 | ~~~~ typescript twoslash 15 | import { getLogger } from "@logtape/logtape"; 16 | // ---cut-before--- 17 | const logger = getLogger(["my-app", "my-module"]); 18 | const ctx = logger.with({ userId: 1234, requestId: "abc" }); 19 | ctx.info `This log message will have the context (userId & requestId).`; 20 | ctx.warn("Context can be used inside message template: {userId}, {requestId}."); 21 | ~~~~ 22 | 23 | The context is inherited by child loggers. Here's an example of setting a 24 | context for a parent logger and logging messages with a child logger: 25 | 26 | ~~~~ typescript twoslash 27 | import { getLogger } from "@logtape/logtape"; 28 | // ---cut-before--- 29 | const logger = getLogger(["my-app"]); 30 | const parentCtx = logger.with({ userId: 1234, requestId: "abc" }); 31 | const childCtx = parentCtx.getChild(["my-module"]); 32 | childCtx.debug("This log message will have the context: {userId} {requestId}."); 33 | ~~~~ 34 | 35 | Contexts are particularly useful when you want to do 36 | [structured logging](./struct.md). 37 | 38 | 39 | Implicit contexts 40 | ----------------- 41 | 42 | *Implicit contexts are available since LogTape 0.7.0.* 43 | 44 | Implicit contexts are a way to set a context for every single log message in 45 | a subroutine and its all subroutines. In other words, implicit contexts are 46 | invasive to the call stack. Or you can think of it as a set of properties 47 | which works like environment variables in a process. 48 | 49 | Implicit contexts are useful when you want to trace a request or a session 50 | across multiple log messages made by different loggers in different modules. 51 | 52 | 53 | ### Settings 54 | 55 | > [!CAUTION] 56 | > In order to use implicit context, your JavaScript runtime must support 57 | > context-local states (like Node.js's [`node:async_hooks`] module). If your 58 | > JavaScript runtime doesn't support context-local states, LogTape will silently 59 | > ignore implicit contexts and log messages will not have implicit contexts. 60 | > 61 | > As of October 2024, Node.js, Deno, and Bun support implicit contexts. 62 | > Web browsers don't support implicit contexts yet. 63 | > 64 | > See also [TC39 Async Context proposal] for web browsers. 65 | 66 | To enable implicit contexts, you need to set a `~Config.contextLocalStorage` 67 | option in the `configure()` function. In Node.js, Deno, and Bun, you can use 68 | [`AsyncLocalStorage`] from the [`node:async_hooks`] module as a context local 69 | storage: 70 | 71 | ~~~~ typescript twoslash 72 | // @noErrors: 2307 73 | import { type ContextLocalStorage } from "@logtape/logtape"; 74 | class AsyncLocalStorage implements ContextLocalStorage { 75 | getStore(): T | undefined { 76 | return undefined; 77 | } 78 | run(store: T, callback: () => R): R { 79 | return callback(); 80 | } 81 | } 82 | // ---cut-before--- 83 | import { AsyncLocalStorage } from "node:async_hooks"; 84 | import { configure, getLogger } from "@logtape/logtape"; 85 | 86 | await configure({ 87 | // ... other settings ... 88 | // ---cut-start--- 89 | sinks: {}, 90 | loggers: [], 91 | // ---cut-end--- 92 | contextLocalStorage: new AsyncLocalStorage(), 93 | }); 94 | ~~~~ 95 | 96 | [`node:async_hooks`]: https://nodejs.org/api/async_context.html 97 | [TC39 Async Context proposal]: https://tc39.es/proposal-async-context/ 98 | [`AsyncLocalStorage`]: https://nodejs.org/api/async_context.html#class-asynclocalstorage 99 | 100 | 101 | ### Basic usage 102 | 103 | Once you set a context local storage, you can use implicit contexts in your 104 | code. Here's an example of using implicit contexts: 105 | 106 | ~~~~ typescript twoslash 107 | import { getLogger, withContext } from "@logtape/logtape"; 108 | 109 | function functionA() { 110 | // Note that you don't need to pass the context explicitly: 111 | getLogger("a").info( 112 | "This log message will have the implicit context: {requestId}." 113 | ); 114 | } 115 | 116 | function handleRequest(requestId: string) { 117 | // Implicit contexts can be set by `withContext()` function: 118 | withContext({ requestId }, () => { 119 | functionA(); 120 | }); 121 | } 122 | ~~~~ 123 | 124 | In the above example, the `handleRequest()` function sets the `requestId` 125 | context and calls `functionA()`. The `functionA()` logs a message with the 126 | implicit context `requestId` even though the `requestId` is not passed to the 127 | `getLogger()` function. 128 | 129 | > [!TIP] 130 | > Even if some asynchronous operations are interleaved, implicit contexts 131 | > are correctly inherited by all subroutines and asynchronous operations. 132 | > In other words, implicit contexts are more than just a global variable. 133 | 134 | ### Nesting 135 | 136 | Implicit contexts can be nested. Here's an example of nesting implicit 137 | contexts: 138 | 139 | ~~~~ typescript twoslash 140 | import { getLogger, withContext } from "@logtape/logtape"; 141 | 142 | function functionA() { 143 | getLogger("a").info( 144 | "This log message will have the implicit context: {requestId}/{userId}." 145 | ); 146 | } 147 | 148 | function functionB() { 149 | getLogger("b").info( 150 | "This log message will have the implicit context: {requestId}." 151 | ); 152 | } 153 | 154 | function handleRequest(requestId: string) { 155 | withContext({ requestId, signed: false }, () => { 156 | functionB(); 157 | handleUser(1234); 158 | }); 159 | } 160 | 161 | function handleUser(userId: number) { 162 | // Note that the `signed` context is overridden: 163 | withContext({ userId, signed: true }, () => { 164 | functionA(); 165 | }); 166 | } 167 | ~~~~ 168 | 169 | In the above example, `functionA()` and `functionB()` log messages with the 170 | implicit context `requestId`. The `handleRequest()` function sets the 171 | `requestId` context and calls `functionB()` and `handleUser()`. 172 | 173 | The `handleUser()` function sets the `userId` context and calls `functionA()`. 174 | The `functionA()` logs a message with the implicit contexts `requestId` and 175 | `userId`. 176 | 177 | Note that the `signed` context is set in the `handleRequest()` function and 178 | overridden in the `handleUser()` function. In the `functionA()`, the `signed` 179 | context is `true` and in the `functionB()`, the `signed` context is `false`. 180 | 181 | 182 | Priorities 183 | ---------- 184 | 185 | When you set an implicit context with the same key multiple times, the last one 186 | (or the innermost one) wins. 187 | 188 | When you set an explicit context with the same key as an implicit context, 189 | the explicit context wins. 190 | 191 | When you set a property with the same key as an implicit or explicit context, 192 | the property wins. 193 | 194 | Here's an example of the priority: 195 | 196 | ~~~~ typescript twoslash 197 | import { getLogger, withContext } from "@logtape/logtape"; 198 | 199 | const logger = getLogger("my-app"); 200 | 201 | withContext({ foo: 1, bar: 2, baz: 3 }, () => { 202 | const context = logger.with({ bar: 4, baz: 5 }); 203 | context.info( 204 | "This log message will have the context: {foo}, {bar}, {baz}.", 205 | { baz: 6 }, 206 | ); 207 | }); 208 | ~~~~ 209 | 210 | The above example logs the following message: 211 | 212 | ~~~~ 213 | This log message will have the context: 1, 4, 6. 214 | ~~~~ 215 | -------------------------------------------------------------------------------- /docs/manual/filters.md: -------------------------------------------------------------------------------- 1 | Filters 2 | ======= 3 | 4 | A filter is a function that filters log messages. A filter takes a log record 5 | and returns a boolean value. If the filter returns `true`, the log record is 6 | passed to the sinks; otherwise, the log record is discarded. The signature of 7 | `Filter` is: 8 | 9 | ~~~~ typescript twoslash 10 | import type { LogRecord } from "@logtape/logtape"; 11 | // ---cut-before--- 12 | export type Filter = (record: LogRecord) => boolean; 13 | ~~~~ 14 | 15 | The `configure()` function takes a `~Config.filters` object that maps filter 16 | names to filter functions. You can use the filter names in 17 | the `~Config.loggers` object to assign filters to loggers. 18 | 19 | For example, the following filter discards log messages whose property `elapsed` 20 | is less than 100 milliseconds: 21 | 22 | ~~~~ typescript{5-10} twoslash 23 | // @noErrors: 2345 24 | import { configure, type LogRecord } from "@logtape/logtape"; 25 | 26 | await configure({ 27 | // Omitted for brevity 28 | filters: { 29 | tooSlow(record: LogRecord) { 30 | return "elapsed" in record.properties 31 | && typeof record.properties.elapsed === "number" 32 | && record.properties.elapsed >= 100; 33 | }, 34 | }, 35 | loggers: [ 36 | { 37 | category: ["my-app", "database"], 38 | sinks: ["console"], 39 | filters: ["tooSlow"], // [!code highlight] 40 | } 41 | ] 42 | }); 43 | ~~~~ 44 | 45 | 46 | Inheritance 47 | ----------- 48 | 49 | Child loggers inherit filters from their parent loggers. Even if a child logger 50 | has its own filters, the child logger filters out log messages that are filtered 51 | out by its parent logger filters plus its own filters. 52 | 53 | For example, the following example sets two filters, `hasUserInfo` and 54 | `tooSlow`, and assigns the `hasUserInfo` filter to the parent logger and 55 | the `tooSlow` filter to the child logger: 56 | 57 | ~~~~ typescript twoslash 58 | // @noErrors: 2345 59 | import { configure, type LogRecord } from "@logtape/logtape"; 60 | // ---cut-before--- 61 | await configure({ 62 | // Omitted for brevity 63 | filters: { 64 | hasUserInfo(record: LogRecord) { 65 | return "userInfo" in record.properties; 66 | }, 67 | tooSlow(record: LogRecord) { 68 | return "elapsed" in record.properties 69 | && typeof record.properties.elapsed === "number" 70 | && record.properties.elapsed >= 100; 71 | }, 72 | }, 73 | loggers: [ 74 | { 75 | category: ["my-app"], 76 | sinks: ["console"], 77 | filters: ["hasUserInfo"], // [!code highlight] 78 | }, 79 | { 80 | category: ["my-app", "database"], 81 | sinks: [], 82 | filters: ["tooSlow"], // [!code highlight] 83 | } 84 | ] 85 | }); 86 | ~~~~ 87 | 88 | In this example, any log messages under the `["my-app"]` category including 89 | the `["my-app", "database"]` category are passed to the console sink only if 90 | they have the `userInfo` property. In addition, the log messages under the 91 | `["my-app", "database"]` category are passed to the console sink only if they 92 | have the `elapsed` with a value greater than or equal to 100 milliseconds. 93 | 94 | 95 | Level filter 96 | ------------ 97 | 98 | LogTape provides a built-in level filter. You can use the level filter to 99 | filter log messages by their log levels. The level filter factory takes 100 | a `LogLevel` string and returns a level filter. For example, the following 101 | level filter discards log messages whose log level is less than `info`: 102 | 103 | ~~~~ typescript twoslash 104 | // @noErrors: 2345 105 | import { configure, getLevelFilter } from "@logtape/logtape"; 106 | 107 | await configure({ 108 | filters: { 109 | infoAndAbove: getLevelFilter("info"), // [!code highlight] 110 | }, 111 | // Omitted for brevity 112 | }); 113 | ~~~~ 114 | 115 | The `~Config.filters` takes a map of filter names to `FilterLike`, instead of 116 | just `Filter`, where `FilterLike` is either a `Filter` function or a severity 117 | level string. The severity level string will be resolved to a `Filter` that 118 | filters log records with the specified severity level and above. Hence, you 119 | can simplify the above example as follows: 120 | 121 | ~~~~ typescript twoslash 122 | // @noErrors: 2345 123 | import { configure } from "@logtape/logtape"; 124 | // ---cut-before--- 125 | await configure({ 126 | filters: { 127 | infoAndAbove: "info", // [!code highlight] 128 | }, 129 | // Omitted for brevity 130 | }); 131 | ~~~~ 132 | 133 | 134 | Sink filter 135 | ----------- 136 | 137 | A sink filter is a filter that is applied to a specific [sink](./sinks.md). 138 | You can add a sink filter to a sink by decorating the sink with `withFilter()`: 139 | 140 | ~~~~ typescript{7-9} twoslash 141 | // @noErrors: 2345 142 | import { configure, getConsoleSink, withFilter } from "@logtape/logtape"; 143 | 144 | await configure({ 145 | sinks: { 146 | filteredConsole: withFilter( 147 | getConsoleSink(), 148 | log => "elapsed" in log.properties && 149 | typeof log.properties.elapsed === "number" && 150 | log.properties.elapsed >= 100, 151 | ), 152 | }, 153 | // Omitted for brevity 154 | }); 155 | ~~~~ 156 | 157 | The `filteredConsoleSink` only logs messages whose property `elapsed` is greater 158 | than or equal to 100 milliseconds to the console. 159 | 160 | > [!TIP] 161 | > The `withFilter()` function can take a [`LogLevel`] string as the second 162 | > argument. In this case, the log messages whose log level is less than 163 | > the specified log level are discarded. 164 | -------------------------------------------------------------------------------- /docs/manual/formatters.md: -------------------------------------------------------------------------------- 1 | Text formatters 2 | =============== 3 | 4 | A text formatter is a function that formats a log record into a string. LogTape 5 | has three built-in [sinks](./sinks.md) that can take a text formatter: 6 | 7 | - [stream sink](./sinks.md#stream-sink) 8 | - [file sink](./sinks.md#file-sink) 9 | - [rotating file sink](./sinks.md#rotating-file-sink) 10 | 11 | Of course, you can write your own sinks that take a text formatter. 12 | 13 | 14 | Built-in text formatters 15 | ------------------------ 16 | 17 | LogTape provides three built-in text formatters: 18 | 19 | ### Default text formatter 20 | 21 | `defaultTextFormatter` formats a log record into a string with a simple 22 | format. It renders the timestamp, the log level, the message, 23 | and the prettified values embedded in the message. 24 | 25 | It formats log records like this: 26 | 27 | ~~~~ 28 | 2023-11-14 22:13:20.000 +00:00 [INF] category·subcategory: Hello, world! 29 | ~~~~ 30 | 31 | ### ANSI color formatter 32 | 33 | *This API is available since LogTape 0.5.0.* 34 | 35 | `ansiColorFormatter` formats a log record into a string with a simple 36 | format and ANSI colors. It renders the timestamp, the log level, 37 | the message, and the prettified values embedded in the message. 38 | 39 | It formats log records like this: 40 | 41 | ![A preview of ansiColorFormatter.](https://i.imgur.com/I8LlBUf.png) 42 | 43 | ### JSON Lines formatter 44 | 45 | *This API is available since LogTape 0.11.0.* 46 | 47 | `jsonLinesFormatter` formats log records as [JSON Lines] (also known as 48 | Newline-Delimited JSON or NDJSON). Each log record is rendered as a JSON object 49 | on a single line, which is a common format for structured logging. 50 | 51 | It formats log records like this: 52 | 53 | ~~~~ 54 | {"@timestamp":"2023-11-14T22:13:20.000Z","level":"INFO","message":"Hello, world!","logger":"my.logger","properties":{"key":"value"}} 55 | ~~~~ 56 | 57 | [JSON Lines]: https://jsonlines.org/ 58 | 59 | 60 | Configuring text formatters 61 | --------------------------- 62 | 63 | *This API is available since LogTape 0.6.0.* 64 | 65 | You can customize the built-in text formatters with a `TextFormatterOptions` 66 | or an `AnsiColorFormatterOptions` object without building a new text formatter 67 | from scratch. 68 | 69 | ### Default text formatter 70 | 71 | You can customize the default text formatter by calling 72 | the `getTextFormatter()` function with a `TextFormatterOptions` object. 73 | Customizable options include: 74 | 75 | #### `~TextFormatterOptions.timestamp` 76 | 77 | The timestamp format. This can be one of the following: 78 | 79 | - `"date-time-timezone"`: The date and time with the full timezone offset 80 | (e.g., `2023-11-14 22:13:20.000 +00:00`). 81 | - `"date-time-tz"`: The date and time with the short timezone offset 82 | (e.g., `2023-11-14 22:13:20.000 +00`). 83 | - `"date-time"`: The date and time without the timezone offset 84 | (e.g., `2023-11-14 22:13:20.000`). 85 | - `"time-timezone"`: The time with the full timezone offset but without 86 | the date (e.g., `22:13:20.000 +00:00`). 87 | - `"time-tz"`: The time with the short timezone offset but without 88 | the date (e.g., `22:13:20.000 +00`). 89 | - `"time"`: The time without the date or timezone offset 90 | (e.g., `22:13:20.000`). 91 | - `"date"`: The date without the time or timezone offset 92 | (e.g., `2023-11-14`). 93 | - `"rfc3339"`: The date and time in RFC 3339 format 94 | (e.g., `2023-11-14T22:13:20.000Z`). 95 | - `"none"` or `"disabled"`: No display 96 | 97 | Alternatively, this can be a function that accepts a timestamp and returns 98 | a string. 99 | 100 | The default is `"date-time-timezone"`. 101 | 102 | #### `~TextFormatterOptions.level` 103 | 104 | The log level format. This can be one of the following: 105 | 106 | - `"ABBR"`: The log level abbreviation in uppercase (e.g., `INF`). 107 | - `"FULL"`: The full log level name in uppercase (e.g., `INFO`). 108 | - `"L"`: The first letter of the log level in uppercase (e.g., `I`). 109 | - `"abbr"`: The log level abbreviation in lowercase (e.g., `inf`). 110 | - `"full"`: The full log level name in lowercase (e.g., `info`). 111 | - `"l"`: The first letter of the log level in lowercase (e.g., `i`). 112 | 113 | Alternatively, this can be a function that accepts a log level and returns 114 | a string. 115 | 116 | The default is `"ABBR"`. 117 | 118 | #### `~TextFormatterOptions.category` 119 | 120 | The separator between the category names. 121 | 122 | For example, if the separator is `"·"`, the category `["a", "b", "c"]` will be 123 | formatted as `"a·b·c"`. 124 | 125 | The default separator is `"·"`. 126 | 127 | If this is a function, it will be called with the category array and should 128 | return a string, which will be used for rendering the category. 129 | 130 | #### `~TextFormatterOptions.value` 131 | 132 | The format of the embedded values. 133 | 134 | A function that renders a value to a string. This function is used to 135 | render the values in the log record. The default is [`util.inspect()`] in 136 | Node.js/Bun and [`Deno.inspect()`] in Deno. 137 | 138 | [`util.inspect()`]: https://nodejs.org/api/util.html#utilinspectobject-options 139 | [`Deno.inspect()`]: https://docs.deno.com/api/deno/~/Deno.inspect 140 | 141 | #### `~TextFormatterOptions.format` 142 | 143 | How those formatted parts are concatenated. 144 | 145 | A function that formats the log record. This function is called with the 146 | formatted values and should return a string. Note that the formatted 147 | *should not* include a newline character at the end. 148 | 149 | By default, this is a function that formats the log record as follows: 150 | 151 | ~~~~ 152 | 2023-11-14 22:13:20.000 +00:00 [INF] category·subcategory: Hello, world! 153 | ~~~~ 154 | 155 | ### ANSI color formatter 156 | 157 | You can customize the `ansiColorFormatter` by calling 158 | the `getAnsiColorFormatter()` function with an `AnsiColorFormatterOptions` 159 | object. Customizable options include: 160 | 161 | #### `~AnsiColorFormatterOptions.timestamp` 162 | 163 | The timestamp format. The available options are the same as the 164 | [`timestamp`](#textformatteroptions-timestamp) option of the default text 165 | formatter. 166 | 167 | The default is `"date-time-tz"`. 168 | 169 | #### `~AnsiColorFormatterOptions.timestampStyle` 170 | 171 | The ANSI style for the timestamp. `"dim"` is used by default. 172 | 173 | #### `~AnsiColorFormatterOptions.timestampColor` 174 | 175 | The ANSI color for the timestamp. No color is used by default. 176 | 177 | #### `~TextFormatterOptions.level` 178 | 179 | The log level format. The available options are the same as the 180 | [`level`](#textformatteroptions-level) option of the default text formatter. 181 | 182 | The default is `"ABBR"`. 183 | 184 | #### `~AnsiColorFormatterOptions.levelStyle` 185 | 186 | The ANSI style for the log level. `"bold"` is used by default. 187 | 188 | #### `~AnsiColorFormatterOptions.levelColors` 189 | 190 | The ANSI colors for the log levels. The default colors are as follows: 191 | 192 | - `"debug"`: `"blue"` 193 | - `"info"`: `"green"` 194 | - `"warning"`: `"yellow"` 195 | - `"error"`: `"red"` 196 | - `"fatal"`: `"magenta"` 197 | 198 | #### `~TextFormatterOptions.category` 199 | 200 | The separator between the category names. Behaves the same as the 201 | [`category`](#textformatteroptions-category) option of the default text 202 | formatter. 203 | 204 | The default separator is `"·"`. 205 | 206 | #### `~AnsiColorFormatterOptions.categoryStyle` 207 | 208 | The ANSI style for the category. `"dim"` is used by default. 209 | 210 | #### `~AnsiColorFormatterOptions.categoryColor` 211 | 212 | The ANSI color for the category. No color is used by default. 213 | 214 | #### `~TextFormatterOptions.value` 215 | 216 | The format of the embedded values. Behaves the same as the 217 | [`value`](#textformatteroptions-value) option of the default text formatter. 218 | 219 | #### `~TextFormatterOptions.format` 220 | 221 | How those formatted parts are concatenated. Behaves the same as the 222 | [`format`](#textformatteroptions-format) option of the default text formatter. 223 | 224 | #### Text styles 225 | 226 | The `~AnsiColorFormatterOptions.timestampStyle`, 227 | `~AnsiColorFormatterOptions.levelStyle`, and 228 | `~AnsiColorFormatterOptions.categoryStyle` options can be one of the following: 229 | 230 | - `"bold"` 231 | - `"dim"` 232 | - `"italic"` 233 | - `"underline"` 234 | - `"strikethrough"` 235 | - `null` (no style) 236 | 237 | #### Colors 238 | 239 | The `~AnsiColorFormatterOptions.timestampColor`, 240 | `~AnsiColorFormatterOptions.levelColors` (an object with log levels as keys), 241 | and `~AnsiColorFormatterOptions.categoryColor` options can be one of 242 | the following: 243 | 244 | - `"black"` 245 | - `"red"` 246 | - `"green"` 247 | - `"yellow"` 248 | - `"blue"` 249 | - `"magenta"` 250 | - `"cyan"` 251 | - `"white"` 252 | - `null` (no color) 253 | 254 | ### JSON Lines formatter 255 | 256 | *This API is available since LogTape 0.11.0.* 257 | 258 | You can customize the JSON Lines formatter by calling 259 | the `getJsonLinesFormatter()` function with a `JsonLinesFormatterOptions` 260 | object. Customizable options include: 261 | 262 | #### `~JsonLinesFormatterOptions.categorySeparator` 263 | 264 | The separator between category names. For example, if the separator is `"."`, 265 | the category `["a", "b", "c"]` will be formatted as `"a.b.c"`. 266 | 267 | The default separator is `"."`. 268 | 269 | If this is a function, it will be called with the category array and should 270 | return a string or an array of strings, which will be used for rendering 271 | the category. 272 | 273 | #### `~JsonLinesFormatterOptions.message` 274 | 275 | The message format. This can be one of the following: 276 | 277 | - `"template"`: The raw message template is used as the message. 278 | - `"rendered"`: The message is rendered with the values. 279 | 280 | The default is `"rendered"`. 281 | 282 | #### `~JsonLinesFormatterOptions.properties` 283 | 284 | The properties format. This can be one of the following: 285 | 286 | - `"flatten"`: The properties are flattened into the root object. 287 | - `"prepend:"`: The properties are prepended with the given prefix 288 | (e.g., `"prepend:ctx_"` will prepend `ctx_` to each property key). 289 | - `"nest:"`: The properties are nested under the given key 290 | (e.g., `"nest:properties"` will nest the properties under the 291 | `properties` key). 292 | 293 | The default is `"nest:properties"`. 294 | 295 | 296 | Pattern-based redaction 297 | ----------------------- 298 | 299 | *This API is available since LogTape 0.10.0.* 300 | 301 | You can redact sensitive data in log records through 302 | [pattern-based redaction](./redaction.md#pattern-based-redaction) by wrapping 303 | your text formatter with a `redactByPattern()` function from 304 | *@logtape/redaction* package: 305 | 306 | ~~~~ typescript {8-10} twoslash 307 | import { getTextFormatter } from "@logtape/logtape"; 308 | import { 309 | EMAIL_ADDRESS_PATTERN, 310 | JWT_PATTERN, 311 | redactByPattern, 312 | } from "@logtape/redaction"; 313 | 314 | const formatter = redactByPattern(getTextFormatter(), [ 315 | EMAIL_ADDRESS_PATTERN, 316 | JWT_PATTERN, 317 | ]); 318 | ~~~~ 319 | 320 | The above code will create a text formatter that redacts email addresses 321 | and JSON Web Tokens (JWTs) in log records. The `redactByPattern()` function 322 | takes a `TextFormatter` and an array of patterns, and returns a new 323 | `TextFormatter` that redacts the sensitive data matching those patterns. 324 | 325 | For more information about it, see the [*Pattern-based redaction* 326 | section](./redaction.md#pattern-based-redaction). 327 | 328 | 329 | Fully customized text formatter 330 | ------------------------------- 331 | 332 | A text formatter is just a function that takes a log record and returns 333 | a string. The type of a text formatter is `TextFormatter`: 334 | 335 | ~~~~ typescript twoslash 336 | import type { LogRecord } from "@logtape/logtape"; 337 | // ---cut-before--- 338 | export type TextFormatter = (record: LogRecord) => string; 339 | ~~~~ 340 | 341 | If you want to build a text formatter from scratch, you can just write 342 | a function that takes a log record and returns a string. For example, 343 | the following function is a simple text formatter that formats log records into 344 | [JSON Lines]: 345 | 346 | ~~~~ typescript twoslash 347 | import type { LogRecord } from "@logtape/logtape"; 348 | // ---cut-before--- 349 | function jsonLinesFormatter(record: LogRecord): string { 350 | return JSON.stringify(record) + "\n"; 351 | } 352 | ~~~~ 353 | 354 | Of course, you can use the built-in `getJsonLinesFormatter()` function for 355 | more sophisticated JSON Lines formatting with customizable options. 356 | 357 | [JSON Lines]: https://jsonlines.org/ 358 | -------------------------------------------------------------------------------- /docs/manual/install.md: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | LogTape is available on [JSR] and [npm]. You can install LogTape for various 5 | JavaScript runtimes and package managers: 6 | 7 | :::code-group 8 | 9 | ~~~~ sh [Deno] 10 | deno add jsr:@logtape/logtape 11 | ~~~~ 12 | 13 | ~~~~ sh [npm] 14 | npm add @logtape/logtape 15 | ~~~~ 16 | 17 | ~~~~ sh [pnpm] 18 | pnpm add @logtape/logtape 19 | ~~~~ 20 | 21 | ~~~~ sh [Yarn] 22 | yarn add @logtape/logtape 23 | ~~~~ 24 | 25 | ~~~~ sh [Bun] 26 | bun add @logtape/logtape 27 | ~~~~ 28 | 29 | ::: 30 | 31 | > [!NOTE] 32 | > Although JSR supports Node.js and Bun, we recommend to install LogTape from 33 | > JSR only for Deno. For Node.js and Bun, we recommend to install LogTape from 34 | > npm. 35 | 36 | In case you want to install an unstable version of LogTape: 37 | 38 | :::code-group 39 | 40 | ~~~~ sh [npm] 41 | npm add @logtape/logtape@dev 42 | ~~~~ 43 | 44 | ~~~~ sh [pnpm] 45 | pnpm add @logtape/logtape@dev 46 | ~~~~ 47 | 48 | ~~~~ sh [Yarn] 49 | yarn add @logtape/logtape@dev 50 | ~~~~ 51 | 52 | ~~~~ sh [Bun] 53 | bun add @logtape/logtape@dev 54 | ~~~~ 55 | 56 | ::: 57 | 58 | > [!NOTE] 59 | > Although JSR supports unstable releases, there is currently no way to install 60 | > the *latest* unstable version of a package using `deno add`; instead, you need 61 | > to specify the specific version number of the unstable release: 62 | > 63 | > ~~~~ sh 64 | > deno add jsr:@logtape/logtape@1.2.3-dev.4 # Replace 1.2.3-dev.4 with the actual version number 65 | > ~~~~ 66 | 67 | [JSR]: https://jsr.io/@logtape/logtape 68 | [npm]: https://www.npmjs.com/package/@logtape/logtape 69 | -------------------------------------------------------------------------------- /docs/manual/levels.md: -------------------------------------------------------------------------------- 1 | Severity levels 2 | =============== 3 | 4 | When you're logging events in your application, not all messages are created 5 | equal. Some might be routine information, while others could be critical errors 6 | that need immediate attention. That's where severity levels come in. 7 | LogTape provides five severity levels to help you categorize your log messages 8 | effectively. 9 | 10 | 11 | Five severity levels 12 | -------------------- 13 | 14 | LogTape uses the following severity levels, listed from lowest to highest 15 | severity: 16 | 17 | 1. *Debug*: Detailed information useful for diagnosing problems. 18 | 2. *Information*: General information about the application's operation. 19 | 3. *Warning*: An unexpected event that doesn't prevent the application 20 | from functioning. 21 | 4. *Error*: A significant problem that prevented a specific operation from 22 | being completed. 23 | 5. *Fatal error*: A critical error that causes the application to abort. 24 | 25 | > [!NOTE] 26 | > LogTape currently does not support custom severity levels. 27 | 28 | Let's break down when you might use each of these: 29 | 30 | ### Debug 31 | 32 | Use this level for detailed information that's mostly useful when diagnosing 33 | problems. Debug logs are typically not shown in production environments. 34 | 35 | ~~~~ typescript twoslash 36 | import { getLogger } from "@logtape/logtape"; 37 | const logger = getLogger(); 38 | const elapsedMs = 0 as number; 39 | // ---cut-before--- 40 | logger.debug("Database query took {elapsedMs}ms to execute.", { elapsedMs }); 41 | ~~~~ 42 | 43 | ### Information 44 | 45 | This level is for general information about the application's operation. 46 | 47 | ~~~~ typescript twoslash 48 | import { getLogger } from "@logtape/logtape"; 49 | const logger = getLogger(); 50 | const username = "" as string; 51 | // ---cut-before--- 52 | logger.info("User {username} logged in successfully.", { username }); 53 | ~~~~ 54 | 55 | ### Warning 56 | 57 | Use this when something unexpected happened, but the application can continue 58 | functioning. This level is often used for events that are close to causing 59 | errors. 60 | 61 | ~~~~ typescript twoslash 62 | import { getLogger } from "@logtape/logtape"; 63 | const logger = getLogger(); 64 | // ---cut-before--- 65 | logger.warn("API rate limit is close to exceeding, 95% of limit reached."); 66 | ~~~~ 67 | 68 | ### Error 69 | 70 | This level indicates a significant problem that prevented a specific operation 71 | from being completed. Use this for errors that need attention but don't 72 | necessarily cause the application to stop. 73 | 74 | ~~~~ typescript twoslash 75 | import { getLogger } from "@logtape/logtape"; 76 | const logger = getLogger(); 77 | const err = new Error(); 78 | // ---cut-before--- 79 | logger.error( 80 | "Failed to save user data to database.", 81 | { userId: "12345", error: err }, 82 | ); 83 | ~~~~ 84 | 85 | ### Fatal error 86 | 87 | Use this for critical errors that cause the application to abort. Fatal errors 88 | are typically unrecoverable and require immediate attention. 89 | 90 | ~~~~ typescript twoslash 91 | import { getLogger } from "@logtape/logtape"; 92 | const logger = getLogger(); 93 | const error = new Error(); 94 | // ---cut-before--- 95 | logger.fatal("Unrecoverable error: Database connection lost.", { error }); 96 | ~~~~ 97 | 98 | 99 | Choosing the right level 100 | ------------------------ 101 | 102 | When deciding which level to use, consider: 103 | 104 | - *The impact on the application*: How severely does this event affect 105 | the application's operation? 106 | - *The urgency of response*: How quickly does someone need to act on 107 | this information? 108 | - *The audience*: Who needs to see this message? Developers? 109 | System administrators? End-users? 110 | 111 | 112 | Configuring severity levels 113 | --------------------------- 114 | 115 | *This API is available since LogTape 0.8.0.* 116 | 117 | You can control which severity levels are logged in different parts of your 118 | application. For example: 119 | 120 | ~~~~ typescript{6,11} twoslash 121 | // @noErrors: 2345 122 | import { configure } from "@logtape/logtape"; 123 | // ---cut-before--- 124 | await configure({ 125 | // ---cut-start--- 126 | sinks: { 127 | console(record) { }, 128 | file(record) { }, 129 | }, 130 | // ---cut-end--- 131 | // ... other configuration ... 132 | loggers: [ 133 | { 134 | category: ["app"], 135 | lowestLevel: "info", // This will log info and above 136 | sinks: ["console"], 137 | }, 138 | { 139 | category: ["app", "database"], 140 | lowestLevel: "debug", // This will log everything for database operations 141 | sinks: ["file"], 142 | } 143 | ] 144 | }); 145 | ~~~~ 146 | 147 | This configuration will log all levels from `"info"` up for most of the app, 148 | but will include `"debug"` logs for database operations. 149 | 150 | > [!NOTE] 151 | > The `~LoggerConfig.lowestLevel` is applied to the logger itself, not to its 152 | > sinks. In other words, the `~LoggerConfig.lowestLevel` property determines 153 | > which log records are emitted by the logger. For example, if the parent 154 | > logger has a `~LoggerConfig.lowestLevel` of `"debug"` with a sink `"console"`, 155 | > and the child logger has a `~LoggerConfig.lowestLevel` of `"info"`, 156 | > the child logger still won't emit `"debug"` records to the `"console"` sink. 157 | 158 | The `~LoggerConfig.lowestLevel` property does not inherit from parent loggers, 159 | but it is `"debug"` by default for all loggers. If you want to make child 160 | loggers inherit the severity level from their parent logger, you can use the 161 | `~LoggerConfig.filters` option instead. 162 | 163 | If you want make child loggers inherit the severity level from their parent 164 | logger, you can use the `~LoggerConfig.filters` option instead: 165 | 166 | ~~~~ typescript{4,9,13} twoslash 167 | // @noErrors: 2345 168 | import { configure } from "@logtape/logtape"; 169 | // ---cut-before--- 170 | await configure({ 171 | // ... other configuration ... 172 | filters: { 173 | infoAndAbove: "info", 174 | }, 175 | loggers: [ 176 | { 177 | category: ["app"], 178 | filters: ["infoAndAbove"], // This will log info and above 179 | }, 180 | { 181 | category: ["app", "database"], 182 | // This also logs info and above, because it inherits from the parent logger 183 | } 184 | ] 185 | }); 186 | ~~~~ 187 | 188 | In this example, the database logger will inherit the `aboveAndInfo` filter from 189 | the parent logger, so it will log all levels from `"info"` up. 190 | 191 | > [!TIP] 192 | > The `~LoggerConfig.filters` option takes a map of filter names to 193 | > `FilterLike`, where `FilterLike` is either a `Filter` function or a severity 194 | > level string. The severity level string will be resolved to a `Filter` that 195 | > filters log records with the specified severity level and above. 196 | > 197 | > See also the [*Level filter* section](./filters.md#level-filter). 198 | 199 | 200 | Comparing two severity levels 201 | ----------------------------- 202 | 203 | *This API is available since LogTape 0.8.0.* 204 | 205 | You can compare two severity levels to see which one is more severe by using 206 | the `compareLogLevel()` function. Since this function returns a number where 207 | negative means the first argument is less severe, zero means they are equal, 208 | and positive means the first argument is more severe, you can use it with 209 | [`Array.sort()`] or [`Array.toSorted()`] to sort severity levels: 210 | 211 | ~~~~ typescript twoslash 212 | // @noErrors: 2724 213 | import { type LogLevel, compareLogLevel } from "@logtape/logtape"; 214 | 215 | const levels: LogLevel[] = ["info", "debug", "error", "warning", "fatal"]; 216 | levels.sort(compareLogLevel); 217 | for (const level of levels) console.log(level); 218 | ~~~~ 219 | 220 | The above code will output: 221 | 222 | ~~~~ 223 | debug 224 | info 225 | warning 226 | error 227 | fatal 228 | ~~~~ 229 | 230 | [`Array.sort()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort 231 | [`Array.toSorted()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSorted 232 | 233 | 234 | Best practices 235 | -------------- 236 | 237 | 1. *Be consistent*: Use levels consistently across your application. 238 | 2. *Don't over-use lower levels*: Too many debug or info logs can make it 239 | harder to find important information. 240 | 3. *Include context*: Especially for higher severity levels, include relevant 241 | data to help diagnose the issue. 242 | 4. *Consider performance*: Remember that logging, especially at lower levels, 243 | can impact performance in high-volume scenarios. 244 | 245 | By using severity levels effectively, you can create logs that are informative, 246 | actionable, and easy to navigate. This will make debugging and monitoring your 247 | application much more manageable. 248 | -------------------------------------------------------------------------------- /docs/manual/library.md: -------------------------------------------------------------------------------- 1 | Using in libraries 2 | ================== 3 | 4 | One of LogTape's key features is its ability to be used effectively both by 5 | library authors and application developers. This chapter will explore 6 | how LogTape can be integrated into libraries and how application developers 7 | can then work with and configure logging for those libraries. 8 | 9 | 10 | For library authors 11 | ------------------- 12 | 13 | As a library author, you want to provide useful logging information without 14 | dictating how that information should be handled. LogTape allows you to do 15 | this seamlessly. 16 | 17 | ### Best practices for library authors 18 | 19 | 1. *Use namespaced categories*: Start your log categories with your library 20 | name to avoid conflicts. 21 | 22 | ~~~~ typescript twoslash 23 | import { getLogger } from "@logtape/logtape"; 24 | 25 | const logger = getLogger(["my-awesome-lib", "database"]); 26 | ~~~~ 27 | 28 | 2. *Don't `configure()` LogTape in your library*: Leave configuration 29 | to the application developer. 30 | 31 | 3. *Use appropriate log levels*: Use log levels judiciously. 32 | Reserve `"error"` for actual errors, use `"info"` for important 33 | but normal operations, and `"debug"` for detailed information useful 34 | during development. 35 | 36 | 4. *Provide context*: Use [structured logging](./struct.md) to provide 37 | relevant context with each log message. 38 | 39 | ~~~~ typescript twoslash 40 | import { getLogger } from "@logtape/logtape"; 41 | const logger = getLogger(["my-awesome-lib", "database"]); 42 | const dbHost: string = ""; 43 | const dbPort: number = 0; 44 | const dbUser: string = ""; 45 | // ---cut-before--- 46 | logger.info("Database connection established", { 47 | host: dbHost, 48 | port: dbPort, 49 | username: dbUser 50 | }); 51 | ~~~~ 52 | 53 | ### Example: Logging in a library 54 | 55 | Here's an example of how you might use LogTape in a library: 56 | 57 | ~~~~ typescript twoslash 58 | // my-awesome-lib/database.ts 59 | import { getLogger } from "@logtape/logtape"; 60 | 61 | export class Database { 62 | private logger = getLogger(["my-awesome-lib", "database"]); 63 | 64 | constructor( 65 | private host: string, 66 | private port: number, 67 | private user: string, 68 | ) { 69 | } 70 | 71 | connect() { 72 | this.logger.info("Attempting to connect to database", { 73 | host: this.host, 74 | port: this.port, 75 | user: this.user 76 | }); 77 | 78 | // Simulating connection logic 79 | if (Math.random() > 0.5) { 80 | this.logger.error("Failed to connect to database", { 81 | host: this.host, 82 | port: this.port, 83 | user: this.user 84 | }); 85 | throw new Error("Connection failed"); 86 | } 87 | 88 | this.logger.info("Successfully connected to database"); 89 | } 90 | 91 | query(sql: string) { 92 | this.logger.debug("Executing query", { sql }); 93 | // Query logic here 94 | } 95 | } 96 | ~~~~ 97 | 98 | 99 | For application developers 100 | -------------------------- 101 | 102 | As an application developer using a library that implements LogTape, 103 | you have full control over how logs from that library are handled. 104 | 105 | ### Configuring Logs for a Library 106 | 107 | 1. *Set up sinks*: Decide where you want logs to go (console, file, etc.) 108 | and set up appropriate [sinks](./sinks.md). 109 | 110 | 2. *Configure log levels*: You can set different log levels for different 111 | parts of the library. 112 | 113 | 3. *Add filters*: You can add [filters](./filters.md) to fine-tune which 114 | log messages you want to see. 115 | 116 | ### Example: Configuring logs for a library 117 | 118 | Here's how you might configure logging for the example library we created 119 | above: 120 | 121 | ~~~~ typescript twoslash 122 | // @noErrors: 2307 123 | import { getFileSink } from "@logtape/file"; 124 | import { configure, getConsoleSink } from "@logtape/logtape"; 125 | import { Database } from "my-awesome-lib"; 126 | 127 | await configure({ 128 | sinks: { 129 | console: getConsoleSink(), 130 | file: getFileSink("app.log") 131 | }, 132 | filters: { 133 | excludeDebug: (record) => record.level !== "debug" 134 | }, 135 | loggers: [ 136 | { 137 | category: ["my-awesome-lib"], 138 | lowestLevel: "info", 139 | sinks: ["console", "file"] 140 | }, 141 | { 142 | category: ["my-awesome-lib", "database"], 143 | lowestLevel: "debug", 144 | sinks: ["file"], 145 | filters: ["excludeDebug"] 146 | } 147 | ] 148 | }); 149 | 150 | const db = new Database("localhost", 5432, "user"); 151 | db.connect(); 152 | db.query("SELECT * FROM users"); 153 | ~~~~ 154 | 155 | In this configuration: 156 | 157 | - All logs from `"my-awesome-lib"` at `"info"` level and above will go 158 | to both `console` and `file`. 159 | 160 | - Database-specific logs at `"debug"` level and above will go to the `file`, 161 | but `"debug"` level logs are filtered out. 162 | -------------------------------------------------------------------------------- /docs/manual/redaction.md: -------------------------------------------------------------------------------- 1 | Data redaction 2 | ============== 3 | 4 | *The *@logtape/redaction* package is available since LogTape 0.10.0.* 5 | 6 | Sensitive data in logs can pose security and privacy risks. LogTape provides 7 | robust redaction capabilities through the *@logtape/redaction* package to help 8 | protect sensitive information from being exposed in your logs. 9 | 10 | LogTape has two distinct approaches to redact sensitive data: 11 | 12 | - [Pattern-based redaction](#pattern-based-redaction): Uses regular 13 | expressions to identify and redact sensitive data in formatted log output 14 | - [Field-based redaction](#field-based-redaction): Identifies and redacts 15 | sensitive fields by their names in structured log data 16 | 17 | [Both approaches have their strengths and use cases.](#comparing-redaction-approaches) 18 | This guide will help you understand when and how to use each. 19 | 20 | 21 | Installation 22 | ------------ 23 | 24 | LogTape provides data redaction capabilities through a separate package 25 | *@logtape/redaction*: 26 | 27 | ::: code-group 28 | 29 | ~~~~ bash [Deno] 30 | deno add jsr:@logtape/redaction 31 | ~~~~ 32 | 33 | ~~~~ bash [npm] 34 | npm add @logtape/redaction 35 | ~~~~ 36 | 37 | ~~~~ bash [pnpm] 38 | pnpm add @logtape/redaction 39 | ~~~~ 40 | 41 | ~~~~ bash [Yarn] 42 | yarn add @logtape/redaction 43 | ~~~~ 44 | 45 | ~~~~ bash [Bun] 46 | bun add @logtape/redaction 47 | ~~~~ 48 | 49 | ::: 50 | 51 | 52 | Pattern-based redaction 53 | ----------------------- 54 | 55 | Pattern-based redaction uses regular expressions to identify and redact 56 | sensitive data patterns like credit card numbers, email addresses, 57 | and tokens in the formatted output of logs. 58 | 59 | ### How it works 60 | 61 | The `redactByPattern()` function wraps a formatter (either a `TextFormatter` or 62 | `ConsoleFormatter`) and scans its output for matching patterns: 63 | 64 | ~~~~ typescript {8-11} twoslash 65 | import { defaultConsoleFormatter, getConsoleSink } from "@logtape/logtape"; 66 | import { 67 | EMAIL_ADDRESS_PATTERN, 68 | JWT_PATTERN, 69 | redactByPattern, 70 | } from "@logtape/redaction"; 71 | 72 | const formatter = redactByPattern(defaultConsoleFormatter, [ 73 | EMAIL_ADDRESS_PATTERN, 74 | JWT_PATTERN, 75 | ]); 76 | 77 | const sink = getConsoleSink({ formatter }); 78 | ~~~~ 79 | 80 | When a log is formatted, any text matching the provided patterns is replaced 81 | with a redacted value. 82 | 83 | ### Built-in patterns 84 | 85 | The `@logtape/redaction` package includes several built-in patterns: 86 | 87 | ~~~~ typescript twoslash 88 | import { 89 | CREDIT_CARD_NUMBER_PATTERN, 90 | EMAIL_ADDRESS_PATTERN, 91 | JWT_PATTERN, 92 | KR_RRN_PATTERN, 93 | US_SSN_PATTERN, 94 | } from "@logtape/redaction"; 95 | ~~~~ 96 | 97 | - `EMAIL_ADDRESS_PATTERN`: Redacts email addresses 98 | - `CREDIT_CARD_NUMBER_PATTERN`: Redacts credit card numbers 99 | - `JWT_PATTERN`: Redacts JSON Web Tokens 100 | - `US_SSN_PATTERN`: Redacts U.S. Social Security numbers 101 | - `KR_RRN_PATTERN`: Redacts South Korean resident registration numbers 102 | 103 | ### Creating custom patterns 104 | 105 | You can create custom patterns to match your specific needs: 106 | 107 | ~~~~ typescript {4-7} twoslash 108 | import { type RedactionPattern, redactByPattern } from "@logtape/redaction"; 109 | import { defaultConsoleFormatter, getConsoleSink } from "@logtape/logtape"; 110 | 111 | const API_KEY_PATTERN: RedactionPattern = { 112 | pattern: /xz([a-zA-Z0-9_-]{32})/g, 113 | replacement: "REDACTED_API_KEY", 114 | }; 115 | 116 | const formatter = redactByPattern(defaultConsoleFormatter, [ 117 | API_KEY_PATTERN, 118 | ]); 119 | 120 | const sink = getConsoleSink({ formatter }); 121 | ~~~~ 122 | 123 | > [!IMPORTANT] 124 | > Regular expressions must have the global (`g`) flag set, otherwise a 125 | > `TypeError` will be thrown. 126 | 127 | 128 | Field-based redaction 129 | --------------------- 130 | 131 | Field-based redaction identifies and redacts sensitive data by field names in 132 | structured log data. It works by examining the property names in the log record 133 | and redacting those that match specified patterns. 134 | 135 | ### How it works 136 | 137 | The `redactByField()` function wraps a sink and redacts properties in the log 138 | record before passing it to the sink: 139 | 140 | ~~~~ typescript twoslash 141 | import { getConsoleSink } from "@logtape/logtape"; 142 | import { redactByField } from "@logtape/redaction"; 143 | 144 | const sink = redactByField(getConsoleSink()); // [!code highlight] 145 | ~~~~ 146 | 147 | By default, it uses `DEFAULT_REDACT_FIELDS`, which includes common sensitive 148 | field patterns like `password`, `secret`, `token`, etc. 149 | 150 | ### Customizing field patterns 151 | 152 | You can provide your own field patterns: 153 | 154 | ~~~~ typescript {4-9} twoslash 155 | import { getConsoleSink } from "@logtape/logtape"; 156 | import { DEFAULT_REDACT_FIELDS, redactByField } from "@logtape/redaction"; 157 | 158 | const customSink = redactByField(getConsoleSink(), [ 159 | /pass(?:code|phrase|word)/i, 160 | /api[-_]?key/i, 161 | "secret", 162 | ...DEFAULT_REDACT_FIELDS 163 | ]); 164 | ~~~~ 165 | 166 | Field patterns can be strings (exact matches) or regular expressions. 167 | 168 | ### Customizing redaction behavior 169 | 170 | By default, field-based redaction removes matching fields. You can customize 171 | this behavior to replace them instead: 172 | 173 | ~~~~ typescript {6} twoslash 174 | import { getConsoleSink } from "@logtape/logtape"; 175 | import { redactByField } from "@logtape/redaction"; 176 | 177 | const customSink = redactByField(getConsoleSink(), { 178 | fieldPatterns: [/password/i, /secret/i], 179 | action: () => "[REDACTED]" // Replace with "[REDACTED]" instead of removing 180 | }); 181 | ~~~~ 182 | 183 | Field redaction is recursive and will redact sensitive fields in nested objects 184 | as well. 185 | 186 | 187 | Comparing redaction approaches 188 | ------------------------------ 189 | 190 | Each redaction approach has its strengths and weaknesses depending on your 191 | specific use case. 192 | 193 | ### Pattern-based redaction 194 | 195 | Pros: 196 | 197 | - More accurate at detecting structured patterns (credit cards, SSNs, etc.) 198 | - Works with any formatter regardless of data structure 199 | - Can redact data within message strings 200 | - Catches sensitive data even if it appears in unexpected places 201 | 202 | Cons: 203 | 204 | - Performance impact can be higher, especially with many patterns 205 | - Regex matching is applied to all output text 206 | - May produce false positives (redacting text that resembles sensitive data) 207 | - Operates after formatting, so sensitive data might exist in memory 208 | temporarily 209 | 210 | ### Field-based redaction 211 | 212 | Pros: 213 | 214 | - More efficient as it only checks field names, not values 215 | - Redacts data before it reaches the sink or formatter 216 | - Less likely to cause false positives 217 | - Works with any sink, regardless of formatter 218 | 219 | Cons: 220 | 221 | - Cannot detect sensitive data in free-form text or message templates 222 | - Only works for structured data fields 223 | - Requires knowledge of field names that contain sensitive data 224 | - May miss sensitive data with unexpected field names 225 | 226 | 227 | Usage examples 228 | ------------- 229 | 230 | ### Basic pattern-based redaction 231 | 232 | ~~~~ typescript {12-18} twoslash 233 | import { 234 | configure, 235 | defaultConsoleFormatter, 236 | getConsoleSink, 237 | } from "@logtape/logtape"; 238 | import { 239 | EMAIL_ADDRESS_PATTERN, 240 | JWT_PATTERN, 241 | redactByPattern, 242 | } from "@logtape/redaction"; 243 | 244 | const consoleSink = getConsoleSink({ 245 | formatter: redactByPattern( 246 | // Wrap the default formatter with pattern-based redaction 247 | defaultConsoleFormatter, 248 | [EMAIL_ADDRESS_PATTERN, JWT_PATTERN] 249 | ), 250 | }); 251 | 252 | await configure({ 253 | sinks: { 254 | console: consoleSink, 255 | }, 256 | loggers: [ 257 | { category: "my-app", sinks: ["console"] }, 258 | ], 259 | }); 260 | 261 | // Later in your code: 262 | import { getLogger } from "@logtape/logtape"; 263 | 264 | const logger = getLogger("my-app"); 265 | logger.info( 266 | "User email: user@example.com, token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." 267 | ); 268 | // Output will show: "User email: REDACTED@EMAIL.ADDRESS, token: [JWT REDACTED]" 269 | ~~~~ 270 | 271 | ### Basic field-based redaction 272 | 273 | ~~~~ typescript twoslash 274 | import { configure, getConsoleSink } from "@logtape/logtape"; 275 | import { redactByField } from "@logtape/redaction"; 276 | 277 | await configure({ 278 | sinks: { 279 | console: redactByField(getConsoleSink()), // [!code highlight] 280 | }, 281 | loggers: [ 282 | { category: "my-app", sinks: ["console"] }, 283 | ], 284 | }); 285 | 286 | // Later in your code: 287 | import { getLogger } from "@logtape/logtape"; 288 | 289 | const logger = getLogger("my-app"); 290 | logger.info("User authenticated", { 291 | username: "johndoe", 292 | password: "supersecret", // This field will be removed from the logged output 293 | email: "johndoe@example.com", // This field will be removed too 294 | }); 295 | ~~~~ 296 | 297 | ### Combining both approaches 298 | 299 | For maximum security, you can combine both approaches: 300 | 301 | ~~~~ typescript {13-22} twoslash 302 | import { 303 | configure, 304 | defaultConsoleFormatter, 305 | getConsoleSink, 306 | } from "@logtape/logtape"; 307 | import { 308 | EMAIL_ADDRESS_PATTERN, 309 | JWT_PATTERN, 310 | redactByField, 311 | redactByPattern, 312 | } from "@logtape/redaction"; 313 | 314 | // First apply field-based redaction to the sink 315 | const sink = redactByField( 316 | getConsoleSink({ 317 | // Then apply pattern-based redaction to the formatter 318 | formatter: redactByPattern( 319 | defaultConsoleFormatter, 320 | [EMAIL_ADDRESS_PATTERN, JWT_PATTERN] 321 | ) 322 | }) 323 | ); 324 | 325 | await configure({ 326 | sinks: { 327 | console: sink, 328 | }, 329 | loggers: [ 330 | { category: "my-app", sinks: ["console"] }, 331 | ], 332 | }); 333 | ~~~~ 334 | 335 | ### File sink with redaction 336 | 337 | ~~~~ typescript {9-14} twoslash 338 | import { getFileSink } from "@logtape/file"; 339 | import { configure, getTextFormatter } from "@logtape/logtape"; 340 | import { 341 | CREDIT_CARD_NUMBER_PATTERN, 342 | EMAIL_ADDRESS_PATTERN, 343 | redactByPattern, 344 | } from "@logtape/redaction"; 345 | 346 | const fileSink = getFileSink("app.log", { 347 | formatter: redactByPattern( 348 | getTextFormatter(), 349 | [CREDIT_CARD_NUMBER_PATTERN, EMAIL_ADDRESS_PATTERN] 350 | ), 351 | }); 352 | 353 | await configure({ 354 | sinks: { 355 | file: fileSink, 356 | }, 357 | loggers: [ 358 | { category: "my-app", sinks: ["file"] }, 359 | ], 360 | }); 361 | ~~~~ 362 | 363 | 364 | Best practices 365 | -------------- 366 | 367 | 1. *Choose the right approach*: 368 | 369 | - Use pattern-based redaction when you need to catch sensitive data in 370 | message strings and have well-defined patterns 371 | - Use field-based redaction for structured data with known field names 372 | - Combine both approaches for maximum security 373 | 374 | 2. *Be comprehensive*: 375 | 376 | - Define patterns for all types of sensitive data your application handles 377 | - Regularly review and update your redaction patterns as new types of 378 | sensitive data are introduced 379 | 380 | 3. *Test your redaction*: 381 | 382 | - Verify that sensitive data is properly redacted by examining your logs 383 | - Include edge cases in your testing (partial matches, data spanning 384 | multiple lines, etc.) 385 | 386 | 4. *Balance performance and security*: 387 | 388 | - For high-volume logs, consider using field-based redaction which is 389 | generally more efficient 390 | - For security-critical applications, use both approaches even if it means 391 | some performance overhead 392 | 393 | 5. *Document your approach*: 394 | 395 | - Make sure your team understands which data is being redacted and how 396 | - Include redaction strategies in your security documentation 397 | 398 | > [!WARNING] 399 | > No redaction system is perfect. Even with redaction in place, be cautious about 400 | > what information you log. It's better to avoid logging sensitive data in the 401 | > first place when possible. 402 | 403 | 404 | -------------------------------------------------------------------------------- /docs/manual/start.md: -------------------------------------------------------------------------------- 1 | 2 | Quick start 3 | =========== 4 | 5 | Setting up 6 | ---------- 7 | 8 | Set up LogTape in the entry point of your application using `configure()`: 9 | 10 | ~~~~ typescript twoslash 11 | import { configure, getConsoleSink } from "@logtape/logtape"; 12 | 13 | await configure({ 14 | sinks: { console: getConsoleSink() }, 15 | loggers: [ 16 | { category: "my-app", lowestLevel: "debug", sinks: ["console"] } 17 | ] 18 | }); 19 | ~~~~ 20 | 21 | > [!WARNING] 22 | > If you are composing a library, you should not set up LogTape in the library 23 | > itself. It is up to the application to set up LogTape. 24 | > 25 | > See also [*Using in libraries*](./library.md). 26 | 27 | And then you can use LogTape in your application or library: 28 | 29 | ~~~~ typescript twoslash 30 | import { getLogger } from "@logtape/logtape"; 31 | 32 | const logger = getLogger(["my-app", "my-module"]); 33 | 34 | export function myFunc(value: number): void { 35 | logger.debug `Hello, ${value}!`; 36 | } 37 | ~~~~ 38 | 39 | For detailed information, see [*Configuration*](./config.md). 40 | 41 | 42 | How to log 43 | ---------- 44 | 45 | There are total 5 log levels: `debug`, `info`, `warning`, `error`, `fatal` (in 46 | the order of verbosity). You can log messages with the following syntax: 47 | 48 | ~~~~ typescript twoslash 49 | import { getLogger } from "@logtape/logtape"; 50 | const logger = getLogger([]); 51 | const value = 0 as unknown; 52 | // ---cut-before--- 53 | logger.debug `This is a debug message with ${value}.`; 54 | logger.info `This is an info message with ${value}.`; 55 | logger.warn `This is a warning message with ${value}.`; 56 | logger.error `This is an error message with ${value}.`; 57 | logger.fatal `This is a fatal message with ${value}.`; 58 | ~~~~ 59 | 60 | ### Structured logging 61 | 62 | You can also log messages with a function call. In this case, log messages 63 | are structured data: 64 | 65 | ~~~~ typescript twoslash 66 | import { getLogger } from "@logtape/logtape"; 67 | const logger = getLogger([]); 68 | const value = 0 as unknown; 69 | // ---cut-before--- 70 | logger.debug("This is a debug message with {value}.", { value }); 71 | logger.info("This is an info message with {value}.", { value }); 72 | logger.warn("This is a warning message with {value}.", { value }); 73 | logger.error("This is an error message with {value}.", { value }); 74 | logger.fatal("This is a fatal message with {value}.", { value }); 75 | ~~~~ 76 | 77 | For detailed information, see [*Structured logging*](./struct.md). 78 | 79 | 80 | ### Lazy evaluation 81 | 82 | Sometimes, values to be logged are expensive to compute. In such cases, you 83 | can use a function to defer the computation so that it is only computed when 84 | the log message is actually logged: 85 | 86 | ~~~~ typescript twoslash 87 | import { getLogger } from "@logtape/logtape"; 88 | const logger = getLogger([]); 89 | /** 90 | * A hypothetical function that computes a value, which is expensive. 91 | * @returns The computed value. 92 | */ 93 | function computeValue(): unknown { return 0; } 94 | // ---cut-before--- 95 | logger.debug(l => l`This is a debug message with ${computeValue()}.`); 96 | logger.debug("Or you can use a function call: {value}.", () => { 97 | return { value: computeValue() }; 98 | }); 99 | ~~~~ 100 | -------------------------------------------------------------------------------- /docs/manual/struct.md: -------------------------------------------------------------------------------- 1 | Structured logging 2 | ================== 3 | 4 | Structured logging is an approach to logging that treats log entries as 5 | structured data rather than plain text. This method makes logs more easily 6 | searchable, filterable, and analyzable, especially when dealing with large 7 | volumes of log data. 8 | 9 | Benefits of structured logging include: 10 | 11 | - *Improved searchability*: Easily search for specific log entries based on 12 | structured fields. 13 | - *Better analysis*: Perform more sophisticated analysis on your logs using 14 | the structured data. 15 | - *Consistency*: Enforce a consistent format for your log data across 16 | your application. 17 | - *Machine-Readable*: Structured logs are easier for log management systems 18 | to process and analyze. 19 | 20 | LogTape provides built-in support for structured logging, allowing you to 21 | include additional context and metadata with your log messages. 22 | 23 | 24 | Logging structured data 25 | ----------------------- 26 | 27 | *This API is available since LogTape 0.11.0.* 28 | 29 | You can log structured data by passing an object as the first argument 30 | to any log method. The properties of this object will be included as 31 | structured data in the log record: 32 | 33 | ~~~~ typescript twoslash 34 | import { getLogger } from "@logtape/logtape"; 35 | 36 | const logger = getLogger(["my-app"]); 37 | 38 | logger.info({ 39 | userId: 123456, 40 | username: "johndoe", 41 | loginTime: new Date(), 42 | }); 43 | ~~~~ 44 | 45 | This will create a log entry with no message, but it will include the `userId`, 46 | `username`, and `loginTime` as structured fields in the log record. 47 | 48 | 49 | Including structured data in log messages 50 | ----------------------------------------- 51 | 52 | You can also log structured data with a message by passing the message as the 53 | first argument and the structured data as the second argument: 54 | 55 | ~~~~ typescript twoslash 56 | import { getLogger } from "@logtape/logtape"; 57 | const logger = getLogger(); 58 | // ---cut-before--- 59 | logger.info("User logged in", { 60 | userId: 123456, 61 | username: "johndoe", 62 | loginTime: new Date(), 63 | }); 64 | ~~~~ 65 | 66 | This will create a log entry with the message `"User logged in"` and include 67 | the `userId`, `username`, and `loginTime` as structured fields. 68 | 69 | You can use placeholders in your log messages. The values for these 70 | placeholders will be included as structured data. 71 | 72 | ~~~~ typescript twoslash 73 | import { getLogger } from "@logtape/logtape"; 74 | const logger = getLogger(); 75 | // ---cut-before--- 76 | logger.info("User {username} (ID: {userId}) logged in at {loginTime}", { 77 | userId: 123456, 78 | username: "johndoe", 79 | loginTime: new Date(), 80 | }); 81 | ~~~~ 82 | 83 | This method allows you to include the structured data directly in your log 84 | message while still maintaining it as separate fields in the log record. 85 | 86 | > [!TIP] 87 | > The way to log single curly braces `{` is to double the brace: 88 | > 89 | > ~~~~ typescript twoslash 90 | > import { getLogger } from "@logtape/logtape"; 91 | > const logger = getLogger(); 92 | > // ---cut-before--- 93 | > logger.debug("This logs {{single}} curly braces."); 94 | > ~~~~ 95 | 96 | > [!TIP] 97 | > Placeholders can have leading and trailing spaces. For example, 98 | > `{ username }` will match the property `"username"` *unless* there is 99 | > a property named `" username "` with exact spaces. In that case, 100 | > the exact property will be prioritized: 101 | > 102 | > ~~~~ typescript twoslash 103 | > import { getLogger } from "@logtape/logtape"; 104 | > const logger = getLogger(); 105 | > // ---cut-before--- 106 | > logger.info( 107 | > "User { username } logged in.", 108 | > { username: "johndoe" }, 109 | > ); 110 | > // -> User johndoe logged in. 111 | > logger.info( 112 | > "User { username } logged in.", 113 | > { " username ": "janedoe", username: "johndoe" }, 114 | > ); 115 | > // -> User janedoe logged in. 116 | > ~~~~ 117 | 118 | > [!NOTE] 119 | > Currently, template literals do not support structured data. You must use 120 | > method calls with an object argument to include structured data in your log 121 | > messages. 122 | 123 | 124 | Including all properties in log messages 125 | ---------------------------------------- 126 | 127 | *This API is available since LogTape 0.11.0.* 128 | 129 | Sometimes, you may want to include all the properties into the log message, 130 | but without listing them all explicitly. You can use the special placeholder 131 | `{*}` to include all properties of the structured data object in the log message: 132 | 133 | ~~~~ typescript twoslash 134 | import { getLogger } from "@logtape/logtape"; 135 | const logger = getLogger(); 136 | // ---cut-before--- 137 | logger.info("User logged in with properties {*}", { 138 | userId: 123456, 139 | username: "johndoe", 140 | loginTime: new Date(), 141 | }); 142 | ~~~~ 143 | 144 | > [!TIP] 145 | > 146 | > Actually, `logger.info({ ... })` is equivalent to 147 | > `logger.info("{*}", { ... })`, so you can use either method to log structured 148 | > data without a message. 149 | 150 | 151 | Lazy evaluation of structured data 152 | ---------------------------------- 153 | 154 | If computing the structured data is expensive and you want to avoid unnecessary 155 | computation when the log level is not enabled, you can use a function to provide 156 | the structured data: 157 | 158 | ~~~~ typescript twoslash 159 | import { getLogger } from "@logtape/logtape"; 160 | const logger = getLogger(); 161 | const startTime = performance.now(); 162 | /** 163 | * A hypothetical function that computes a value, which is expensive. 164 | * @returns The computed value. 165 | */ 166 | function expensiveComputation(): unknown { return 0; } 167 | // ---cut-before--- 168 | logger.debug("Expensive operation completed", () => ({ 169 | result: expensiveComputation(), 170 | duration: performance.now() - startTime 171 | })); 172 | ~~~~ 173 | 174 | The function will only be called if the debug log level is enabled. 175 | 176 | 177 | Configuring sinks for structured logging 178 | ---------------------------------------- 179 | 180 | To make the most of structured logging, you'll want to use sinks that can handle 181 | structured data. LogTape provides several ways to format structured logs: 182 | 183 | ### JSON Lines formatter 184 | 185 | *This API is available since LogTape 0.11.0.* 186 | 187 | The [JSON Lines formatter](./formatters.md#json-lines-formatter) is specifically 188 | designed for structured logging, outputting each log record as a JSON object 189 | on a separate line: 190 | 191 | ~~~~ typescript twoslash 192 | // @noErrors: 2345 193 | import { getFileSink } from "@logtape/file"; 194 | import { configure, jsonLinesFormatter } from "@logtape/logtape"; 195 | 196 | await configure({ 197 | sinks: { 198 | jsonl: getFileSink("log.jsonl", { 199 | formatter: jsonLinesFormatter 200 | }), 201 | }, 202 | // ... rest of configuration 203 | }); 204 | ~~~~ 205 | 206 | ### Custom formatter 207 | 208 | You can also create a custom formatter for JSON Lines format: 209 | 210 | ~~~~ typescript twoslash 211 | // @noErrors: 2345 212 | import { getFileSink } from "@logtape/file"; 213 | import { configure } from "@logtape/logtape"; 214 | 215 | await configure({ 216 | sinks: { 217 | jsonl: getFileSink("log.jsonl", { 218 | formatter: (record) => JSON.stringify(record) + "\n" 219 | }), 220 | }, 221 | // ... rest of configuration 222 | }); 223 | ~~~~ 224 | 225 | Both approaches will output each log record as a JSON object on a separate line, 226 | preserving the structure of your log data. 227 | 228 | > [!TIP] 229 | > If you want to monitor log messages formatted in JSON Lines in real-time 230 | > readably, you can utilize the `tail` and [`jq`] commands: 231 | > 232 | > ~~~~ sh 233 | > tail -f log.jsonl | jq . 234 | > ~~~~ 235 | 236 | [JSON Lines]: https://jsonlines.org/ 237 | [`jq`]: https://jqlang.github.io/jq/ 238 | 239 | 240 | Filtering based on structured data 241 | ---------------------------------- 242 | 243 | You can create filters that use the structured data in your log records: 244 | 245 | ~~~~ typescript twoslash 246 | import { configure, getConsoleSink } from "@logtape/logtape"; 247 | 248 | await configure({ 249 | sinks: { 250 | console: getConsoleSink() 251 | }, 252 | filters: { 253 | highPriorityOnly: (record) => 254 | record.properties.priority === "high" || record.level === "error" 255 | }, 256 | loggers: [ 257 | { 258 | category: ["my-app"], 259 | sinks: ["console"], 260 | filters: ["highPriorityOnly"] 261 | } 262 | ] 263 | }); 264 | ~~~~ 265 | 266 | This filter will only allow logs with a `"high"` priority or error level to pass through. 267 | 268 | 269 | Best practices 270 | -------------- 271 | 272 | 1. *Be consistent*: Use consistent field names across your application for 273 | similar types of data. 274 | 275 | 2. *Use appropriate data types*: Ensure that the values in your structured data 276 | are of appropriate types (e.g., numbers for numeric values, booleans for 277 | true/false values). 278 | 279 | 3. *Don't overload*: While it's tempting to include lots of data, be mindful of 280 | the volume of data you're logging. Include what's necessary for debugging 281 | and analysis, but avoid logging sensitive or redundant information. 282 | 283 | 4. *Use nested structures when appropriate*: For complex data, consider using 284 | nested objects to maintain a logical structure. 285 | 286 | 5. *Consider performance*: If you're logging high-volume data, be aware of 287 | the performance impact of generating and processing structured logs. 288 | 289 | 290 | -------------------------------------------------------------------------------- /docs/manual/testing.md: -------------------------------------------------------------------------------- 1 | Testing 2 | ======= 3 | 4 | Here are some tips for testing your application or library with LogTape. 5 | 6 | 7 | Reset configuration 8 | ------------------- 9 | 10 | You can reset the configuration of LogTape to its initial state. This is 11 | useful when you want to reset the configuration between tests. For example, 12 | the following code shows how to reset the configuration after a test 13 | (regardless of whether the test passes or fails) in Deno: 14 | 15 | ~~~~ typescript twoslash 16 | // @noErrors: 2345 17 | import { configure, reset } from "@logtape/logtape"; 18 | 19 | Deno.test("my test", async (t) => { 20 | await t.step("set up", async () => { 21 | await configure({ /* ... */ }); 22 | }); 23 | 24 | await t.step("run test", () => { 25 | // Run the test 26 | }); 27 | 28 | await t.step("tear down", async () => { 29 | await reset(); // [!code highlight] 30 | }); 31 | }); 32 | ~~~~ 33 | 34 | 35 | Buffer sink 36 | ----------- 37 | 38 | For testing purposes, you may want to collect log messages in memory. Although 39 | LogTape does not provide a built-in buffer sink, you can easily implement it: 40 | 41 | ~~~~ typescript twoslash 42 | // @noErrors: 2345 43 | import { type LogRecord, configure } from "@logtape/logtape"; 44 | 45 | const buffer: LogRecord[] = []; 46 | 47 | await configure({ 48 | sinks: { 49 | buffer: buffer.push.bind(buffer), // [!code highlight] 50 | }, 51 | // Omitted for brevity 52 | }); 53 | ~~~~ 54 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@biomejs/biome": "^1.8.3", 4 | "@cloudflare/workers-types": "^4.20240909.0", 5 | "@logtape/file": "^0.10.0-dev.167", 6 | "@logtape/logtape": "^0.11.0-dev.174", 7 | "@logtape/otel": "^0.2.0", 8 | "@logtape/redaction": "^0.10.0-dev.167", 9 | "@logtape/sentry": "^0.1.0", 10 | "@sentry/node": "^8.40.0", 11 | "@shikijs/vitepress-twoslash": "^1.17.6", 12 | "@teidesu/deno-types": "^1.46.3", 13 | "@types/bun": "^1.1.9", 14 | "@types/node": "^22.5.5", 15 | "markdown-it-jsr-ref": "^0.4.2", 16 | "vitepress": "^1.3.4", 17 | "vitepress-plugin-llms": "^1.1.0" 18 | }, 19 | "scripts": { 20 | "dev": "vitepress dev", 21 | "build": "vitepress build", 22 | "preview": "vitepress preview" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/screenshots: -------------------------------------------------------------------------------- 1 | ../screenshots -------------------------------------------------------------------------------- /file/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | File sinks for LogTape 4 | ====================== 5 | 6 | [![JSR][JSR badge]][JSR] 7 | [![npm][npm badge]][npm] 8 | 9 | This package provides file sinks for [LogTape]. You can use the file sinks to 10 | write log records to files. For details, read the docs: 11 | 12 | - [File sink] 13 | - [Rotating file sink] 14 | 15 | [JSR]: https://jsr.io/@logtape/file 16 | [JSR badge]: https://jsr.io/badges/@logtape/file 17 | [npm]: https://www.npmjs.com/package/@logtape/file 18 | [npm badge]: https://img.shields.io/npm/v/@logtape/file?logo=npm 19 | [LogTape]: https://logtape.org/ 20 | [File sink]: https://logtape.org/manual/sinks#file-sink 21 | [Rotating file sink]: https://logtape.org/manual/sinks#rotating-file-sink 22 | 23 | 24 | Installation 25 | ------------ 26 | 27 | This package is available on [JSR] and [npm]. You can install it for various 28 | JavaScript runtimes and package managers: 29 | 30 | ~~~~ sh 31 | deno add jsr:@logtape/file # for Deno 32 | npm add @logtape/file # for npm 33 | pnpm add @logtape/file # for pnpm 34 | yarn add @logtape/file # for Yarn 35 | bun add @logtape/file # for Bun 36 | ~~~~ 37 | 38 | 39 | Docs 40 | ---- 41 | 42 | The docs of this package is available at . 43 | For the API references, see . 44 | -------------------------------------------------------------------------------- /file/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@logtape/file", 3 | "version": "0.12.0", 4 | "license": "MIT", 5 | "exports": "./mod.ts", 6 | "exclude": [ 7 | "coverage/", 8 | "npm/", 9 | ".dnt-import-map.json" 10 | ], 11 | "tasks": { 12 | "dnt": "deno run -A dnt.ts", 13 | "test:bun": { 14 | "command": "cd npm/ && bun run ./test_runner.js && cd ../", 15 | "dependencies": [ 16 | "dnt" 17 | ] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /file/dnt.ts: -------------------------------------------------------------------------------- 1 | import { build, emptyDir } from "@deno/dnt"; 2 | import { maxWith } from "@std/collections/max-with"; 3 | import { compare } from "@std/semver/compare"; 4 | import { format } from "@std/semver/format"; 5 | import { parse } from "@std/semver/parse"; 6 | import type { SemVer } from "@std/semver/types"; 7 | import workspace from "../deno.json" with { type: "json" }; 8 | import metadata from "./deno.json" with { type: "json" }; 9 | 10 | await emptyDir("./npm"); 11 | 12 | const version = parse(Deno.args[0] ?? metadata.version); 13 | let minorVersion: SemVer = { 14 | ...version, 15 | patch: 0, 16 | prerelease: [], 17 | build: [], 18 | }; 19 | 20 | const logtapeResp = await fetch("https://registry.npmjs.com/@logtape/logtape"); 21 | const logtapeData = await logtapeResp.json(); 22 | const logtapeVersions = Object.keys(logtapeData.versions); 23 | if (!logtapeVersions.includes(format(minorVersion))) { 24 | minorVersion = maxWith(logtapeVersions.map(parse), compare) ?? Deno.exit(1); 25 | } 26 | 27 | const imports = { 28 | "@logtape/logtape": `npm:@logtape/logtape@^${format(minorVersion)}`, 29 | ...workspace.imports, 30 | }; 31 | 32 | await Deno.writeTextFile( 33 | ".dnt-import-map.json", 34 | JSON.stringify({ imports }, undefined, 2), 35 | ); 36 | 37 | await build({ 38 | package: { 39 | name: metadata.name, 40 | version: format(version), 41 | description: "File sink and rotating file sink for LogTape", 42 | keywords: ["logging", "log", "logger", "file", "sink", "rotating"], 43 | license: "MIT", 44 | author: { 45 | name: "Hong Minhee", 46 | email: "hong@minhee.org", 47 | url: "https://hongminhee.org/", 48 | }, 49 | homepage: "https://logtape.org/", 50 | repository: { 51 | type: "git", 52 | url: "git+https://github.com/dahlia/logtape.git", 53 | directory: "file/", 54 | }, 55 | bugs: { 56 | url: "https://github.com/dahlia/logtape/issues", 57 | }, 58 | funding: [ 59 | "https://github.com/sponsors/dahlia", 60 | ], 61 | }, 62 | outDir: "./npm", 63 | entryPoints: ["./mod.ts"], 64 | importMap: "./.dnt-import-map.json", 65 | mappings: { 66 | "./filesink.jsr.ts": "./filesink.node.ts", 67 | "./filesink.deno.ts": "./filesink.node.ts", 68 | }, 69 | shims: { 70 | deno: "dev", 71 | }, 72 | typeCheck: "both", 73 | declaration: "separate", 74 | declarationMap: true, 75 | compilerOptions: { 76 | lib: ["ES2021", "DOM"], 77 | }, 78 | async postBuild() { 79 | await Deno.copyFile("../LICENSE", "npm/LICENSE"); 80 | await Deno.copyFile("README.md", "npm/README.md"); 81 | }, 82 | }); 83 | 84 | // cSpell: ignore Minhee filesink 85 | -------------------------------------------------------------------------------- /file/filesink.base.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultTextFormatter, 3 | type LogRecord, 4 | type Sink, 5 | type StreamSinkOptions, 6 | } from "@logtape/logtape"; 7 | 8 | /** 9 | * Options for the {@link getBaseFileSink} function. 10 | */ 11 | export type FileSinkOptions = StreamSinkOptions & { 12 | /** 13 | * If `true`, the file is not opened until the first write. Defaults to `false`. 14 | */ 15 | lazy?: boolean; 16 | }; 17 | 18 | /** 19 | * A platform-specific file sink driver. 20 | * @typeParam TFile The type of the file descriptor. 21 | */ 22 | export interface FileSinkDriver { 23 | /** 24 | * Open a file for appending and return a file descriptor. 25 | * @param path A path to the file to open. 26 | */ 27 | openSync(path: string): TFile; 28 | 29 | /** 30 | * Write a chunk of data to the file. 31 | * @param fd The file descriptor. 32 | * @param chunk The data to write. 33 | */ 34 | writeSync(fd: TFile, chunk: Uint8Array): void; 35 | 36 | /** 37 | * Flush the file to ensure that all data is written to the disk. 38 | * @param fd The file descriptor. 39 | */ 40 | flushSync(fd: TFile): void; 41 | 42 | /** 43 | * Close the file. 44 | * @param fd The file descriptor. 45 | */ 46 | closeSync(fd: TFile): void; 47 | } 48 | 49 | /** 50 | * Get a platform-independent file sink. 51 | * 52 | * @typeParam TFile The type of the file descriptor. 53 | * @param path A path to the file to write to. 54 | * @param options The options for the sink and the file driver. 55 | * @returns A sink that writes to the file. The sink is also a disposable 56 | * object that closes the file when disposed. 57 | */ 58 | export function getBaseFileSink( 59 | path: string, 60 | options: FileSinkOptions & FileSinkDriver, 61 | ): Sink & Disposable { 62 | const formatter = options.formatter ?? defaultTextFormatter; 63 | const encoder = options.encoder ?? new TextEncoder(); 64 | let fd = options.lazy ? null : options.openSync(path); 65 | const sink: Sink & Disposable = (record: LogRecord) => { 66 | if (fd === null) { 67 | fd = options.openSync(path); 68 | } 69 | options.writeSync(fd, encoder.encode(formatter(record))); 70 | options.flushSync(fd); 71 | }; 72 | sink[Symbol.dispose] = () => { 73 | if (fd !== null) { 74 | options.closeSync(fd); 75 | } 76 | }; 77 | return sink; 78 | } 79 | 80 | /** 81 | * Options for the {@link getBaseRotatingFileSink} function. 82 | */ 83 | export interface RotatingFileSinkOptions extends Omit { 84 | /** 85 | * The maximum bytes of the file before it is rotated. 1 MiB by default. 86 | */ 87 | maxSize?: number; 88 | 89 | /** 90 | * The maximum number of files to keep. 5 by default. 91 | */ 92 | maxFiles?: number; 93 | } 94 | 95 | /** 96 | * A platform-specific rotating file sink driver. 97 | */ 98 | export interface RotatingFileSinkDriver extends FileSinkDriver { 99 | /** 100 | * Get the size of the file. 101 | * @param path A path to the file. 102 | * @returns The `size` of the file in bytes, in an object. 103 | */ 104 | statSync(path: string): { size: number }; 105 | 106 | /** 107 | * Rename a file. 108 | * @param oldPath A path to the file to rename. 109 | * @param newPath A path to be renamed to. 110 | */ 111 | renameSync(oldPath: string, newPath: string): void; 112 | } 113 | 114 | /** 115 | * Get a platform-independent rotating file sink. 116 | * 117 | * This sink writes log records to a file, and rotates the file when it reaches 118 | * the `maxSize`. The rotated files are named with the original file name 119 | * followed by a dot and a number, starting from 1. The number is incremented 120 | * for each rotation, and the maximum number of files to keep is `maxFiles`. 121 | * 122 | * @param path A path to the file to write to. 123 | * @param options The options for the sink and the file driver. 124 | * @returns A sink that writes to the file. The sink is also a disposable 125 | * object that closes the file when disposed. 126 | */ 127 | export function getBaseRotatingFileSink( 128 | path: string, 129 | options: RotatingFileSinkOptions & RotatingFileSinkDriver, 130 | ): Sink & Disposable { 131 | const formatter = options.formatter ?? defaultTextFormatter; 132 | const encoder = options.encoder ?? new TextEncoder(); 133 | const maxSize = options.maxSize ?? 1024 * 1024; 134 | const maxFiles = options.maxFiles ?? 5; 135 | let offset: number = 0; 136 | try { 137 | const stat = options.statSync(path); 138 | offset = stat.size; 139 | } catch { 140 | // Continue as the offset is already 0. 141 | } 142 | let fd = options.openSync(path); 143 | function shouldRollover(bytes: Uint8Array): boolean { 144 | return offset + bytes.length > maxSize; 145 | } 146 | function performRollover(): void { 147 | options.closeSync(fd); 148 | for (let i = maxFiles - 1; i > 0; i--) { 149 | const oldPath = `${path}.${i}`; 150 | const newPath = `${path}.${i + 1}`; 151 | try { 152 | options.renameSync(oldPath, newPath); 153 | } catch (_) { 154 | // Continue if the file does not exist. 155 | } 156 | } 157 | options.renameSync(path, `${path}.1`); 158 | offset = 0; 159 | fd = options.openSync(path); 160 | } 161 | const sink: Sink & Disposable = (record: LogRecord) => { 162 | const bytes = encoder.encode(formatter(record)); 163 | if (shouldRollover(bytes)) performRollover(); 164 | options.writeSync(fd, bytes); 165 | options.flushSync(fd); 166 | offset += bytes.length; 167 | }; 168 | sink[Symbol.dispose] = () => options.closeSync(fd); 169 | return sink; 170 | } 171 | -------------------------------------------------------------------------------- /file/filesink.deno.ts: -------------------------------------------------------------------------------- 1 | import type { Sink } from "@logtape/logtape"; 2 | import { 3 | type FileSinkOptions, 4 | getBaseFileSink, 5 | getBaseRotatingFileSink, 6 | type RotatingFileSinkDriver, 7 | type RotatingFileSinkOptions, 8 | } from "./filesink.base.ts"; 9 | 10 | /** 11 | * A Deno-specific file sink driver. 12 | */ 13 | export const denoDriver: RotatingFileSinkDriver = { 14 | openSync(path: string) { 15 | return Deno.openSync(path, { create: true, append: true }); 16 | }, 17 | writeSync(fd, chunk) { 18 | fd.writeSync(chunk); 19 | }, 20 | flushSync(fd) { 21 | fd.syncSync(); 22 | }, 23 | closeSync(fd) { 24 | fd.close(); 25 | }, 26 | statSync: globalThis?.Deno.statSync, 27 | renameSync: globalThis?.Deno.renameSync, 28 | }; 29 | 30 | /** 31 | * Get a file sink. 32 | * 33 | * Note that this function is unavailable in the browser. 34 | * 35 | * @param path A path to the file to write to. 36 | * @param options The options for the sink. 37 | * @returns A sink that writes to the file. The sink is also a disposable 38 | * object that closes the file when disposed. 39 | */ 40 | export function getFileSink( 41 | path: string, 42 | options: FileSinkOptions = {}, 43 | ): Sink & Disposable { 44 | return getBaseFileSink(path, { ...options, ...denoDriver }); 45 | } 46 | 47 | /** 48 | * Get a rotating file sink. 49 | * 50 | * This sink writes log records to a file, and rotates the file when it reaches 51 | * the `maxSize`. The rotated files are named with the original file name 52 | * followed by a dot and a number, starting from 1. The number is incremented 53 | * for each rotation, and the maximum number of files to keep is `maxFiles`. 54 | * 55 | * Note that this function is unavailable in the browser. 56 | * 57 | * @param path A path to the file to write to. 58 | * @param options The options for the sink and the file driver. 59 | * @returns A sink that writes to the file. The sink is also a disposable 60 | * object that closes the file when disposed. 61 | */ 62 | export function getRotatingFileSink( 63 | path: string, 64 | options: RotatingFileSinkOptions = {}, 65 | ): Sink & Disposable { 66 | return getBaseRotatingFileSink(path, { ...options, ...denoDriver }); 67 | } 68 | 69 | // cSpell: ignore filesink 70 | -------------------------------------------------------------------------------- /file/filesink.jsr.ts: -------------------------------------------------------------------------------- 1 | import type { Sink } from "@logtape/logtape"; 2 | import type { 3 | FileSinkOptions, 4 | RotatingFileSinkOptions, 5 | } from "./filesink.base.ts"; 6 | 7 | const filesink: Omit = 8 | // dnt-shim-ignore 9 | await ("Deno" in globalThis 10 | ? import("./filesink.deno.ts") 11 | : import("./filesink.node.ts")); 12 | 13 | /** 14 | * Get a file sink. 15 | * 16 | * Note that this function is unavailable in the browser. 17 | * 18 | * @param path A path to the file to write to. 19 | * @param options The options for the sink. 20 | * @returns A sink that writes to the file. The sink is also a disposable 21 | * object that closes the file when disposed. 22 | */ 23 | export function getFileSink( 24 | path: string, 25 | options: FileSinkOptions = {}, 26 | ): Sink & Disposable { 27 | return filesink.getFileSink(path, options); 28 | } 29 | 30 | /** 31 | * Get a rotating file sink. 32 | * 33 | * This sink writes log records to a file, and rotates the file when it reaches 34 | * the `maxSize`. The rotated files are named with the original file name 35 | * followed by a dot and a number, starting from 1. The number is incremented 36 | * for each rotation, and the maximum number of files to keep is `maxFiles`. 37 | * 38 | * Note that this function is unavailable in the browser. 39 | * 40 | * @param path A path to the file to write to. 41 | * @param options The options for the sink and the file driver. 42 | * @returns A sink that writes to the file. The sink is also a disposable 43 | * object that closes the file when disposed. 44 | */ 45 | export function getRotatingFileSink( 46 | path: string, 47 | options: RotatingFileSinkOptions = {}, 48 | ): Sink & Disposable { 49 | return filesink.getRotatingFileSink(path, options); 50 | } 51 | 52 | // cSpell: ignore filesink 53 | -------------------------------------------------------------------------------- /file/filesink.node.ts: -------------------------------------------------------------------------------- 1 | import type { Sink } from "@logtape/logtape"; 2 | import fs from "node:fs"; 3 | import { 4 | type FileSinkOptions, 5 | getBaseFileSink, 6 | getBaseRotatingFileSink, 7 | type RotatingFileSinkDriver, 8 | type RotatingFileSinkOptions, 9 | } from "./filesink.base.ts"; 10 | 11 | /** 12 | * A Node.js-specific file sink driver. 13 | */ 14 | export const nodeDriver: RotatingFileSinkDriver = { 15 | openSync(path: string) { 16 | return fs.openSync(path, "a"); 17 | }, 18 | writeSync: fs.writeSync, 19 | flushSync: fs.fsyncSync, 20 | closeSync: fs.closeSync, 21 | statSync: fs.statSync, 22 | renameSync: fs.renameSync, 23 | }; 24 | 25 | /** 26 | * Get a file sink. 27 | * 28 | * Note that this function is unavailable in the browser. 29 | * 30 | * @param path A path to the file to write to. 31 | * @param options The options for the sink. 32 | * @returns A sink that writes to the file. The sink is also a disposable 33 | * object that closes the file when disposed. 34 | */ 35 | export function getFileSink( 36 | path: string, 37 | options: FileSinkOptions = {}, 38 | ): Sink & Disposable { 39 | return getBaseFileSink(path, { ...options, ...nodeDriver }); 40 | } 41 | 42 | /** 43 | * Get a rotating file sink. 44 | * 45 | * This sink writes log records to a file, and rotates the file when it reaches 46 | * the `maxSize`. The rotated files are named with the original file name 47 | * followed by a dot and a number, starting from 1. The number is incremented 48 | * for each rotation, and the maximum number of files to keep is `maxFiles`. 49 | * 50 | * Note that this function is unavailable in the browser. 51 | * 52 | * @param path A path to the file to write to. 53 | * @param options The options for the sink and the file driver. 54 | * @returns A sink that writes to the file. The sink is also a disposable 55 | * object that closes the file when disposed. 56 | */ 57 | export function getRotatingFileSink( 58 | path: string, 59 | options: RotatingFileSinkOptions = {}, 60 | ): Sink & Disposable { 61 | return getBaseRotatingFileSink(path, { ...options, ...nodeDriver }); 62 | } 63 | 64 | // cSpell: ignore filesink 65 | -------------------------------------------------------------------------------- /file/filesink.test.ts: -------------------------------------------------------------------------------- 1 | import { isDeno } from "@david/which-runtime"; 2 | import type { Sink } from "@logtape/logtape"; 3 | import { assertEquals } from "@std/assert/assert-equals"; 4 | import { assertThrows } from "@std/assert/assert-throws"; 5 | import { join } from "@std/path/join"; 6 | import fs from "node:fs"; 7 | import { debug, error, fatal, info, warning } from "../logtape/fixtures.ts"; 8 | import { type FileSinkDriver, getBaseFileSink } from "./filesink.base.ts"; 9 | import { getFileSink, getRotatingFileSink } from "./filesink.deno.ts"; 10 | 11 | Deno.test("getBaseFileSink()", () => { 12 | const path = Deno.makeTempFileSync(); 13 | let sink: Sink & Disposable; 14 | if (isDeno) { 15 | const driver: FileSinkDriver = { 16 | openSync(path: string) { 17 | return Deno.openSync(path, { create: true, append: true }); 18 | }, 19 | writeSync(fd, chunk) { 20 | fd.writeSync(chunk); 21 | }, 22 | flushSync(fd) { 23 | fd.syncSync(); 24 | }, 25 | closeSync(fd) { 26 | fd.close(); 27 | }, 28 | }; 29 | sink = getBaseFileSink(path, driver); 30 | } else { 31 | const driver: FileSinkDriver = { 32 | openSync(path: string) { 33 | return fs.openSync(path, "a"); 34 | }, 35 | writeSync: fs.writeSync, 36 | flushSync: fs.fsyncSync, 37 | closeSync: fs.closeSync, 38 | }; 39 | sink = getBaseFileSink(path, driver); 40 | } 41 | sink(debug); 42 | sink(info); 43 | sink(warning); 44 | sink(error); 45 | sink(fatal); 46 | sink[Symbol.dispose](); 47 | assertEquals( 48 | Deno.readTextFileSync(path), 49 | `\ 50 | 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456! 51 | 2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456! 52 | 2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456! 53 | 2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456! 54 | 2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456! 55 | `, 56 | ); 57 | }); 58 | 59 | Deno.test("getBaseFileSink() with lazy option", () => { 60 | const pathDir = Deno.makeTempDirSync(); 61 | const path = join(pathDir, "test.log"); 62 | let sink: Sink & Disposable; 63 | if (isDeno) { 64 | const driver: FileSinkDriver = { 65 | openSync(path: string) { 66 | return Deno.openSync(path, { create: true, append: true }); 67 | }, 68 | writeSync(fd, chunk) { 69 | fd.writeSync(chunk); 70 | }, 71 | flushSync(fd) { 72 | fd.syncSync(); 73 | }, 74 | closeSync(fd) { 75 | fd.close(); 76 | }, 77 | }; 78 | sink = getBaseFileSink(path, { ...driver, lazy: true }); 79 | } else { 80 | const driver: FileSinkDriver = { 81 | openSync(path: string) { 82 | return fs.openSync(path, "a"); 83 | }, 84 | writeSync: fs.writeSync, 85 | flushSync: fs.fsyncSync, 86 | closeSync: fs.closeSync, 87 | }; 88 | sink = getBaseFileSink(path, { ...driver, lazy: true }); 89 | } 90 | if (isDeno) { 91 | assertThrows( 92 | () => Deno.lstatSync(path), 93 | Deno.errors.NotFound, 94 | ); 95 | } else { 96 | assertEquals(fs.existsSync(path), false); 97 | } 98 | sink(debug); 99 | sink(info); 100 | sink(warning); 101 | sink(error); 102 | sink(fatal); 103 | sink[Symbol.dispose](); 104 | assertEquals( 105 | Deno.readTextFileSync(path), 106 | `\ 107 | 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456! 108 | 2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456! 109 | 2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456! 110 | 2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456! 111 | 2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456! 112 | `, 113 | ); 114 | }); 115 | 116 | Deno.test("getFileSink()", () => { 117 | const path = Deno.makeTempFileSync(); 118 | const sink: Sink & Disposable = getFileSink(path); 119 | sink(debug); 120 | sink(info); 121 | sink(warning); 122 | sink(error); 123 | sink(fatal); 124 | sink[Symbol.dispose](); 125 | assertEquals( 126 | Deno.readTextFileSync(path), 127 | `\ 128 | 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456! 129 | 2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456! 130 | 2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456! 131 | 2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456! 132 | 2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456! 133 | `, 134 | ); 135 | }); 136 | 137 | Deno.test("getRotatingFileSink()", () => { 138 | const path = Deno.makeTempFileSync(); 139 | const sink: Sink & Disposable = getRotatingFileSink(path, { 140 | maxSize: 150, 141 | }); 142 | sink(debug); 143 | assertEquals( 144 | Deno.readTextFileSync(path), 145 | "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n", 146 | ); 147 | sink(info); 148 | assertEquals( 149 | Deno.readTextFileSync(path), 150 | `\ 151 | 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456! 152 | 2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456! 153 | `, 154 | ); 155 | sink(warning); 156 | assertEquals( 157 | Deno.readTextFileSync(path), 158 | "2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!\n", 159 | ); 160 | assertEquals( 161 | Deno.readTextFileSync(`${path}.1`), 162 | `\ 163 | 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456! 164 | 2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456! 165 | `, 166 | ); 167 | sink(error); 168 | assertEquals( 169 | Deno.readTextFileSync(path), 170 | `\ 171 | 2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456! 172 | 2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456! 173 | `, 174 | ); 175 | assertEquals( 176 | Deno.readTextFileSync(`${path}.1`), 177 | `\ 178 | 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456! 179 | 2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456! 180 | `, 181 | ); 182 | sink(fatal); 183 | sink[Symbol.dispose](); 184 | assertEquals( 185 | Deno.readTextFileSync(path), 186 | "2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!\n", 187 | ); 188 | assertEquals( 189 | Deno.readTextFileSync(`${path}.1`), 190 | `\ 191 | 2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456! 192 | 2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456! 193 | `, 194 | ); 195 | assertEquals( 196 | Deno.readTextFileSync(`${path}.2`), 197 | `\ 198 | 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456! 199 | 2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456! 200 | `, 201 | ); 202 | 203 | const dirPath = Deno.makeTempDirSync(); 204 | const path2 = join(dirPath, "log"); 205 | const sink2: Sink & Disposable = getRotatingFileSink(path2, { 206 | maxSize: 150, 207 | }); 208 | sink2(debug); 209 | assertEquals( 210 | Deno.readTextFileSync(path2), 211 | "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n", 212 | ); 213 | sink2[Symbol.dispose](); 214 | }); 215 | 216 | // cSpell: ignore filesink 217 | -------------------------------------------------------------------------------- /file/mod.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | FileSinkDriver, 3 | FileSinkOptions, 4 | RotatingFileSinkDriver, 5 | RotatingFileSinkOptions, 6 | } from "./filesink.base.ts"; 7 | export { getFileSink, getRotatingFileSink } from "./filesink.jsr.ts"; 8 | -------------------------------------------------------------------------------- /logtape/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | LogTape 4 | ======= 5 | 6 | [![JSR][JSR badge]][JSR] 7 | [![npm][npm badge]][npm] 8 | [![GitHub Actions][GitHub Actions badge]][GitHub Actions] 9 | [![Codecov][Codecov badge]][Codecov] 10 | 11 | LogTape is a logging library for JavaScript and TypeScript. It provides a 12 | simple and flexible logging system that is easy to use and easy to extend. 13 | The highlights of LogTape are: 14 | 15 | - *Zero dependencies*: LogTape has zero dependencies. You can use LogTape 16 | without worrying about the dependencies of LogTape. 17 | 18 | - *[Library support]*: LogTape is designed to be used in libraries as well 19 | as applications. You can use LogTape in libraries to provide logging 20 | capabilities to users of the libraries. 21 | 22 | - *[Runtime diversity]*: LogTape supports Deno, Node.js, Bun, edge functions, 23 | and browsers. You can use LogTape in various environments without 24 | changing the code. 25 | 26 | - *[Structured logging]*: You can log messages with structured data. 27 | 28 | - *[Hierarchical categories]*: LogTape uses a hierarchical category system 29 | to manage loggers. You can control the verbosity of log messages by 30 | setting the log level of loggers at different levels of the category 31 | hierarchy. 32 | 33 | - *[Template literals]*: LogTape supports template literals for log messages. 34 | You can use template literals to log messages with placeholders and 35 | values. 36 | 37 | - *[Built-in data redaction]*: LogTape provides robust capabilities to redact 38 | sensitive information from logs using pattern-based or field-based approaches. 39 | 40 | - *[Dead simple sinks]*: You can easily add your own sinks to LogTape. 41 | 42 | ![](./screenshots/web-console.png) 43 | ![](./screenshots/terminal-console.png) 44 | 45 | [JSR]: https://jsr.io/@logtape/logtape 46 | [JSR badge]: https://jsr.io/badges/@logtape/logtape 47 | [npm]: https://www.npmjs.com/package/@logtape/logtape 48 | [npm badge]: https://img.shields.io/npm/v/@logtape/logtape?logo=npm 49 | [GitHub Actions]: https://github.com/dahlia/logtape/actions/workflows/main.yaml 50 | [GitHub Actions badge]: https://github.com/dahlia/logtape/actions/workflows/main.yaml/badge.svg 51 | [Codecov]: https://codecov.io/gh/dahlia/logtape 52 | [Codecov badge]: https://codecov.io/gh/dahlia/logtape/graph/badge.svg?token=yOejfcuX7r 53 | [Library support]: https://logtape.org/manual/library 54 | [Runtime diversity]: https://logtape.org/manual/install 55 | [Structured logging]: https://logtape.org/manual/struct 56 | [Hierarchical categories]: https://logtape.org/manual/categories 57 | [Template literals]: https://logtape.org/manual/start#how-to-log 58 | [Built-in data redaction]: https://logtape.org/manual/redaction 59 | [Dead simple sinks]: https://logtape.org/manual/sinks 60 | 61 | 62 | Installation 63 | ------------ 64 | 65 | LogTape is available on [JSR] and [npm]. You can install LogTape for various 66 | JavaScript runtimes and package managers: 67 | 68 | ~~~~ sh 69 | deno add jsr:@logtape/logtape # for Deno 70 | npm add @logtape/logtape # for npm 71 | pnpm add @logtape/logtape # for pnpm 72 | yarn add @logtape/logtape # for Yarn 73 | bun add @logtape/logtape # for Bun 74 | ~~~~ 75 | 76 | See also the [installation manual][Runtime diversity] for more details. 77 | 78 | 79 | Docs 80 | ---- 81 | 82 | The docs of LogTape is available at . 83 | For the API references, see . 84 | -------------------------------------------------------------------------------- /logtape/context.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert/assert-equals"; 2 | import { delay } from "@std/async/delay"; 3 | import { AsyncLocalStorage } from "node:async_hooks"; 4 | import { configure, reset } from "./config.ts"; 5 | import { withContext } from "./context.ts"; 6 | import { getLogger } from "./logger.ts"; 7 | import type { LogRecord } from "./record.ts"; 8 | 9 | Deno.test("withContext()", async (t) => { 10 | const buffer: LogRecord[] = []; 11 | 12 | await t.step("set up", async () => { 13 | await configure({ 14 | sinks: { 15 | buffer: buffer.push.bind(buffer), 16 | }, 17 | loggers: [ 18 | { category: "my-app", sinks: ["buffer"], lowestLevel: "debug" }, 19 | { category: ["logtape", "meta"], sinks: [], lowestLevel: "warning" }, 20 | ], 21 | contextLocalStorage: new AsyncLocalStorage(), 22 | reset: true, 23 | }); 24 | }); 25 | 26 | await t.step("test", () => { 27 | getLogger("my-app").debug("hello", { foo: 1, bar: 2 }); 28 | assertEquals(buffer, [ 29 | { 30 | category: ["my-app"], 31 | level: "debug", 32 | message: ["hello"], 33 | rawMessage: "hello", 34 | properties: { foo: 1, bar: 2 }, 35 | timestamp: buffer[0].timestamp, 36 | }, 37 | ]); 38 | buffer.pop(); 39 | const rv = withContext({ foo: 3, baz: 4 }, () => { 40 | getLogger("my-app").debug("world", { foo: 1, bar: 2 }); 41 | return 123; 42 | }); 43 | assertEquals(rv, 123); 44 | assertEquals(buffer, [ 45 | { 46 | category: ["my-app"], 47 | level: "debug", 48 | message: ["world"], 49 | rawMessage: "world", 50 | properties: { foo: 1, bar: 2, baz: 4 }, 51 | timestamp: buffer[0].timestamp, 52 | }, 53 | ]); 54 | buffer.pop(); 55 | getLogger("my-app").debug("hello", { foo: 1, bar: 2 }); 56 | assertEquals(buffer, [ 57 | { 58 | category: ["my-app"], 59 | level: "debug", 60 | message: ["hello"], 61 | rawMessage: "hello", 62 | properties: { foo: 1, bar: 2 }, 63 | timestamp: buffer[0].timestamp, 64 | }, 65 | ]); 66 | }); 67 | 68 | await t.step("nesting", () => { 69 | while (buffer.length > 0) buffer.pop(); 70 | withContext({ foo: 1, bar: 2 }, () => { 71 | withContext({ foo: 3, baz: 4 }, () => { 72 | getLogger("my-app").debug("hello"); 73 | }); 74 | }); 75 | assertEquals(buffer, [ 76 | { 77 | category: ["my-app"], 78 | level: "debug", 79 | message: ["hello"], 80 | rawMessage: "hello", 81 | properties: { foo: 3, bar: 2, baz: 4 }, 82 | timestamp: buffer[0].timestamp, 83 | }, 84 | ]); 85 | }); 86 | 87 | await t.step("concurrent runs", async () => { 88 | while (buffer.length > 0) buffer.pop(); 89 | await Promise.all([ 90 | (async () => { 91 | await delay(Math.random() * 100); 92 | withContext({ foo: 1 }, () => { 93 | getLogger("my-app").debug("foo"); 94 | }); 95 | })(), 96 | (async () => { 97 | await delay(Math.random() * 100); 98 | withContext({ bar: 2 }, () => { 99 | getLogger("my-app").debug("bar"); 100 | }); 101 | })(), 102 | (async () => { 103 | await delay(Math.random() * 100); 104 | withContext({ baz: 3 }, () => { 105 | getLogger("my-app").debug("baz"); 106 | }); 107 | })(), 108 | (async () => { 109 | await delay(Math.random() * 100); 110 | withContext({ qux: 4 }, () => { 111 | getLogger("my-app").debug("qux"); 112 | }); 113 | })(), 114 | ]); 115 | assertEquals(buffer.length, 4); 116 | for (const log of buffer) { 117 | if (log.message[0] === "foo") { 118 | assertEquals(log.properties, { foo: 1 }); 119 | } else if (log.message[0] === "bar") { 120 | assertEquals(log.properties, { bar: 2 }); 121 | } else if (log.message[0] === "baz") { 122 | assertEquals(log.properties, { baz: 3 }); 123 | } else { 124 | assertEquals(log.properties, { qux: 4 }); 125 | } 126 | } 127 | }); 128 | 129 | await t.step("tear down", async () => { 130 | await reset(); 131 | }); 132 | 133 | const metaBuffer: LogRecord[] = []; 134 | 135 | await t.step("set up", async () => { 136 | await configure({ 137 | sinks: { 138 | buffer: buffer.push.bind(buffer), 139 | metaBuffer: metaBuffer.push.bind(metaBuffer), 140 | }, 141 | loggers: [ 142 | { category: "my-app", sinks: ["buffer"], lowestLevel: "debug" }, 143 | { 144 | category: ["logtape", "meta"], 145 | sinks: ["metaBuffer"], 146 | lowestLevel: "warning", 147 | }, 148 | ], 149 | reset: true, 150 | }); 151 | }); 152 | 153 | await t.step("without settings", () => { 154 | while (buffer.length > 0) buffer.pop(); 155 | const rv = withContext({ foo: 1 }, () => { 156 | getLogger("my-app").debug("hello", { bar: 2 }); 157 | return 123; 158 | }); 159 | assertEquals(rv, 123); 160 | assertEquals(buffer, [ 161 | { 162 | category: ["my-app"], 163 | level: "debug", 164 | message: ["hello"], 165 | rawMessage: "hello", 166 | properties: { bar: 2 }, 167 | timestamp: buffer[0].timestamp, 168 | }, 169 | ]); 170 | assertEquals(metaBuffer, [ 171 | { 172 | category: ["logtape", "meta"], 173 | level: "warning", 174 | message: [ 175 | "Context-local storage is not configured. " + 176 | "Specify contextLocalStorage option in the configure() function.", 177 | ], 178 | properties: {}, 179 | rawMessage: "Context-local storage is not configured. " + 180 | "Specify contextLocalStorage option in the configure() function.", 181 | timestamp: metaBuffer[0].timestamp, 182 | }, 183 | ]); 184 | }); 185 | 186 | await t.step("tear down", async () => { 187 | await reset(); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /logtape/context.ts: -------------------------------------------------------------------------------- 1 | import { LoggerImpl } from "./logger.ts"; 2 | 3 | /** 4 | * A generic interface for a context-local storage. It resembles 5 | * the {@link AsyncLocalStorage} API from Node.js. 6 | * @typeParam T The type of the context-local store. 7 | * @since 0.7.0 8 | */ 9 | export interface ContextLocalStorage { 10 | /** 11 | * Runs a callback with the given store as the context-local store. 12 | * @param store The store to use as the context-local store. 13 | * @param callback The callback to run. 14 | * @returns The return value of the callback. 15 | */ 16 | run(store: T, callback: () => R): R; 17 | 18 | /** 19 | * Returns the current context-local store. 20 | * @returns The current context-local store, or `undefined` if there is no 21 | * store. 22 | */ 23 | getStore(): T | undefined; 24 | } 25 | 26 | /** 27 | * Runs a callback with the given implicit context. Every single log record 28 | * in the callback will have the given context. 29 | * 30 | * If no `contextLocalStorage` is configured, this function does nothing and 31 | * just returns the return value of the callback. It also logs a warning to 32 | * the `["logtape", "meta"]` logger in this case. 33 | * @param context The context to inject. 34 | * @param callback The callback to run. 35 | * @returns The return value of the callback. 36 | * @since 0.7.0 37 | */ 38 | export function withContext( 39 | context: Record, 40 | callback: () => T, 41 | ): T { 42 | const rootLogger = LoggerImpl.getLogger(); 43 | if (rootLogger.contextLocalStorage == null) { 44 | LoggerImpl.getLogger(["logtape", "meta"]).warn( 45 | "Context-local storage is not configured. " + 46 | "Specify contextLocalStorage option in the configure() function.", 47 | ); 48 | return callback(); 49 | } 50 | const parentContext = rootLogger.contextLocalStorage.getStore() ?? {}; 51 | return rootLogger.contextLocalStorage.run( 52 | { ...parentContext, ...context }, 53 | callback, 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /logtape/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@logtape/logtape", 3 | "version": "0.12.0", 4 | "license": "MIT", 5 | "exports": "./mod.ts", 6 | "exclude": [ 7 | "npm/" 8 | ], 9 | "tasks": { 10 | "dnt": "deno run -A dnt.ts", 11 | "test:bun": { 12 | "command": "cd npm/ && bun run ./test_runner.js && cd ../", 13 | "dependencies": [ 14 | "dnt" 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /logtape/dnt.ts: -------------------------------------------------------------------------------- 1 | import { build, emptyDir } from "@deno/dnt"; 2 | import metadata from "./deno.json" with { type: "json" }; 3 | 4 | await emptyDir("./npm"); 5 | 6 | await build({ 7 | package: { 8 | name: metadata.name, 9 | version: Deno.args[0] ?? metadata.version, 10 | description: "Simple logging library with zero dependencies for " + 11 | "Deno/Node.js/Bun/browsers", 12 | keywords: ["logging", "log", "logger"], 13 | license: "MIT", 14 | author: { 15 | name: "Hong Minhee", 16 | email: "hong@minhee.org", 17 | url: "https://hongminhee.org/", 18 | }, 19 | homepage: "https://logtape.org/", 20 | repository: { 21 | type: "git", 22 | url: "git+https://github.com/dahlia/logtape.git", 23 | directory: "logtape/", 24 | }, 25 | bugs: { 26 | url: "https://github.com/dahlia/logtape/issues", 27 | }, 28 | funding: [ 29 | "https://github.com/sponsors/dahlia", 30 | ], 31 | }, 32 | outDir: "./npm", 33 | entryPoints: ["./mod.ts"], 34 | importMap: "../deno.json", 35 | shims: { 36 | deno: "dev", 37 | }, 38 | typeCheck: "both", 39 | declaration: "separate", 40 | declarationMap: true, 41 | compilerOptions: { 42 | lib: ["ES2021", "DOM"], 43 | }, 44 | async postBuild() { 45 | await Deno.writeTextFile( 46 | "npm/esm/nodeUtil.js", 47 | 'import util from "./nodeUtil.cjs";\nexport default util;\n', 48 | ); 49 | await Deno.copyFile("nodeUtil.cjs", "npm/esm/nodeUtil.cjs"); 50 | await Deno.copyFile("nodeUtil.cjs", "npm/script/nodeUtil.js"); 51 | await Deno.copyFile("../LICENSE", "npm/LICENSE"); 52 | await Deno.copyFile("README.md", "npm/README.md"); 53 | }, 54 | }); 55 | 56 | // cSpell: ignore Minhee filesink 57 | -------------------------------------------------------------------------------- /logtape/filter.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "@std/assert/assert"; 2 | import { assertFalse } from "@std/assert/assert-false"; 3 | import { assertStrictEquals } from "@std/assert/assert-strict-equals"; 4 | import { assertThrows } from "@std/assert/assert-throws"; 5 | import { type Filter, getLevelFilter, toFilter } from "./filter.ts"; 6 | import { debug, error, fatal, info, warning } from "./fixtures.ts"; 7 | import type { LogLevel } from "./level.ts"; 8 | 9 | Deno.test("getLevelFilter()", () => { 10 | const noneFilter = getLevelFilter(null); 11 | assertFalse(noneFilter(fatal)); 12 | assertFalse(noneFilter(error)); 13 | assertFalse(noneFilter(warning)); 14 | assertFalse(noneFilter(info)); 15 | assertFalse(noneFilter(debug)); 16 | 17 | const fatalFilter = getLevelFilter("fatal"); 18 | assert(fatalFilter(fatal)); 19 | assertFalse(fatalFilter(error)); 20 | assertFalse(fatalFilter(warning)); 21 | assertFalse(fatalFilter(info)); 22 | assertFalse(fatalFilter(debug)); 23 | 24 | const errorFilter = getLevelFilter("error"); 25 | assert(errorFilter(fatal)); 26 | assert(errorFilter(error)); 27 | assertFalse(errorFilter(warning)); 28 | assertFalse(errorFilter(info)); 29 | assertFalse(errorFilter(debug)); 30 | 31 | const warningFilter = getLevelFilter("warning"); 32 | assert(warningFilter(fatal)); 33 | assert(warningFilter(error)); 34 | assert(warningFilter(warning)); 35 | assertFalse(warningFilter(info)); 36 | assertFalse(warningFilter(debug)); 37 | 38 | const infoFilter = getLevelFilter("info"); 39 | assert(infoFilter(fatal)); 40 | assert(infoFilter(error)); 41 | assert(infoFilter(warning)); 42 | assert(infoFilter(info)); 43 | assertFalse(infoFilter(debug)); 44 | 45 | const debugFilter = getLevelFilter("debug"); 46 | assert(debugFilter(fatal)); 47 | assert(debugFilter(error)); 48 | assert(debugFilter(warning)); 49 | assert(debugFilter(info)); 50 | assert(debugFilter(debug)); 51 | 52 | assertThrows( 53 | () => getLevelFilter("invalid" as LogLevel), 54 | TypeError, 55 | "Invalid log level: invalid.", 56 | ); 57 | }); 58 | 59 | Deno.test("toFilter()", () => { 60 | const hasJunk: Filter = (record) => record.category.includes("junk"); 61 | assertStrictEquals(toFilter(hasJunk), hasJunk); 62 | 63 | const infoFilter = toFilter("info"); 64 | assertFalse(infoFilter(debug)); 65 | assert(infoFilter(info)); 66 | assert(infoFilter(warning)); 67 | }); 68 | -------------------------------------------------------------------------------- /logtape/filter.ts: -------------------------------------------------------------------------------- 1 | import type { LogLevel } from "./level.ts"; 2 | import type { LogRecord } from "./record.ts"; 3 | 4 | /** 5 | * A filter is a function that accepts a log record and returns `true` if the 6 | * record should be passed to the sink. 7 | * 8 | * @param record The log record to filter. 9 | * @returns `true` if the record should be passed to the sink. 10 | */ 11 | export type Filter = (record: LogRecord) => boolean; 12 | 13 | /** 14 | * A filter-like value is either a {@link Filter} or a {@link LogLevel}. 15 | * `null` is also allowed to represent a filter that rejects all records. 16 | */ 17 | export type FilterLike = Filter | LogLevel | null; 18 | 19 | /** 20 | * Converts a {@link FilterLike} value to an actual {@link Filter}. 21 | * 22 | * @param filter The filter-like value to convert. 23 | * @returns The actual filter. 24 | */ 25 | export function toFilter(filter: FilterLike): Filter { 26 | if (typeof filter === "function") return filter; 27 | return getLevelFilter(filter); 28 | } 29 | 30 | /** 31 | * Returns a filter that accepts log records with the specified level. 32 | * 33 | * @param level The level to filter by. If `null`, the filter will reject all 34 | * records. 35 | * @returns The filter. 36 | */ 37 | export function getLevelFilter(level: LogLevel | null): Filter { 38 | if (level == null) return () => false; 39 | if (level === "fatal") { 40 | return (record: LogRecord) => record.level === "fatal"; 41 | } else if (level === "error") { 42 | return (record: LogRecord) => 43 | record.level === "fatal" || record.level === "error"; 44 | } else if (level === "warning") { 45 | return (record: LogRecord) => 46 | record.level === "fatal" || 47 | record.level === "error" || 48 | record.level === "warning"; 49 | } else if (level === "info") { 50 | return (record: LogRecord) => 51 | record.level === "fatal" || 52 | record.level === "error" || 53 | record.level === "warning" || 54 | record.level === "info"; 55 | } else if (level === "debug") return () => true; 56 | throw new TypeError(`Invalid log level: ${level}.`); 57 | } 58 | -------------------------------------------------------------------------------- /logtape/fixtures.ts: -------------------------------------------------------------------------------- 1 | import type { LogRecord } from "./record.ts"; 2 | 3 | export const info: LogRecord = { 4 | level: "info", 5 | category: ["my-app", "junk"], 6 | message: ["Hello, ", 123, " & ", 456, "!"], 7 | rawMessage: "Hello, {a} & {b}!", 8 | timestamp: 1700000000000, 9 | properties: {}, 10 | }; 11 | 12 | export const debug: LogRecord = { 13 | ...info, 14 | level: "debug", 15 | }; 16 | 17 | export const warning: LogRecord = { 18 | ...info, 19 | level: "warning", 20 | }; 21 | 22 | export const error: LogRecord = { 23 | ...info, 24 | level: "error", 25 | }; 26 | 27 | export const fatal: LogRecord = { 28 | ...info, 29 | level: "fatal", 30 | }; 31 | -------------------------------------------------------------------------------- /logtape/level.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "@std/assert/assert"; 2 | import { assertEquals } from "@std/assert/assert-equals"; 3 | import { assertFalse } from "@std/assert/assert-false"; 4 | import { assertThrows } from "@std/assert/assert-throws"; 5 | import { 6 | compareLogLevel, 7 | isLogLevel, 8 | type LogLevel, 9 | parseLogLevel, 10 | } from "./level.ts"; 11 | 12 | Deno.test("parseLogLevel()", () => { 13 | assertEquals(parseLogLevel("debug"), "debug"); 14 | assertEquals(parseLogLevel("info"), "info"); 15 | assertEquals(parseLogLevel("warning"), "warning"); 16 | assertEquals(parseLogLevel("error"), "error"); 17 | assertEquals(parseLogLevel("fatal"), "fatal"); 18 | assertEquals(parseLogLevel("DEBUG"), "debug"); 19 | assertEquals(parseLogLevel("INFO"), "info"); 20 | assertEquals(parseLogLevel("WARNING"), "warning"); 21 | assertEquals(parseLogLevel("ERROR"), "error"); 22 | assertEquals(parseLogLevel("FATAL"), "fatal"); 23 | assertThrows( 24 | () => parseLogLevel("invalid"), 25 | TypeError, 26 | "Invalid log level: invalid.", 27 | ); 28 | }); 29 | 30 | Deno.test("isLogLevel()", () => { 31 | assert(isLogLevel("debug")); 32 | assert(isLogLevel("info")); 33 | assert(isLogLevel("warning")); 34 | assert(isLogLevel("error")); 35 | assert(isLogLevel("fatal")); 36 | assertFalse(isLogLevel("DEBUG")); 37 | assertFalse(isLogLevel("invalid")); 38 | }); 39 | 40 | Deno.test("compareLogLevel()", () => { 41 | const levels: LogLevel[] = ["info", "debug", "error", "warning", "fatal"]; 42 | levels.sort(compareLogLevel); 43 | assertEquals(levels, ["debug", "info", "warning", "error", "fatal"]); 44 | }); 45 | -------------------------------------------------------------------------------- /logtape/level.ts: -------------------------------------------------------------------------------- 1 | const logLevels = ["debug", "info", "warning", "error", "fatal"] as const; 2 | 3 | /** 4 | * The severity level of a {@link LogRecord}. 5 | */ 6 | export type LogLevel = typeof logLevels[number]; 7 | 8 | /** 9 | * Parses a log level from a string. 10 | * 11 | * @param level The log level as a string. This is case-insensitive. 12 | * @returns The log level. 13 | * @throws {TypeError} If the log level is invalid. 14 | */ 15 | export function parseLogLevel(level: string): LogLevel { 16 | level = level.toLowerCase(); 17 | switch (level) { 18 | case "debug": 19 | case "info": 20 | case "warning": 21 | case "error": 22 | case "fatal": 23 | return level; 24 | default: 25 | throw new TypeError(`Invalid log level: ${level}.`); 26 | } 27 | } 28 | 29 | /** 30 | * Checks if a string is a valid log level. This function can be used as 31 | * as a type guard to narrow the type of a string to a {@link LogLevel}. 32 | * 33 | * @param level The log level as a string. This is case-sensitive. 34 | * @returns `true` if the string is a valid log level. 35 | */ 36 | export function isLogLevel(level: string): level is LogLevel { 37 | switch (level) { 38 | case "debug": 39 | case "info": 40 | case "warning": 41 | case "error": 42 | case "fatal": 43 | return true; 44 | default: 45 | return false; 46 | } 47 | } 48 | 49 | /** 50 | * Compares two log levels. 51 | * @param a The first log level. 52 | * @param b The second log level. 53 | * @returns A negative number if `a` is less than `b`, a positive number if `a` 54 | * is greater than `b`, or zero if they are equal. 55 | * @since 0.8.0 56 | */ 57 | export function compareLogLevel(a: LogLevel, b: LogLevel): number { 58 | const aIndex = logLevels.indexOf(a); 59 | if (aIndex < 0) { 60 | throw new TypeError(`Invalid log level: ${JSON.stringify(a)}.`); 61 | } 62 | const bIndex = logLevels.indexOf(b); 63 | if (bIndex < 0) { 64 | throw new TypeError(`Invalid log level: ${JSON.stringify(b)}.`); 65 | } 66 | return aIndex - bIndex; 67 | } 68 | -------------------------------------------------------------------------------- /logtape/mod.ts: -------------------------------------------------------------------------------- 1 | export { 2 | type Config, 3 | ConfigError, 4 | configure, 5 | configureSync, 6 | dispose, 7 | disposeSync, 8 | getConfig, 9 | type LoggerConfig, 10 | reset, 11 | resetSync, 12 | } from "./config.ts"; 13 | export { type ContextLocalStorage, withContext } from "./context.ts"; 14 | export { 15 | type Filter, 16 | type FilterLike, 17 | getLevelFilter, 18 | toFilter, 19 | } from "./filter.ts"; 20 | export { 21 | type AnsiColor, 22 | ansiColorFormatter, 23 | type AnsiColorFormatterOptions, 24 | type AnsiStyle, 25 | type ConsoleFormatter, 26 | defaultConsoleFormatter, 27 | defaultTextFormatter, 28 | type FormattedValues, 29 | getAnsiColorFormatter, 30 | getJsonLinesFormatter, 31 | getTextFormatter, 32 | jsonLinesFormatter, 33 | type JsonLinesFormatterOptions, 34 | type TextFormatter, 35 | type TextFormatterOptions, 36 | } from "./formatter.ts"; 37 | export { 38 | compareLogLevel, 39 | isLogLevel, 40 | type LogLevel, 41 | parseLogLevel, 42 | } from "./level.ts"; 43 | export { getLogger, type Logger } from "./logger.ts"; 44 | export type { LogRecord } from "./record.ts"; 45 | export { 46 | type ConsoleSinkOptions, 47 | getConsoleSink, 48 | getStreamSink, 49 | type Sink, 50 | type StreamSinkOptions, 51 | withFilter, 52 | } from "./sink.ts"; 53 | 54 | // cSpell: ignore filesink 55 | -------------------------------------------------------------------------------- /logtape/nodeUtil.cjs: -------------------------------------------------------------------------------- 1 | let util = null; 2 | if ( 3 | typeof window === "undefined" && ( 4 | "process" in globalThis && "versions" in globalThis.process && 5 | "node" in globalThis.process.versions && 6 | typeof globalThis.caches === "undefined" && 7 | typeof globalThis.addEventListener !== "function" || 8 | "Bun" in globalThis 9 | ) 10 | ) { 11 | try { 12 | // Intentionally confuse static analysis of bundlers: 13 | const $require = [require]; 14 | util = $require[0](`${["node", "util"].join(":")}`); 15 | } catch { 16 | util = null; 17 | } 18 | } 19 | 20 | module.exports = util; 21 | -------------------------------------------------------------------------------- /logtape/nodeUtil.ts: -------------------------------------------------------------------------------- 1 | // Detect if we're in a browser environment without using window directly 2 | function isBrowser(): boolean { 3 | try { 4 | return ( 5 | // @ts-ignore: Browser detection 6 | typeof document !== "undefined" || 7 | // @ts-ignore: React Native detection 8 | typeof navigator !== "undefined" && navigator.product === "ReactNative" 9 | ); 10 | } catch { 11 | return false; 12 | } 13 | } 14 | 15 | interface InspectOptions { 16 | colors?: boolean; 17 | depth?: number | null; 18 | compact?: boolean; 19 | [key: string]: unknown; 20 | } 21 | 22 | interface UtilInterface { 23 | inspect(obj: unknown, options?: InspectOptions): string; 24 | } 25 | 26 | // Default implementation with fallback 27 | let utilModule: UtilInterface = { 28 | inspect(obj: unknown, options?: InspectOptions): string { 29 | const indent = options?.compact === true ? undefined : 2; 30 | return JSON.stringify(obj, null, indent); 31 | }, 32 | }; 33 | 34 | // Only try to import node:util in non-browser environments 35 | if (!isBrowser()) { 36 | if ("Deno" in globalThis) { 37 | // @ts-ignore: Deno.inspect() exists 38 | utilModule = { inspect: Deno.inspect }; 39 | } else { 40 | import("node:util").then((util) => { 41 | utilModule.inspect = util.inspect; 42 | }); 43 | } 44 | } 45 | 46 | export default utilModule; 47 | -------------------------------------------------------------------------------- /logtape/record.ts: -------------------------------------------------------------------------------- 1 | import type { LogLevel } from "./level.ts"; 2 | 3 | /** 4 | * A log record. 5 | */ 6 | export interface LogRecord { 7 | /** 8 | * The category of the logger that produced the log record. 9 | */ 10 | readonly category: readonly string[]; 11 | 12 | /** 13 | * The log level. 14 | */ 15 | readonly level: LogLevel; 16 | 17 | /** 18 | * The log message. This is the result of substituting the message template 19 | * with the values. The number of elements in this array is always odd, 20 | * with the message template values interleaved between the substitution 21 | * values. 22 | */ 23 | readonly message: readonly unknown[]; 24 | 25 | /** 26 | * The raw log message. This is the original message template without any 27 | * further processing. It can be either: 28 | * 29 | * - A string without any substitutions if the log record was created with 30 | * a method call syntax, e.g., "Hello, {name}!" for 31 | * `logger.info("Hello, {name}!", { name })`. 32 | * - A template string array if the log record was created with a tagged 33 | * template literal syntax, e.g., `["Hello, ", "!"]` for 34 | * ``logger.info`Hello, ${name}!```. 35 | * 36 | * @since 0.6.0 37 | */ 38 | readonly rawMessage: string | TemplateStringsArray; 39 | 40 | /** 41 | * The timestamp of the log record in milliseconds since the Unix epoch. 42 | */ 43 | readonly timestamp: number; 44 | 45 | /** 46 | * The extra properties of the log record. 47 | */ 48 | readonly properties: Record; 49 | } 50 | -------------------------------------------------------------------------------- /logtape/sink.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert/assert-equals"; 2 | import { assertThrows } from "@std/assert/assert-throws"; 3 | import makeConsoleMock from "consolemock"; 4 | import { debug, error, fatal, info, warning } from "./fixtures.ts"; 5 | import { defaultTextFormatter } from "./formatter.ts"; 6 | import type { LogLevel } from "./level.ts"; 7 | import type { LogRecord } from "./record.ts"; 8 | import { getConsoleSink, getStreamSink, withFilter } from "./sink.ts"; 9 | 10 | Deno.test("withFilter()", () => { 11 | const buffer: LogRecord[] = []; 12 | const sink = withFilter(buffer.push.bind(buffer), "warning"); 13 | sink(debug); 14 | sink(info); 15 | sink(warning); 16 | sink(error); 17 | sink(fatal); 18 | assertEquals(buffer, [warning, error, fatal]); 19 | }); 20 | 21 | interface ConsoleMock extends Console { 22 | history(): unknown[]; 23 | } 24 | 25 | Deno.test("getStreamSink()", async () => { 26 | let buffer: string = ""; 27 | const decoder = new TextDecoder(); 28 | const sink = getStreamSink( 29 | new WritableStream({ 30 | write(chunk: Uint8Array) { 31 | buffer += decoder.decode(chunk); 32 | return Promise.resolve(); 33 | }, 34 | }), 35 | ); 36 | sink(debug); 37 | sink(info); 38 | sink(warning); 39 | sink(error); 40 | sink(fatal); 41 | await sink[Symbol.asyncDispose](); 42 | assertEquals( 43 | buffer, 44 | `\ 45 | 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456! 46 | 2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456! 47 | 2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456! 48 | 2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456! 49 | 2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456! 50 | `, 51 | ); 52 | }); 53 | 54 | Deno.test("getConsoleSink()", () => { 55 | // @ts-ignore: consolemock is not typed 56 | const mock: ConsoleMock = makeConsoleMock(); 57 | const sink = getConsoleSink({ console: mock }); 58 | sink(debug); 59 | sink(info); 60 | sink(warning); 61 | sink(error); 62 | sink(fatal); 63 | assertEquals(mock.history(), [ 64 | { 65 | DEBUG: [ 66 | "%c22:13:20.000 %cDBG%c %cmy-app·junk %cHello, %o & %o!", 67 | "color: gray;", 68 | "background-color: gray; color: white;", 69 | "background-color: default;", 70 | "color: gray;", 71 | "color: default;", 72 | 123, 73 | 456, 74 | ], 75 | }, 76 | { 77 | INFO: [ 78 | "%c22:13:20.000 %cINF%c %cmy-app·junk %cHello, %o & %o!", 79 | "color: gray;", 80 | "background-color: white; color: black;", 81 | "background-color: default;", 82 | "color: gray;", 83 | "color: default;", 84 | 123, 85 | 456, 86 | ], 87 | }, 88 | { 89 | WARN: [ 90 | "%c22:13:20.000 %cWRN%c %cmy-app·junk %cHello, %o & %o!", 91 | "color: gray;", 92 | "background-color: orange; color: black;", 93 | "background-color: default;", 94 | "color: gray;", 95 | "color: default;", 96 | 123, 97 | 456, 98 | ], 99 | }, 100 | { 101 | ERROR: [ 102 | "%c22:13:20.000 %cERR%c %cmy-app·junk %cHello, %o & %o!", 103 | "color: gray;", 104 | "background-color: red; color: white;", 105 | "background-color: default;", 106 | "color: gray;", 107 | "color: default;", 108 | 123, 109 | 456, 110 | ], 111 | }, 112 | { 113 | ERROR: [ 114 | "%c22:13:20.000 %cFTL%c %cmy-app·junk %cHello, %o & %o!", 115 | "color: gray;", 116 | "background-color: maroon; color: white;", 117 | "background-color: default;", 118 | "color: gray;", 119 | "color: default;", 120 | 123, 121 | 456, 122 | ], 123 | }, 124 | ]); 125 | 126 | assertThrows( 127 | () => sink({ ...info, level: "invalid" as LogLevel }), 128 | TypeError, 129 | "Invalid log level: invalid.", 130 | ); 131 | 132 | // @ts-ignore: consolemock is not typed 133 | const mock2: ConsoleMock = makeConsoleMock(); 134 | const sink2 = getConsoleSink({ 135 | console: mock2, 136 | formatter: defaultTextFormatter, 137 | }); 138 | sink2(debug); 139 | sink2(info); 140 | sink2(warning); 141 | sink2(error); 142 | sink2(fatal); 143 | assertEquals(mock2.history(), [ 144 | { 145 | DEBUG: [ 146 | "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!", 147 | ], 148 | }, 149 | { 150 | INFO: [ 151 | "2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!", 152 | ], 153 | }, 154 | { 155 | WARN: [ 156 | "2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!", 157 | ], 158 | }, 159 | { 160 | ERROR: [ 161 | "2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!", 162 | ], 163 | }, 164 | { 165 | ERROR: [ 166 | "2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!", 167 | ], 168 | }, 169 | ]); 170 | 171 | // @ts-ignore: consolemock is not typed 172 | const mock3: ConsoleMock = makeConsoleMock(); 173 | const sink3 = getConsoleSink({ 174 | console: mock3, 175 | levelMap: { 176 | debug: "log", 177 | info: "log", 178 | warning: "log", 179 | error: "log", 180 | fatal: "log", 181 | }, 182 | formatter: defaultTextFormatter, 183 | }); 184 | sink3(debug); 185 | sink3(info); 186 | sink3(warning); 187 | sink3(error); 188 | sink3(fatal); 189 | assertEquals(mock3.history(), [ 190 | { 191 | LOG: [ 192 | "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!", 193 | ], 194 | }, 195 | { 196 | LOG: [ 197 | "2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!", 198 | ], 199 | }, 200 | { 201 | LOG: [ 202 | "2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!", 203 | ], 204 | }, 205 | { 206 | LOG: [ 207 | "2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!", 208 | ], 209 | }, 210 | { 211 | LOG: [ 212 | "2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!", 213 | ], 214 | }, 215 | ]); 216 | }); 217 | -------------------------------------------------------------------------------- /logtape/sink.ts: -------------------------------------------------------------------------------- 1 | import { type FilterLike, toFilter } from "./filter.ts"; 2 | import { 3 | type ConsoleFormatter, 4 | defaultConsoleFormatter, 5 | defaultTextFormatter, 6 | type TextFormatter, 7 | } from "./formatter.ts"; 8 | import type { LogLevel } from "./level.ts"; 9 | import type { LogRecord } from "./record.ts"; 10 | 11 | /** 12 | * A sink is a function that accepts a log record and prints it somewhere. 13 | * Thrown exceptions will be suppressed and then logged to the meta logger, 14 | * a {@link Logger} with the category `["logtape", "meta"]`. (In that case, 15 | * the meta log record will not be passed to the sink to avoid infinite 16 | * recursion.) 17 | * 18 | * @param record The log record to sink. 19 | */ 20 | export type Sink = (record: LogRecord) => void; 21 | 22 | /** 23 | * Turns a sink into a filtered sink. The returned sink only logs records that 24 | * pass the filter. 25 | * 26 | * @example Filter a console sink to only log records with the info level 27 | * ```typescript 28 | * const sink = withFilter(getConsoleSink(), "info"); 29 | * ``` 30 | * 31 | * @param sink A sink to be filtered. 32 | * @param filter A filter to apply to the sink. It can be either a filter 33 | * function or a {@link LogLevel} string. 34 | * @returns A sink that only logs records that pass the filter. 35 | */ 36 | export function withFilter(sink: Sink, filter: FilterLike): Sink { 37 | const filterFunc = toFilter(filter); 38 | return (record: LogRecord) => { 39 | if (filterFunc(record)) sink(record); 40 | }; 41 | } 42 | 43 | /** 44 | * Options for the {@link getStreamSink} function. 45 | */ 46 | export interface StreamSinkOptions { 47 | /** 48 | * The text formatter to use. Defaults to {@link defaultTextFormatter}. 49 | */ 50 | formatter?: TextFormatter; 51 | 52 | /** 53 | * The text encoder to use. Defaults to an instance of {@link TextEncoder}. 54 | */ 55 | encoder?: { encode(text: string): Uint8Array }; 56 | } 57 | 58 | /** 59 | * A factory that returns a sink that writes to a {@link WritableStream}. 60 | * 61 | * Note that the `stream` is of Web Streams API, which is different from 62 | * Node.js streams. You can convert a Node.js stream to a Web Streams API 63 | * stream using [`stream.Writable.toWeb()`] method. 64 | * 65 | * [`stream.Writable.toWeb()`]: https://nodejs.org/api/stream.html#streamwritabletowebstreamwritable 66 | * 67 | * @example Sink to the standard error in Deno 68 | * ```typescript 69 | * const stderrSink = getStreamSink(Deno.stderr.writable); 70 | * ``` 71 | * 72 | * @example Sink to the standard error in Node.js 73 | * ```typescript 74 | * import stream from "node:stream"; 75 | * const stderrSink = getStreamSink(stream.Writable.toWeb(process.stderr)); 76 | * ``` 77 | * 78 | * @param stream The stream to write to. 79 | * @param options The options for the sink. 80 | * @returns A sink that writes to the stream. 81 | */ 82 | export function getStreamSink( 83 | stream: WritableStream, 84 | options: StreamSinkOptions = {}, 85 | ): Sink & AsyncDisposable { 86 | const formatter = options.formatter ?? defaultTextFormatter; 87 | const encoder = options.encoder ?? new TextEncoder(); 88 | const writer = stream.getWriter(); 89 | let lastPromise = Promise.resolve(); 90 | const sink: Sink & AsyncDisposable = (record: LogRecord) => { 91 | const bytes = encoder.encode(formatter(record)); 92 | lastPromise = lastPromise 93 | .then(() => writer.ready) 94 | .then(() => writer.write(bytes)); 95 | }; 96 | sink[Symbol.asyncDispose] = async () => { 97 | await lastPromise; 98 | await writer.close(); 99 | }; 100 | return sink; 101 | } 102 | 103 | type ConsoleMethod = "debug" | "info" | "log" | "warn" | "error"; 104 | 105 | /** 106 | * Options for the {@link getConsoleSink} function. 107 | */ 108 | export interface ConsoleSinkOptions { 109 | /** 110 | * The console formatter or text formatter to use. 111 | * Defaults to {@link defaultConsoleFormatter}. 112 | */ 113 | formatter?: ConsoleFormatter | TextFormatter; 114 | 115 | /** 116 | * The mapping from log levels to console methods. Defaults to: 117 | * 118 | * ```typescript 119 | * { 120 | * debug: "debug", 121 | * info: "info", 122 | * warning: "warn", 123 | * error: "error", 124 | * fatal: "error", 125 | * } 126 | * ``` 127 | * @since 0.9.0 128 | */ 129 | levelMap?: Record; 130 | 131 | /** 132 | * The console to log to. Defaults to {@link console}. 133 | */ 134 | console?: Console; 135 | } 136 | 137 | /** 138 | * A console sink factory that returns a sink that logs to the console. 139 | * 140 | * @param options The options for the sink. 141 | * @returns A sink that logs to the console. 142 | */ 143 | export function getConsoleSink(options: ConsoleSinkOptions = {}): Sink { 144 | const formatter = options.formatter ?? defaultConsoleFormatter; 145 | const levelMap: Record = { 146 | debug: "debug", 147 | info: "info", 148 | warning: "warn", 149 | error: "error", 150 | fatal: "error", 151 | ...(options.levelMap ?? {}), 152 | }; 153 | const console = options.console ?? globalThis.console; 154 | return (record: LogRecord) => { 155 | const args = formatter(record); 156 | const method = levelMap[record.level]; 157 | if (method === undefined) { 158 | throw new TypeError(`Invalid log level: ${record.level}.`); 159 | } 160 | if (typeof args === "string") { 161 | const msg = args.replace(/\r?\n$/, ""); 162 | console[method](msg); 163 | } else { 164 | console[method](...args); 165 | } 166 | }; 167 | } 168 | -------------------------------------------------------------------------------- /redaction/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Data redaction for LogTape 4 | ========================== 5 | 6 | [![JSR][JSR badge]][JSR] 7 | [![npm][npm badge]][npm] 8 | 9 | This package provides data redaction for [LogTape]. You can redact sensitive 10 | data in log records. For details, read the docs: 11 | 12 | - [Pattern-based redaction] 13 | - [Field-based redaction] 14 | 15 | [JSR]: https://jsr.io/@logtape/redaction 16 | [JSR badge]: https://jsr.io/badges/@logtape/redaction 17 | [npm]: https://www.npmjs.com/package/@logtape/redaction 18 | [npm badge]: https://img.shields.io/npm/v/@logtape/redaction?logo=npm 19 | [LogTape]: https://logtape.org/ 20 | [Pattern-based redaction]: https://logtape.org/manual/redaction#pattern-based-redaction 21 | [Field-based redaction]: https://logtape.org/manual/redaction#field-based-redaction 22 | 23 | 24 | Installation 25 | ------------ 26 | 27 | This package is available on [JSR] and [npm]. You can install it for various 28 | JavaScript runtimes and package managers: 29 | 30 | ~~~~ sh 31 | deno add jsr:@logtape/redaction # for Deno 32 | npm add @logtape/redaction # for npm 33 | pnpm add @logtape/redaction # for pnpm 34 | yarn add @logtape/redaction # for Yarn 35 | bun add @logtape/redaction # for Bun 36 | ~~~~ 37 | 38 | 39 | Docs 40 | ---- 41 | 42 | The docs of this package is available at . 43 | For the API references, see . 44 | -------------------------------------------------------------------------------- /redaction/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@logtape/redaction", 3 | "version": "0.12.0", 4 | "license": "MIT", 5 | "exports": "./mod.ts", 6 | "exclude": [ 7 | "coverage/", 8 | "npm/", 9 | ".dnt-import-map.json" 10 | ], 11 | "tasks": { 12 | "dnt": "deno run -A dnt.ts", 13 | "test:bun": { 14 | "command": "cd npm/ && bun run ./test_runner.js && cd ../", 15 | "dependencies": [ 16 | "dnt" 17 | ] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /redaction/dnt.ts: -------------------------------------------------------------------------------- 1 | import { build, emptyDir } from "@deno/dnt"; 2 | import { maxWith } from "@std/collections/max-with"; 3 | import { compare } from "@std/semver/compare"; 4 | import { format } from "@std/semver/format"; 5 | import { parse } from "@std/semver/parse"; 6 | import type { SemVer } from "@std/semver/types"; 7 | import metadata from "./deno.json" with { type: "json" }; 8 | 9 | await emptyDir("./npm"); 10 | 11 | const version = parse(Deno.args[0] ?? metadata.version); 12 | let minorVersion: SemVer = { 13 | ...version, 14 | patch: 0, 15 | prerelease: [], 16 | build: [], 17 | }; 18 | 19 | const logtapeResp = await fetch("https://registry.npmjs.com/@logtape/logtape"); 20 | const logtapeData = await logtapeResp.json(); 21 | const logtapeVersions = Object.keys(logtapeData.versions); 22 | if (!logtapeVersions.includes(format(minorVersion))) { 23 | minorVersion = maxWith(logtapeVersions.map(parse), compare) ?? Deno.exit(1); 24 | } 25 | 26 | await build({ 27 | package: { 28 | name: metadata.name, 29 | version: format(version), 30 | description: "Redact sensitive data from log messages", 31 | keywords: [ 32 | "logging", 33 | "log", 34 | "logger", 35 | "redaction", 36 | "mask", 37 | "masking", 38 | "sensitive", 39 | ], 40 | license: "MIT", 41 | author: { 42 | name: "Hong Minhee", 43 | email: "hong@minhee.org", 44 | url: "https://hongminhee.org/", 45 | }, 46 | homepage: "https://logtape.org/", 47 | repository: { 48 | type: "git", 49 | url: "git+https://github.com/dahlia/logtape.git", 50 | directory: "file/", 51 | }, 52 | bugs: { 53 | url: "https://github.com/dahlia/logtape/issues", 54 | }, 55 | funding: [ 56 | "https://github.com/sponsors/dahlia", 57 | ], 58 | devDependencies: { 59 | "@logtape/logtape": `^${format(minorVersion)}`, 60 | }, 61 | }, 62 | outDir: "./npm", 63 | entryPoints: ["./mod.ts"], 64 | importMap: "../deno.json", 65 | shims: { 66 | deno: "dev", 67 | }, 68 | typeCheck: "both", 69 | declaration: "separate", 70 | declarationMap: true, 71 | compilerOptions: { 72 | lib: ["ES2021", "DOM"], 73 | }, 74 | async postBuild() { 75 | await Deno.copyFile("../LICENSE", "npm/LICENSE"); 76 | await Deno.copyFile("README.md", "npm/README.md"); 77 | }, 78 | }); 79 | 80 | // cSpell: ignore Minhee filesink 81 | -------------------------------------------------------------------------------- /redaction/field.test.ts: -------------------------------------------------------------------------------- 1 | import type { LogRecord, Sink } from "@logtape/logtape"; 2 | import { assertEquals } from "@std/assert/assert-equals"; 3 | import { assert } from "@std/assert/assert"; 4 | import { assertExists } from "@std/assert/assert-exists"; 5 | import { assertFalse } from "@std/assert/assert-false"; 6 | import { 7 | type FieldPatterns, 8 | redactByField, 9 | redactProperties, 10 | shouldFieldRedacted, 11 | } from "./field.ts"; 12 | 13 | Deno.test("shouldFieldRedacted()", async (t) => { 14 | await t.step("matches string pattern", () => { 15 | const fieldPatterns: FieldPatterns = ["password", "secret"]; 16 | assertEquals(shouldFieldRedacted("password", fieldPatterns), true); 17 | assertEquals(shouldFieldRedacted("secret", fieldPatterns), true); 18 | assertEquals(shouldFieldRedacted("username", fieldPatterns), false); 19 | }); 20 | 21 | await t.step("matches regex pattern", () => { 22 | const fieldPatterns: FieldPatterns = [/pass/i, /secret/i]; 23 | assertEquals(shouldFieldRedacted("password", fieldPatterns), true); 24 | assertEquals(shouldFieldRedacted("secretKey", fieldPatterns), true); 25 | assertEquals(shouldFieldRedacted("myPassword", fieldPatterns), true); 26 | assertEquals(shouldFieldRedacted("username", fieldPatterns), false); 27 | }); 28 | 29 | await t.step("case sensitivity in regex", () => { 30 | const caseSensitivePatterns: FieldPatterns = [/pass/, /secret/]; 31 | const caseInsensitivePatterns: FieldPatterns = [/pass/i, /secret/i]; 32 | 33 | assertEquals(shouldFieldRedacted("Password", caseSensitivePatterns), false); 34 | assertEquals( 35 | shouldFieldRedacted("Password", caseInsensitivePatterns), 36 | true, 37 | ); 38 | }); 39 | }); 40 | Deno.test("redactProperties()", async (t) => { 41 | await t.step("delete action (default)", () => { 42 | const properties = { 43 | username: "user123", 44 | password: "secret123", 45 | email: "user@example.com", 46 | message: "Hello world", 47 | }; 48 | 49 | const result = redactProperties(properties, { 50 | fieldPatterns: ["password", "email"], 51 | }); 52 | 53 | assert("username" in result); 54 | assertFalse("password" in result); 55 | assertFalse("email" in result); 56 | assert("message" in result); 57 | 58 | const nestedObject = { 59 | ...properties, 60 | nested: { 61 | foo: "bar", 62 | baz: "qux", 63 | passphrase: "asdf", 64 | }, 65 | }; 66 | const result2 = redactProperties(nestedObject, { 67 | fieldPatterns: ["password", "email", "passphrase"], 68 | }); 69 | 70 | assert("username" in result2); 71 | assertFalse("password" in result2); 72 | assertFalse("email" in result2); 73 | assert("message" in result2); 74 | assert("nested" in result2); 75 | assert(typeof result2.nested === "object"); 76 | assertExists(result2.nested); 77 | assert("foo" in result2.nested); 78 | assert("baz" in result2.nested); 79 | assertFalse("passphrase" in result2.nested); 80 | }); 81 | 82 | await t.step("custom action function", () => { 83 | const properties = { 84 | username: "user123", 85 | password: "secret123", 86 | token: "abc123", 87 | message: "Hello world", 88 | }; 89 | 90 | const result = redactProperties(properties, { 91 | fieldPatterns: [/password/i, /token/i], 92 | action: () => "REDACTED", 93 | }); 94 | 95 | assertEquals(result.username, "user123"); 96 | assertEquals(result.password, "REDACTED"); 97 | assertEquals(result.token, "REDACTED"); 98 | assertEquals(result.message, "Hello world"); 99 | }); 100 | 101 | await t.step("preserves other properties", () => { 102 | const properties = { 103 | username: "user123", 104 | data: { nested: "value" }, 105 | sensitive: "hidden", 106 | }; 107 | 108 | const result = redactProperties(properties, { 109 | fieldPatterns: ["sensitive"], 110 | }); 111 | 112 | assertEquals(result.username, "user123"); 113 | assertEquals(result.data, { nested: "value" }); 114 | assertFalse("sensitive" in result); 115 | }); 116 | }); 117 | 118 | Deno.test("redactByField()", async (t) => { 119 | await t.step("wraps sink and redacts properties", () => { 120 | const records: LogRecord[] = []; 121 | const originalSink: Sink = (record) => records.push(record); 122 | 123 | const wrappedSink = redactByField(originalSink, { 124 | fieldPatterns: ["password", "token"], 125 | }); 126 | 127 | const record: LogRecord = { 128 | level: "info", 129 | category: ["test"], 130 | message: ["Test message"], 131 | rawMessage: "Test message", 132 | timestamp: Date.now(), 133 | properties: { 134 | username: "user123", 135 | password: "secret123", 136 | token: "abc123", 137 | }, 138 | }; 139 | 140 | wrappedSink(record); 141 | 142 | assertEquals(records.length, 1); 143 | assert("username" in records[0].properties); 144 | assertFalse("password" in records[0].properties); 145 | assertFalse("token" in records[0].properties); 146 | }); 147 | 148 | await t.step("uses default field patterns when not specified", () => { 149 | const records: LogRecord[] = []; 150 | const originalSink: Sink = (record) => records.push(record); 151 | 152 | const wrappedSink = redactByField(originalSink); 153 | 154 | const record: LogRecord = { 155 | level: "info", 156 | category: ["test"], 157 | message: ["Test message"], 158 | rawMessage: "Test message", 159 | timestamp: Date.now(), 160 | properties: { 161 | username: "user123", 162 | password: "secret123", 163 | email: "user@example.com", 164 | apiKey: "xyz789", 165 | }, 166 | }; 167 | 168 | wrappedSink(record); 169 | 170 | assertEquals(records.length, 1); 171 | assert("username" in records[0].properties); 172 | assertFalse("password" in records[0].properties); 173 | assertFalse("email" in records[0].properties); 174 | assertFalse("apiKey" in records[0].properties); 175 | }); 176 | 177 | await t.step("preserves Disposable behavior", () => { 178 | let disposed = false; 179 | const originalSink: Sink & Disposable = Object.assign( 180 | (_record: LogRecord) => {}, 181 | { 182 | [Symbol.dispose]: () => { 183 | disposed = true; 184 | }, 185 | }, 186 | ); 187 | 188 | const wrappedSink = redactByField(originalSink) as Sink & Disposable; 189 | 190 | assert(Symbol.dispose in wrappedSink); 191 | wrappedSink[Symbol.dispose](); 192 | assert(disposed); 193 | }); 194 | 195 | await t.step("preserves AsyncDisposable behavior", async () => { 196 | let disposed = false; 197 | const originalSink: Sink & AsyncDisposable = Object.assign( 198 | (_record: LogRecord) => {}, 199 | { 200 | [Symbol.asyncDispose]: () => { 201 | disposed = true; 202 | return Promise.resolve(); 203 | }, 204 | }, 205 | ); 206 | 207 | const wrappedSink = redactByField(originalSink) as Sink & AsyncDisposable; 208 | 209 | assert(Symbol.asyncDispose in wrappedSink); 210 | await wrappedSink[Symbol.asyncDispose](); 211 | assert(disposed); 212 | }); 213 | }); 214 | -------------------------------------------------------------------------------- /redaction/field.ts: -------------------------------------------------------------------------------- 1 | import type { LogRecord, Sink } from "@logtape/logtape"; 2 | 3 | /** 4 | * The type for a field pattern used in redaction. A string or a regular 5 | * expression that matches field names. 6 | * @since 0.10.0 7 | */ 8 | export type FieldPattern = string | RegExp; 9 | 10 | /** 11 | * An array of field patterns used for redaction. Each pattern can be 12 | * a string or a regular expression that matches field names. 13 | * @since 0.10.0 14 | */ 15 | export type FieldPatterns = FieldPattern[]; 16 | 17 | /** 18 | * Default field patterns for redaction. These patterns will match 19 | * common sensitive fields such as passwords, tokens, and personal 20 | * information. 21 | * @since 0.10.0 22 | */ 23 | export const DEFAULT_REDACT_FIELDS: FieldPatterns = [ 24 | /pass(?:code|phrase|word)/i, 25 | /secret/i, 26 | /token/i, 27 | /key/i, 28 | /credential/i, 29 | /auth/i, 30 | /signature/i, 31 | /sensitive/i, 32 | /private/i, 33 | /ssn/i, 34 | /email/i, 35 | /phone/i, 36 | /address/i, 37 | ]; 38 | 39 | /** 40 | * Options for redacting fields in a {@link LogRecord}. Used by 41 | * the {@link redactByField} function. 42 | * @since 0.10.0 43 | */ 44 | export interface FieldRedactionOptions { 45 | /** 46 | * The field patterns to match against. This can be an array of 47 | * strings or regular expressions. If a field matches any of the 48 | * patterns, it will be redacted. 49 | * @defaultValue {@link DEFAULT_REDACT_FIELDS} 50 | */ 51 | readonly fieldPatterns: FieldPatterns; 52 | 53 | /** 54 | * The action to perform on the matched fields. If not provided, 55 | * the default action is to delete the field from the properties. 56 | * If a function is provided, it will be called with the 57 | * value of the field, and the return value will be used to replace 58 | * the field in the properties. 59 | * If the action is `"delete"`, the field will be removed from the 60 | * properties. 61 | * @default `"delete"` 62 | */ 63 | readonly action?: "delete" | ((value: unknown) => unknown); 64 | } 65 | 66 | /** 67 | * Redacts properties in a {@link LogRecord} based on the provided field 68 | * patterns and action. 69 | * 70 | * Note that it is a decorator which wraps the sink and redacts properties 71 | * before passing them to the sink. 72 | * 73 | * @example 74 | * ```ts 75 | * import { getConsoleSink } from "@logtape/logtape"; 76 | * import { redactByField } from "@logtape/redaction"; 77 | * 78 | * const sink = redactByField(getConsoleSink()); 79 | * ``` 80 | * 81 | * @param sink The sink to wrap. 82 | * @param options The redaction options. 83 | * @returns The wrapped sink. 84 | * @since 0.10.0 85 | */ 86 | export function redactByField( 87 | sink: Sink | Sink & Disposable | Sink & AsyncDisposable, 88 | options: FieldRedactionOptions | FieldPatterns = DEFAULT_REDACT_FIELDS, 89 | ): Sink | Sink & Disposable | Sink & AsyncDisposable { 90 | const opts = Array.isArray(options) ? { fieldPatterns: options } : options; 91 | const wrapped = (record: LogRecord) => { 92 | sink({ ...record, properties: redactProperties(record.properties, opts) }); 93 | }; 94 | if (Symbol.dispose in sink) wrapped[Symbol.dispose] = sink[Symbol.dispose]; 95 | if (Symbol.asyncDispose in sink) { 96 | wrapped[Symbol.asyncDispose] = sink[Symbol.asyncDispose]; 97 | } 98 | return wrapped; 99 | } 100 | 101 | /** 102 | * Redacts properties from an object based on specified field patterns. 103 | * 104 | * This function creates a shallow copy of the input object and applies 105 | * redaction rules to its properties. For properties that match the redaction 106 | * patterns, the function either removes them or transforms their values based 107 | * on the provided action. 108 | * 109 | * The redaction process is recursive and will be applied to nested objects 110 | * as well, allowing for deep redaction of sensitive data in complex object 111 | * structures. 112 | * @param properties The properties to redact. 113 | * @param options The redaction options. 114 | * @returns The redacted properties. 115 | * @since 0.10.0 116 | */ 117 | export function redactProperties( 118 | properties: Record, 119 | options: FieldRedactionOptions, 120 | ): Record { 121 | const copy = { ...properties }; 122 | for (const field in copy) { 123 | if (shouldFieldRedacted(field, options.fieldPatterns)) { 124 | if (options.action == null || options.action === "delete") { 125 | delete copy[field]; 126 | } else { 127 | copy[field] = options.action(copy[field]); 128 | } 129 | continue; 130 | } 131 | const value = copy[field]; 132 | // Check if value is a vanilla object: 133 | if ( 134 | typeof value === "object" && value !== null && 135 | (Object.getPrototypeOf(value) === Object.prototype || 136 | Object.getPrototypeOf(value) === null) 137 | ) { 138 | // @ts-ignore: value is always Record 139 | copy[field] = redactProperties(value, options); 140 | } 141 | } 142 | return copy; 143 | } 144 | 145 | /** 146 | * Checks if a field should be redacted based on the provided field patterns. 147 | * @param field The field name to check. 148 | * @param fieldPatterns The field patterns to match against. 149 | * @returns `true` if the field should be redacted, `false` otherwise. 150 | * @since 0.10.0 151 | */ 152 | export function shouldFieldRedacted( 153 | field: string, 154 | fieldPatterns: FieldPatterns, 155 | ): boolean { 156 | for (const fieldPattern of fieldPatterns) { 157 | if (typeof fieldPattern === "string") { 158 | if (fieldPattern === field) return true; 159 | } else { 160 | if (fieldPattern.test(field)) return true; 161 | } 162 | } 163 | return false; 164 | } 165 | -------------------------------------------------------------------------------- /redaction/mod.ts: -------------------------------------------------------------------------------- 1 | export { 2 | DEFAULT_REDACT_FIELDS, 3 | type FieldPattern, 4 | type FieldPatterns, 5 | type FieldRedactionOptions, 6 | redactByField, 7 | } from "./field.ts"; 8 | export * from "./pattern.ts"; 9 | -------------------------------------------------------------------------------- /redaction/pattern.test.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ConsoleFormatter, 3 | LogRecord, 4 | TextFormatter, 5 | } from "@logtape/logtape"; 6 | import { assert } from "@std/assert/assert"; 7 | import { assertEquals } from "@std/assert/assert-equals"; 8 | import { assertMatch } from "@std/assert/assert-match"; 9 | import { assertThrows } from "@std/assert/assert-throws"; 10 | import { 11 | CREDIT_CARD_NUMBER_PATTERN, 12 | EMAIL_ADDRESS_PATTERN, 13 | JWT_PATTERN, 14 | KR_RRN_PATTERN, 15 | redactByPattern, 16 | type RedactionPattern, 17 | US_SSN_PATTERN, 18 | } from "./pattern.ts"; 19 | 20 | Deno.test("EMAIL_ADDRESS_PATTERN", () => { 21 | const { pattern, replacement } = EMAIL_ADDRESS_PATTERN; 22 | 23 | // Test valid email addresses 24 | const validEmails = [ 25 | "user@example.com", 26 | "first.last@example.co.uk", 27 | "user+tag@example.org", 28 | "user123@sub.domain.com", 29 | "user-name@example.com", 30 | "user_name@example.com", 31 | "user.name@example-domain.co", 32 | // Ensure international domains work: 33 | // cSpell: disable 34 | "用户@例子.世界", 35 | "пользователь@пример.рф", 36 | // cSpell: enable 37 | ]; 38 | 39 | for (const email of validEmails) { 40 | assertMatch(email, pattern); 41 | pattern.lastIndex = 0; 42 | } 43 | 44 | // Test replacements 45 | assertEquals( 46 | "Contact at user@example.com for more info.".replaceAll( 47 | pattern, 48 | replacement as string, 49 | ), 50 | "Contact at REDACTED@EMAIL.ADDRESS for more info.", 51 | ); 52 | assertEquals( 53 | "My email is user@example.com".replaceAll(pattern, replacement as string), 54 | "My email is REDACTED@EMAIL.ADDRESS", 55 | ); 56 | assertEquals( 57 | "Emails: user1@example.com and user2@example.org".replaceAll( 58 | pattern, 59 | replacement as string, 60 | ), 61 | "Emails: REDACTED@EMAIL.ADDRESS and REDACTED@EMAIL.ADDRESS", 62 | ); 63 | 64 | // Ensure the global flag is set 65 | assert( 66 | pattern.global, 67 | "EMAIL_ADDRESS_PATTERN should have the global flag set", 68 | ); 69 | }); 70 | 71 | Deno.test("CREDIT_CARD_NUMBER_PATTERN", () => { 72 | const { pattern, replacement } = CREDIT_CARD_NUMBER_PATTERN; 73 | 74 | // Test valid credit card numbers with dashes 75 | assertMatch("1234-5678-9012-3456", pattern); // Regular 16-digit card 76 | pattern.lastIndex = 0; 77 | assertMatch("1234-5678-901234", pattern); // American Express format 78 | pattern.lastIndex = 0; 79 | 80 | // Test replacements 81 | assertEquals( 82 | "Card: 1234-5678-9012-3456".replaceAll(pattern, replacement as string), 83 | "Card: XXXX-XXXX-XXXX-XXXX", 84 | ); 85 | assertEquals( 86 | "AmEx: 1234-5678-901234".replaceAll(pattern, replacement as string), 87 | "AmEx: XXXX-XXXX-XXXX-XXXX", 88 | ); 89 | assertEquals( 90 | "Cards: 1234-5678-9012-3456 and 1234-5678-901234".replaceAll( 91 | pattern, 92 | replacement as string, 93 | ), 94 | "Cards: XXXX-XXXX-XXXX-XXXX and XXXX-XXXX-XXXX-XXXX", 95 | ); 96 | }); 97 | 98 | Deno.test("US_SSN_PATTERN", () => { 99 | const { pattern, replacement } = US_SSN_PATTERN; 100 | 101 | // Test valid US Social Security numbers 102 | assertMatch("123-45-6789", pattern); 103 | pattern.lastIndex = 0; 104 | 105 | // Test replacements 106 | assertEquals( 107 | "SSN: 123-45-6789".replaceAll(pattern, replacement as string), 108 | "SSN: XXX-XX-XXXX", 109 | ); 110 | assertEquals( 111 | "SSNs: 123-45-6789 and 987-65-4321".replaceAll( 112 | pattern, 113 | replacement as string, 114 | ), 115 | "SSNs: XXX-XX-XXXX and XXX-XX-XXXX", 116 | ); 117 | }); 118 | 119 | Deno.test("KR_RRN_PATTERN", () => { 120 | const { pattern, replacement } = KR_RRN_PATTERN; 121 | 122 | // Test valid South Korean resident registration numbers 123 | assertMatch("123456-7890123", pattern); 124 | pattern.lastIndex = 0; 125 | 126 | // Test replacements 127 | assertEquals( 128 | "RRN: 123456-7890123".replaceAll(pattern, replacement as string), 129 | "RRN: XXXXXX-XXXXXXX", 130 | ); 131 | assertEquals( 132 | "RRNs: 123456-7890123 and 654321-0987654".replaceAll( 133 | pattern, 134 | replacement as string, 135 | ), 136 | "RRNs: XXXXXX-XXXXXXX and XXXXXX-XXXXXXX", 137 | ); 138 | }); 139 | 140 | Deno.test("JWT_PATTERN", () => { 141 | const { pattern, replacement } = JWT_PATTERN; 142 | 143 | // Test valid JWT tokens 144 | const sampleJwt = 145 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; 146 | assertMatch(sampleJwt, pattern); 147 | pattern.lastIndex = 0; 148 | 149 | // Test replacements 150 | assertEquals( 151 | `Token: ${sampleJwt}`.replaceAll(pattern, replacement as string), 152 | "Token: [JWT REDACTED]", 153 | ); 154 | assertEquals( 155 | `First: ${sampleJwt}, Second: ${sampleJwt}`.replaceAll( 156 | pattern, 157 | replacement as string, 158 | ), 159 | "First: [JWT REDACTED], Second: [JWT REDACTED]", 160 | ); 161 | }); 162 | 163 | Deno.test("redactByPattern(TextFormatter)", async (t) => { 164 | await t.step("redacts sensitive information in text output", () => { 165 | // Create a simple TextFormatter that returns a string 166 | const formatter: TextFormatter = (record: LogRecord) => { 167 | return `[${record.level.toUpperCase()}] ${record.message.join(" ")}`; 168 | }; 169 | 170 | // Test data with multiple patterns to redact 171 | const record: LogRecord = { 172 | level: "info", 173 | category: ["test"], 174 | message: [ 175 | "Sensitive info: email = user@example.com, cc = 1234-5678-9012-3456, ssn = 123-45-6789", 176 | ], 177 | rawMessage: 178 | "Sensitive info: email = user@example.com, cc = 1234-5678-9012-3456, ssn = 123-45-6789", 179 | timestamp: Date.now(), 180 | properties: {}, 181 | }; 182 | 183 | // Apply redaction with multiple patterns 184 | const redactedFormatter = redactByPattern(formatter, [ 185 | EMAIL_ADDRESS_PATTERN, 186 | CREDIT_CARD_NUMBER_PATTERN, 187 | US_SSN_PATTERN, 188 | ]); 189 | 190 | const output = redactedFormatter(record); 191 | 192 | // Verify all sensitive data was redacted 193 | assertEquals( 194 | output, 195 | "[INFO] Sensitive info: email = REDACTED@EMAIL.ADDRESS, cc = XXXX-XXXX-XXXX-XXXX, ssn = XXX-XX-XXXX", 196 | ); 197 | }); 198 | 199 | await t.step("handles function-based replacements", () => { 200 | const formatter: TextFormatter = (record: LogRecord) => { 201 | return record.message.join(" "); 202 | }; 203 | 204 | // Custom pattern with function replacement 205 | const customPattern: RedactionPattern = { 206 | pattern: /\b(password|pw)=([^\s,]+)/g, 207 | replacement: (_match, key) => `${key}=[HIDDEN]`, 208 | }; 209 | 210 | const record: LogRecord = { 211 | level: "info", 212 | category: ["test"], 213 | message: ["Credentials: password=secret123, pw=another-secret"], 214 | rawMessage: "Credentials: password=secret123, pw=another-secret", 215 | timestamp: Date.now(), 216 | properties: {}, 217 | }; 218 | 219 | const redactedFormatter = redactByPattern(formatter, [customPattern]); 220 | const output = redactedFormatter(record); 221 | 222 | assertEquals( 223 | output, 224 | "Credentials: password=[HIDDEN], pw=[HIDDEN]", 225 | ); 226 | }); 227 | 228 | await t.step("throws error if global flag is not set", () => { 229 | const formatter: TextFormatter = (record: LogRecord) => 230 | record.message.join(" "); 231 | 232 | const invalidPattern: RedactionPattern = { 233 | pattern: /password/, // Missing global flag 234 | replacement: "****", 235 | }; 236 | 237 | assertThrows( 238 | () => redactByPattern(formatter, [invalidPattern]), 239 | TypeError, 240 | "does not have the global flag set", 241 | ); 242 | }); 243 | }); 244 | 245 | Deno.test("redactByPattern(ConsoleFormatter)", async (t) => { 246 | await t.step( 247 | "redacts sensitive information in console formatter arrays", 248 | () => { 249 | // Create a simple ConsoleFormatter that returns an array of values 250 | const formatter: ConsoleFormatter = (record: LogRecord) => { 251 | return [ 252 | `[${record.level.toUpperCase()}]`, 253 | ...record.message, 254 | ]; 255 | }; 256 | 257 | // Create test record with sensitive data 258 | const record: LogRecord = { 259 | level: "info", 260 | category: ["test"], 261 | message: [ 262 | "User data:", 263 | { 264 | name: "John Doe", 265 | email: "john@example.com", 266 | creditCard: "1234-5678-9012-3456", 267 | }, 268 | ], 269 | rawMessage: "User data: [object Object]", 270 | timestamp: Date.now(), 271 | properties: {}, 272 | }; 273 | 274 | // Apply redaction 275 | const redactedFormatter = redactByPattern(formatter, [ 276 | EMAIL_ADDRESS_PATTERN, 277 | CREDIT_CARD_NUMBER_PATTERN, 278 | ]); 279 | 280 | const output = redactedFormatter(record); 281 | 282 | // Verify output structure is preserved and data is redacted 283 | assertEquals(output[0], "[INFO]"); 284 | assertEquals(output[1], "User data:"); 285 | assertEquals( 286 | (output[2] as { name: string; email: string; creditCard: string }).name, 287 | "John Doe", 288 | ); 289 | assertEquals( 290 | (output[2] as { name: string; email: string; creditCard: string }) 291 | .email, 292 | "REDACTED@EMAIL.ADDRESS", 293 | ); 294 | assertEquals( 295 | (output[2] as { name: string; email: string; creditCard: string }) 296 | .creditCard, 297 | "XXXX-XXXX-XXXX-XXXX", 298 | ); 299 | }, 300 | ); 301 | 302 | await t.step("handles nested objects and arrays in console output", () => { 303 | const formatter: ConsoleFormatter = (record: LogRecord) => { 304 | return [record.level, record.message]; 305 | }; 306 | 307 | const nestedData = { 308 | user: { 309 | contact: { 310 | email: "user@example.com", 311 | phone: "123-456-7890", 312 | }, 313 | payment: { 314 | cards: [ 315 | "1234-5678-9012-3456", 316 | "8765-4321-8765-4321", 317 | ], 318 | }, 319 | documents: { 320 | ssn: "123-45-6789", 321 | }, 322 | }, 323 | }; 324 | 325 | const record: LogRecord = { 326 | level: "info", 327 | category: ["test"], 328 | message: ["Data:", nestedData], 329 | rawMessage: "Data: [object Object]", 330 | timestamp: Date.now(), 331 | properties: {}, 332 | }; 333 | 334 | const redactedFormatter = redactByPattern(formatter, [ 335 | EMAIL_ADDRESS_PATTERN, 336 | CREDIT_CARD_NUMBER_PATTERN, 337 | US_SSN_PATTERN, 338 | ]); 339 | 340 | const output = redactedFormatter(record); 341 | 342 | // Verify deep redaction in nested structures 343 | const resultData = 344 | (output[1] as unknown[])[1] as unknown as typeof nestedData; 345 | assertEquals(resultData.user.contact.email, "REDACTED@EMAIL.ADDRESS"); 346 | assertEquals(resultData.user.contact.phone, "123-456-7890"); // Not redacted 347 | assertEquals(resultData.user.payment.cards[0], "XXXX-XXXX-XXXX-XXXX"); 348 | assertEquals(resultData.user.payment.cards[1], "XXXX-XXXX-XXXX-XXXX"); 349 | assertEquals(resultData.user.documents.ssn, "XXX-XX-XXXX"); 350 | }); 351 | }); 352 | -------------------------------------------------------------------------------- /redaction/pattern.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ConsoleFormatter, 3 | LogRecord, 4 | TextFormatter, 5 | } from "@logtape/logtape"; 6 | 7 | /** 8 | * A redaction pattern, which is a pair of regular expression and replacement 9 | * string or function. 10 | * @since 0.10.0 11 | */ 12 | export interface RedactionPattern { 13 | /** 14 | * The regular expression to match against. Note that it must have the 15 | * `g` (global) flag set, otherwise it will throw a `TypeError`. 16 | */ 17 | readonly pattern: RegExp; 18 | 19 | /** 20 | * The replacement string or function. If the replacement is a function, 21 | * it will be called with the matched string and any capture groups (the same 22 | * signature as `String.prototype.replaceAll()`). 23 | */ 24 | readonly replacement: 25 | | string 26 | // deno-lint-ignore no-explicit-any 27 | | ((match: string, ...rest: readonly any[]) => string); 28 | } 29 | 30 | /** 31 | * A redaction pattern for email addresses. 32 | * @since 0.10.0 33 | */ 34 | export const EMAIL_ADDRESS_PATTERN: RedactionPattern = { 35 | pattern: 36 | /[\p{L}0-9.!#$%&'*+/=?^_`{|}~-]+@[\p{L}0-9](?:[\p{L}0-9-]{0,61}[\p{L}0-9])?(?:\.[\p{L}0-9](?:[\p{L}0-9-]{0,61}[\p{L}0-9])?)+/gu, 37 | replacement: "REDACTED@EMAIL.ADDRESS", 38 | }; 39 | 40 | /** 41 | * A redaction pattern for credit card numbers (including American Express). 42 | * @since 0.10.0 43 | */ 44 | export const CREDIT_CARD_NUMBER_PATTERN: RedactionPattern = { 45 | pattern: /(?:\d{4}-){3}\d{4}|(?:\d{4}-){2}\d{6}/g, 46 | replacement: "XXXX-XXXX-XXXX-XXXX", 47 | }; 48 | 49 | /** 50 | * A redaction pattern for U.S. Social Security numbers. 51 | * @since 0.10.0 52 | */ 53 | export const US_SSN_PATTERN: RedactionPattern = { 54 | pattern: /\d{3}-\d{2}-\d{4}/g, 55 | replacement: "XXX-XX-XXXX", 56 | }; 57 | 58 | /** 59 | * A redaction pattern for South Korean resident registration numbers 60 | * (住民登錄番號). 61 | * @since 0.10.0 62 | */ 63 | export const KR_RRN_PATTERN: RedactionPattern = { 64 | pattern: /\d{6}-\d{7}/g, 65 | replacement: "XXXXXX-XXXXXXX", 66 | }; 67 | 68 | /** 69 | * A redaction pattern for JSON Web Tokens (JWT). 70 | * @since 0.10.0 71 | */ 72 | export const JWT_PATTERN: RedactionPattern = { 73 | pattern: /eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/g, 74 | replacement: "[JWT REDACTED]", 75 | }; 76 | 77 | /** 78 | * A list of {@link RedactionPattern}s. 79 | * @since 0.10.0 80 | */ 81 | export type RedactionPatterns = readonly RedactionPattern[]; 82 | 83 | /** 84 | * Applies data redaction to a {@link TextFormatter}. 85 | * 86 | * Note that there are some built-in redaction patterns: 87 | * 88 | * - {@link CREDIT_CARD_NUMBER_PATTERN} 89 | * - {@link EMAIL_ADDRESS_PATTERN} 90 | * - {@link JWT_PATTERN} 91 | * - {@link KR_RRN_PATTERN} 92 | * - {@link US_SSN_PATTERN} 93 | * 94 | * @example 95 | * ```ts 96 | * import { getFileSink } from "@logtape/file"; 97 | * import { getAnsiColorFormatter } from "@logtape/logtape"; 98 | * import { 99 | * CREDIT_CARD_NUMBER_PATTERN, 100 | * EMAIL_ADDRESS_PATTERN, 101 | * JWT_PATTERN, 102 | * redactByPattern, 103 | * } from "@logtape/redaction"; 104 | * 105 | * const formatter = redactByPattern(getAnsiConsoleFormatter(), [ 106 | * CREDIT_CARD_NUMBER_PATTERN, 107 | * EMAIL_ADDRESS_PATTERN, 108 | * JWT_PATTERN, 109 | * ]); 110 | * const sink = getFileSink("my-app.log", { formatter }); 111 | * ``` 112 | * @param formatter The text formatter to apply redaction to. 113 | * @param patterns The redaction patterns to apply. 114 | * @returns The redacted text formatter. 115 | * @since 0.10.0 116 | */ 117 | export function redactByPattern( 118 | formatter: TextFormatter, 119 | patterns: RedactionPatterns, 120 | ): TextFormatter; 121 | 122 | /** 123 | * Applies data redaction to a {@link ConsoleFormatter}. 124 | * 125 | * Note that there are some built-in redaction patterns: 126 | * 127 | * - {@link CREDIT_CARD_NUMBER_PATTERN} 128 | * - {@link EMAIL_ADDRESS_PATTERN} 129 | * - {@link JWT_PATTERN} 130 | * - {@link KR_RRN_PATTERN} 131 | * - {@link US_SSN_PATTERN} 132 | * 133 | * @example 134 | * ```ts 135 | * import { defaultConsoleFormatter, getConsoleSink } from "@logtape/logtape"; 136 | * import { 137 | * CREDIT_CARD_NUMBER_PATTERN, 138 | * EMAIL_ADDRESS_PATTERN, 139 | * JWT_PATTERN, 140 | * redactByPattern, 141 | * } from "@logtape/redaction"; 142 | * 143 | * const formatter = redactByPattern(defaultConsoleFormatter, [ 144 | * CREDIT_CARD_NUMBER_PATTERN, 145 | * EMAIL_ADDRESS_PATTERN, 146 | * JWT_PATTERN, 147 | * ]); 148 | * const sink = getConsoleSink({ formatter }); 149 | * ``` 150 | * @param formatter The console formatter to apply redaction to. 151 | * @param patterns The redaction patterns to apply. 152 | * @returns The redacted console formatter. 153 | * @since 0.10.0 154 | */ 155 | export function redactByPattern( 156 | formatter: ConsoleFormatter, 157 | patterns: RedactionPatterns, 158 | ): ConsoleFormatter; 159 | 160 | export function redactByPattern( 161 | formatter: TextFormatter | ConsoleFormatter, 162 | patterns: RedactionPatterns, 163 | ): (record: LogRecord) => string | readonly unknown[] { 164 | for (const { pattern } of patterns) { 165 | if (!pattern.global) { 166 | throw new TypeError( 167 | `Pattern ${pattern} does not have the global flag set.`, 168 | ); 169 | } 170 | } 171 | 172 | function replaceString(str: string): string { 173 | for (const p of patterns) { 174 | // The following ternary operator may seem strange, but it's for 175 | // making TypeScript happy: 176 | str = typeof p.replacement === "string" 177 | ? str.replaceAll(p.pattern, p.replacement) 178 | : str.replaceAll(p.pattern, p.replacement); 179 | } 180 | return str; 181 | } 182 | 183 | function replaceObject(object: unknown): unknown { 184 | if (typeof object === "string") return replaceString(object); 185 | else if (Array.isArray(object)) return object.map(replaceObject); 186 | else if (typeof object === "object" && object !== null) { 187 | // Check if object is a vanilla object: 188 | if ( 189 | Object.getPrototypeOf(object) === Object.prototype || 190 | Object.getPrototypeOf(object) === null 191 | ) { 192 | const redacted: Record = {}; 193 | for (const key in object) { 194 | redacted[key] = 195 | // @ts-ignore: object always has key 196 | replaceObject(object[key]); 197 | } 198 | return redacted; 199 | } 200 | } 201 | return object; 202 | } 203 | 204 | return (record: LogRecord) => { 205 | const output = formatter(record); 206 | if (typeof output === "string") return replaceString(output); 207 | return output.map(replaceObject); 208 | }; 209 | } 210 | -------------------------------------------------------------------------------- /screenshots/terminal-console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahlia/logtape/25eb1392255dd8b59485b8e915305073b2ce34a2/screenshots/terminal-console.png -------------------------------------------------------------------------------- /screenshots/web-console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahlia/logtape/25eb1392255dd8b59485b8e915305073b2ce34a2/screenshots/web-console.png -------------------------------------------------------------------------------- /scripts/check_versions.ts: -------------------------------------------------------------------------------- 1 | import { dirname, join } from "@std/path"; 2 | import metadata from "../deno.json" with { type: "json" }; 3 | 4 | const root = dirname(import.meta.dirname!); 5 | const versions: Record = {}; 6 | 7 | for (const member of metadata.workspace) { 8 | const file = join(root, member, "deno.json"); 9 | const json = await Deno.readTextFile(file); 10 | const data = JSON.parse(json); 11 | versions[join(member, "deno.json")] = data.version; 12 | } 13 | let version: string | undefined; 14 | 15 | for (const file in versions) { 16 | if (version != null && versions[file] !== version) { 17 | console.error("Versions are inconsistent:"); 18 | for (const file in versions) { 19 | console.error(` ${file}: ${versions[file]}`); 20 | } 21 | Deno.exit(1); 22 | } 23 | version = versions[file]; 24 | } 25 | -------------------------------------------------------------------------------- /scripts/update_versions.ts: -------------------------------------------------------------------------------- 1 | import { dirname, join } from "@std/path"; 2 | import metadata from "../deno.json" with { type: "json" }; 3 | 4 | const root = dirname(import.meta.dirname!); 5 | 6 | if (Deno.args.length < 1) { 7 | console.error("error: no argument"); 8 | Deno.exit(1); 9 | } 10 | 11 | const version = Deno.args[0]; 12 | 13 | for (const member of metadata.workspace) { 14 | const file = join(root, member, "deno.json"); 15 | const json = await Deno.readTextFile(file); 16 | const data = JSON.parse(json); 17 | data.version = version; 18 | await Deno.writeTextFile(file, JSON.stringify(data, undefined, 2) + "\n"); 19 | } 20 | --------------------------------------------------------------------------------