├── .github ├── FUNDING.yaml └── workflows │ └── main.yaml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── deno.json ├── deno.lock ├── dnt.ts ├── mod.ts └── sample.ts /.github/FUNDING.yaml: -------------------------------------------------------------------------------- 1 | github: dahlia 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | publish: 6 | permissions: 7 | contents: read 8 | id-token: write 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: denoland/setup-deno@v2 13 | with: 14 | deno-version: v2.x 15 | - if: github.ref_type == 'branch' 16 | run: | 17 | jq \ 18 | --arg build "$GITHUB_RUN_NUMBER" \ 19 | --arg commit "${GITHUB_SHA::8}" \ 20 | '.version = .version + "-dev." + $build + "+" + $commit' \ 21 | deno.json > deno.json.tmp 22 | mv deno.json.tmp deno.json 23 | - if: github.ref_type == 'tag' 24 | run: | 25 | set -ex 26 | [[ "$(jq -r .version deno.json)" = "$GITHUB_REF_NAME" ]] 27 | - run: 'deno task dnt "$(jq -r .version deno.json)"' 28 | - if: github.event_name == 'push' 29 | run: | 30 | set -ex 31 | npm config set //registry.npmjs.org/:_authToken "$NPM_AUTH_TOKEN" 32 | if [[ "$GITHUB_REF_TYPE" = "tag" ]]; then 33 | npm publish --provenance --access public 34 | else 35 | npm publish --provenance --access public --tag dev 36 | fi 37 | env: 38 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 39 | working-directory: ${{ github.workspace }}/npm/ 40 | - if: github.event_name == 'pull_request' 41 | run: deno publish --dry-run --allow-dirty 42 | - if: github.event_name == 'push' 43 | run: deno publish --allow-dirty 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .dnt-import-map.json 2 | .env 3 | npm/ 4 | -------------------------------------------------------------------------------- /.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": true, 4 | "editor.detectIndentation": false, 5 | "editor.indentSize": 2, 6 | "editor.insertSpaces": true, 7 | "files.eol": "\n", 8 | "files.insertFinalNewline": true, 9 | "files.trimFinalNewlines": true, 10 | "[json]": { 11 | "editor.defaultFormatter": "vscode.json-language-features", 12 | "editor.formatOnSave": true 13 | }, 14 | "[jsonc]": { 15 | "editor.defaultFormatter": "vscode.json-language-features", 16 | "editor.formatOnSave": true 17 | }, 18 | "[typescript]": { 19 | "editor.defaultFormatter": "denoland.vscode-deno", 20 | "editor.formatOnSave": true, 21 | "editor.codeActionsOnSave": { 22 | "source.organizeImports": "explicit" 23 | } 24 | }, 25 | "cSpell.words": [ 26 | "deno", 27 | "logtape", 28 | "opentelemetry", 29 | "otel", 30 | "OTLP" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /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/otel: LogTape OpenTelemetry Sink 2 | ========================================= 3 | 4 | [![JSR][JSR badge]][JSR] 5 | [![npm][npm badge]][npm] 6 | [![GitHub Actions][GitHub Actions badge]][GitHub Actions] 7 | 8 | This package provides an [OpenTelemetry] sink for [LogTape]. It allows you to 9 | send your LogTape logs to OpenTelemetry-compatible backends. 10 | 11 | [JSR]: https://jsr.io/@logtape/otel 12 | [JSR badge]: https://jsr.io/badges/@logtape/otel 13 | [npm]: https://www.npmjs.com/package/@logtape/otel 14 | [npm badge]: https://img.shields.io/npm/v/@logtape/otel?logo=npm 15 | [GitHub Actions]: https://github.com/dahlia/logtape-otel/actions/workflows/main.yaml 16 | [GitHub Actions badge]: https://github.com/dahlia/logtape-otel/actions/workflows/main.yaml/badge.svg 17 | [OpenTelemetry]: https://opentelemetry.io/ 18 | [LogTape]: https://github.com/dahlia/logtape 19 | 20 | 21 | Installation 22 | ------------ 23 | 24 | The package is available on [JSR] and [npm]. 25 | 26 | ~~~~ bash 27 | deno add @logtape/otel # for Deno 28 | npm add @logtape/otel # for npm 29 | pnpm add @logtape/otel # for pnpm 30 | yarn add @logtape/otel # for Yarn 31 | bun add @logtape/otel # for Bun 32 | ~~~~ 33 | 34 | 35 | Usage 36 | ----- 37 | 38 | The quickest way to get started is to use the `getOpenTelemetrySink()` function 39 | without any arguments: 40 | 41 | ~~~~ typescript 42 | import { configure } from "@logtape/logtape"; 43 | import { getOpenTelemetrySink } from "@logtape/otel"; 44 | 45 | await configure({ 46 | sinks: { 47 | otel: getOpenTelemetrySink(), 48 | }, 49 | filters: {}, 50 | loggers: [ 51 | { category: [], sinks: ["otel"], level: "debug" }, 52 | ], 53 | }); 54 | ~~~~ 55 | 56 | This will use the default OpenTelemetry configuration, which is to send logs to 57 | the OpenTelemetry collector running on `localhost:4317` or respects the `OTEL_*` 58 | environment variables. 59 | 60 | If you want to customize the OpenTelemetry configuration, you can specify 61 | options to the [`getOpenTelemetrySink()`] function: 62 | 63 | ~~~~ typescript 64 | import { configure } from "@logtape/logtape"; 65 | import { getOpenTelemetrySink } from "@logtape/otel"; 66 | 67 | await configure({ 68 | sinks: { 69 | otel: getOpenTelemetrySink({ 70 | serviceName: "my-service", 71 | otlpExporterConfig: { 72 | url: "https://my-otel-collector:4317", 73 | headers: { "x-api-key": "my-api-key" }, 74 | }, 75 | }), 76 | }, 77 | filters: {}, 78 | loggers: [ 79 | { category: [], sinks: ["otel"], level: "debug" }, 80 | ], 81 | }); 82 | ~~~~ 83 | 84 | Or you can even pass an existing OpenTelemetry [`LoggerProvider`] instance: 85 | 86 | ~~~~ typescript 87 | import { configure } from "@logtape/logtape"; 88 | import { getOpenTelemetrySink } from "@logtape/otel"; 89 | import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'; 90 | import { 91 | LoggerProvider, 92 | SimpleLogRecordProcessor, 93 | } from '@opentelemetry/sdk-logs'; 94 | 95 | const exporter = new OTLPLogExporter({ 96 | url: "https://my-otel-collector:4317", 97 | headers: { "x-api-key": "my-api-key" }, 98 | }); 99 | const loggerProvider = new LoggerProvider(); 100 | loggerProvider.addProcessor(new SimpleLogRecordProcessor(exporter)); 101 | 102 | await configure({ 103 | sinks: { 104 | otel: getOpenTelemetrySink({ loggerProvider }), 105 | }, 106 | filters: {}, 107 | loggers: [ 108 | { category: [], sinks: ["otel"], level: "debug" }, 109 | ], 110 | }); 111 | ~~~~ 112 | 113 | For more information, see the documentation of the [`getOpenTelemetrySink()`] 114 | function and [`OpenTelemetrySinkOptions`] type. 115 | 116 | [`getOpenTelemetrySink()`]: https://jsr.io/@logtape/otel/doc/~/getOpenTelemetrySink 117 | [`OpenTelemetrySinkOptions`]: https://jsr.io/@logtape/otel/doc/~/OpenTelemetrySinkOptions 118 | [`LoggerProvider`]: https://open-telemetry.github.io/opentelemetry-js/classes/_opentelemetry_sdk_logs.LoggerProvider.html 119 | 120 | 121 | Diagnostic logging 122 | ------------------ 123 | 124 | If you want to log diagnostic messages from the OpenTelemetry sink itself, 125 | you can enable `diagnostics: true` in the sink options: 126 | 127 | ~~~~ typescript 128 | import { configure, getConsoleSink } from "@logtape/logtape"; 129 | import { getOpenTelemetrySink } from "@logtape/otel"; 130 | 131 | await configure({ 132 | sinks: { 133 | otel: getOpenTelemetrySink({ diagnostics: true }), 134 | console: getConsoleSink(), 135 | }, 136 | filters: {}, 137 | loggers: [ 138 | { category: ["logtape", "meta"], sinks: ["console"], level: "debug" }, 139 | { category: [], sinks: ["otel"], level: "debug" }, 140 | ], 141 | }); 142 | ~~~~ 143 | 144 | This will log messages with the `["logtape", "meta", "otel"]` category. 145 | 146 | These messages are useful for debugging the configuration of the OpenTelemetry 147 | sink, but they can be verbose, so it's recommended to enable them only when 148 | needed. 149 | 150 | 151 | Changelog 152 | --------- 153 | 154 | ### Version 0.4.0 155 | 156 | To be released. 157 | 158 | 159 | ### Version 0.3.0 160 | 161 | Released on February 26, 2025. 162 | 163 | - Now you can customize the body formatter. [[#1] by Hyeseong Kim] 164 | 165 | - Added `BodyFormatter` type. 166 | - Changed the type of `OpenTelemetrySinkOptions.messageType` to 167 | `"string" | "array" | BodyFormatter | undefined` (was 168 | `"string" | "array" | undefined`). 169 | 170 | [#1]: https://github.com/dahlia/logtape-otel/pull/1 171 | 172 | 173 | ### Version 0.2.0 174 | 175 | Released on August 26, 2024. 176 | 177 | - The `OpenTelemetrySinkOptions` type is now an interface. 178 | - Added `OpenTelemetrySinkOptions.messageType` option. 179 | - Added `OpenTelemetrySinkOptions.objectRenderer` option. Now non-scalar 180 | values are rendered using `util.inspect()` in Node.js/Bun and 181 | `Deno.inspect()` in Deno by default. 182 | 183 | 184 | ### Version 0.1.0 185 | 186 | Released on August 24, 2024. Initial release. 187 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@logtape/otel", 3 | "version": "0.4.0", 4 | "license": "MIT", 5 | "exports": { 6 | ".": "./mod.ts" 7 | }, 8 | "imports": { 9 | "@deno/dnt": "jsr:@deno/dnt@^0.41.3", 10 | "@logtape/logtape": "jsr:@logtape/logtape@^0.4.3", 11 | "@logtape/otel": "./mod.ts", 12 | "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", 13 | "@opentelemetry/api-logs": "npm:@opentelemetry/api-logs@^0.52.1", 14 | "@opentelemetry/exporter-logs-otlp-http": "npm:@opentelemetry/exporter-logs-otlp-http@^0.52.1", 15 | "@opentelemetry/otlp-exporter-base": "npm:@opentelemetry/otlp-exporter-base@^0.52.1", 16 | "@opentelemetry/resources": "npm:@opentelemetry/resources@^1.25.1", 17 | "@opentelemetry/sdk-logs": "npm:@opentelemetry/sdk-logs@^0.52.1", 18 | "@opentelemetry/semantic-conventions": "npm:@opentelemetry/semantic-conventions@^1.26.0", 19 | "@std/dotenv": "jsr:@std/dotenv@^0.225.1" 20 | }, 21 | "tasks": { 22 | "dnt": "deno run -A dnt.ts" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "4", 3 | "specifiers": { 4 | "jsr:@david/code-block-writer@^13.0.2": "13.0.2", 5 | "jsr:@deno/cache-dir@~0.10.3": "0.10.3", 6 | "jsr:@deno/dnt@~0.41.3": "0.41.3", 7 | "jsr:@deno/graph@~0.73.1": "0.73.1", 8 | "jsr:@logtape/logtape@~0.4.3": "0.4.3", 9 | "jsr:@std/assert@0.223": "0.223.0", 10 | "jsr:@std/assert@0.226": "0.226.0", 11 | "jsr:@std/bytes@0.223": "0.223.0", 12 | "jsr:@std/dotenv@~0.225.1": "0.225.1", 13 | "jsr:@std/fmt@0.223": "0.223.0", 14 | "jsr:@std/fmt@1": "1.0.0", 15 | "jsr:@std/fs@0.223": "0.223.0", 16 | "jsr:@std/fs@1": "1.0.1", 17 | "jsr:@std/fs@~0.229.3": "0.229.3", 18 | "jsr:@std/io@0.223": "0.223.0", 19 | "jsr:@std/path@0.223": "0.223.0", 20 | "jsr:@std/path@1": "1.0.2", 21 | "jsr:@std/path@1.0.0-rc.1": "1.0.0-rc.1", 22 | "jsr:@std/path@^1.0.2": "1.0.2", 23 | "jsr:@std/path@~0.225.2": "0.225.2", 24 | "jsr:@ts-morph/bootstrap@0.24": "0.24.0", 25 | "jsr:@ts-morph/common@0.24": "0.24.0", 26 | "npm:@opentelemetry/api-logs@~0.52.1": "0.52.1", 27 | "npm:@opentelemetry/api@^1.9.0": "1.9.0", 28 | "npm:@opentelemetry/exporter-logs-otlp-http@~0.52.1": "0.52.1_@opentelemetry+api@1.9.0", 29 | "npm:@opentelemetry/otlp-exporter-base@~0.52.1": "0.52.1_@opentelemetry+api@1.9.0", 30 | "npm:@opentelemetry/resources@^1.25.1": "1.25.1_@opentelemetry+api@1.9.0", 31 | "npm:@opentelemetry/sdk-logs@~0.52.1": "0.52.1_@opentelemetry+api@1.9.0", 32 | "npm:@opentelemetry/semantic-conventions@^1.26.0": "1.26.0", 33 | "npm:@types/node@*": "18.16.19" 34 | }, 35 | "jsr": { 36 | "@david/code-block-writer@13.0.2": { 37 | "integrity": "14dd3baaafa3a2dea8bf7dfbcddeccaa13e583da2d21d666c01dc6d681cd74ad" 38 | }, 39 | "@deno/cache-dir@0.10.3": { 40 | "integrity": "eb022f84ecc49c91d9d98131c6e6b118ff63a29e343624d058646b9d50404776", 41 | "dependencies": [ 42 | "jsr:@deno/graph", 43 | "jsr:@std/fmt@0.223", 44 | "jsr:@std/fs@0.223", 45 | "jsr:@std/io", 46 | "jsr:@std/path@0.223" 47 | ] 48 | }, 49 | "@deno/dnt@0.41.3": { 50 | "integrity": "b2ef2c8a5111eef86cb5bfcae103d6a2938e8e649e2461634a7befb7fc59d6d2", 51 | "dependencies": [ 52 | "jsr:@david/code-block-writer", 53 | "jsr:@deno/cache-dir", 54 | "jsr:@std/fmt@1", 55 | "jsr:@std/fs@1", 56 | "jsr:@std/path@1", 57 | "jsr:@ts-morph/bootstrap" 58 | ] 59 | }, 60 | "@deno/graph@0.73.1": { 61 | "integrity": "cd69639d2709d479037d5ce191a422eabe8d71bb68b0098344f6b07411c84d41" 62 | }, 63 | "@logtape/logtape@0.4.3": { 64 | "integrity": "eac68335a6ffe28265b16bcbc3aea27e10581c09c0c9f5cb11a3acb5cb170945" 65 | }, 66 | "@std/assert@0.223.0": { 67 | "integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24" 68 | }, 69 | "@std/assert@0.226.0": { 70 | "integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3" 71 | }, 72 | "@std/bytes@0.223.0": { 73 | "integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8" 74 | }, 75 | "@std/dotenv@0.225.1": { 76 | "integrity": "138f31352843d76cfb6cda1b860e8aac6d02ce3b05e7e236a81bf285a9868d0c" 77 | }, 78 | "@std/fmt@0.223.0": { 79 | "integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208" 80 | }, 81 | "@std/fmt@1.0.0": { 82 | "integrity": "8a95c9fdbb61559418ccbc0f536080cf43341655e1444f9d375a66886ceaaa3d" 83 | }, 84 | "@std/fs@0.223.0": { 85 | "integrity": "3b4b0550b2c524cbaaa5a9170c90e96cbb7354e837ad1bdaf15fc9df1ae9c31c" 86 | }, 87 | "@std/fs@0.229.3": { 88 | "integrity": "783bca21f24da92e04c3893c9e79653227ab016c48e96b3078377ebd5222e6eb", 89 | "dependencies": [ 90 | "jsr:@std/path@1.0.0-rc.1" 91 | ] 92 | }, 93 | "@std/fs@1.0.1": { 94 | "integrity": "d6914ca2c21abe591f733b31dbe6331e446815e513e2451b3b9e472daddfefcb", 95 | "dependencies": [ 96 | "jsr:@std/path@^1.0.2" 97 | ] 98 | }, 99 | "@std/io@0.223.0": { 100 | "integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1", 101 | "dependencies": [ 102 | "jsr:@std/assert@0.223", 103 | "jsr:@std/bytes" 104 | ] 105 | }, 106 | "@std/path@0.223.0": { 107 | "integrity": "593963402d7e6597f5a6e620931661053572c982fc014000459edc1f93cc3989", 108 | "dependencies": [ 109 | "jsr:@std/assert@0.223" 110 | ] 111 | }, 112 | "@std/path@0.225.2": { 113 | "integrity": "0f2db41d36b50ef048dcb0399aac720a5348638dd3cb5bf80685bf2a745aa506", 114 | "dependencies": [ 115 | "jsr:@std/assert@0.226" 116 | ] 117 | }, 118 | "@std/path@1.0.0-rc.1": { 119 | "integrity": "b8c00ae2f19106a6bb7cbf1ab9be52aa70de1605daeb2dbdc4f87a7cbaf10ff6" 120 | }, 121 | "@std/path@1.0.2": { 122 | "integrity": "a452174603f8c620bd278a380c596437a9eef50c891c64b85812f735245d9ec7" 123 | }, 124 | "@ts-morph/bootstrap@0.24.0": { 125 | "integrity": "a826a2ef7fa8a7c3f1042df2c034d20744d94da2ee32bf29275bcd4dffd3c060", 126 | "dependencies": [ 127 | "jsr:@ts-morph/common" 128 | ] 129 | }, 130 | "@ts-morph/common@0.24.0": { 131 | "integrity": "12b625b8e562446ba658cdbe9ad77774b4bd96b992ae8bd34c60dbf24d06c1f3", 132 | "dependencies": [ 133 | "jsr:@std/fs@~0.229.3", 134 | "jsr:@std/path@~0.225.2" 135 | ] 136 | } 137 | }, 138 | "npm": { 139 | "@opentelemetry/api-logs@0.52.1": { 140 | "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", 141 | "dependencies": [ 142 | "@opentelemetry/api" 143 | ] 144 | }, 145 | "@opentelemetry/api@1.9.0": { 146 | "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==" 147 | }, 148 | "@opentelemetry/core@1.25.1_@opentelemetry+api@1.9.0": { 149 | "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", 150 | "dependencies": [ 151 | "@opentelemetry/api", 152 | "@opentelemetry/semantic-conventions@1.25.1" 153 | ] 154 | }, 155 | "@opentelemetry/exporter-logs-otlp-http@0.52.1_@opentelemetry+api@1.9.0": { 156 | "integrity": "sha512-qKgywId2DbdowPZpOBXQKp0B8DfhfIArmSic15z13Nk/JAOccBUQdPwDjDnjsM5f0ckZFMVR2t/tijTUAqDZoA==", 157 | "dependencies": [ 158 | "@opentelemetry/api", 159 | "@opentelemetry/api-logs", 160 | "@opentelemetry/core", 161 | "@opentelemetry/otlp-exporter-base", 162 | "@opentelemetry/otlp-transformer", 163 | "@opentelemetry/sdk-logs" 164 | ] 165 | }, 166 | "@opentelemetry/otlp-exporter-base@0.52.1_@opentelemetry+api@1.9.0": { 167 | "integrity": "sha512-z175NXOtX5ihdlshtYBe5RpGeBoTXVCKPPLiQlD6FHvpM4Ch+p2B0yWKYSrBfLH24H9zjJiBdTrtD+hLlfnXEQ==", 168 | "dependencies": [ 169 | "@opentelemetry/api", 170 | "@opentelemetry/core", 171 | "@opentelemetry/otlp-transformer" 172 | ] 173 | }, 174 | "@opentelemetry/otlp-transformer@0.52.1_@opentelemetry+api@1.9.0": { 175 | "integrity": "sha512-I88uCZSZZtVa0XniRqQWKbjAUm73I8tpEy/uJYPPYw5d7BRdVk0RfTBQw8kSUl01oVWEuqxLDa802222MYyWHg==", 176 | "dependencies": [ 177 | "@opentelemetry/api", 178 | "@opentelemetry/api-logs", 179 | "@opentelemetry/core", 180 | "@opentelemetry/resources", 181 | "@opentelemetry/sdk-logs", 182 | "@opentelemetry/sdk-metrics", 183 | "@opentelemetry/sdk-trace-base", 184 | "protobufjs" 185 | ] 186 | }, 187 | "@opentelemetry/resources@1.25.1_@opentelemetry+api@1.9.0": { 188 | "integrity": "sha512-pkZT+iFYIZsVn6+GzM0kSX+u3MSLCY9md+lIJOoKl/P+gJFfxJte/60Usdp8Ce4rOs8GduUpSPNe1ddGyDT1sQ==", 189 | "dependencies": [ 190 | "@opentelemetry/api", 191 | "@opentelemetry/core", 192 | "@opentelemetry/semantic-conventions@1.25.1" 193 | ] 194 | }, 195 | "@opentelemetry/sdk-logs@0.52.1_@opentelemetry+api@1.9.0": { 196 | "integrity": "sha512-MBYh+WcPPsN8YpRHRmK1Hsca9pVlyyKd4BxOC4SsgHACnl/bPp4Cri9hWhVm5+2tiQ9Zf4qSc1Jshw9tOLGWQA==", 197 | "dependencies": [ 198 | "@opentelemetry/api", 199 | "@opentelemetry/api-logs", 200 | "@opentelemetry/core", 201 | "@opentelemetry/resources" 202 | ] 203 | }, 204 | "@opentelemetry/sdk-metrics@1.25.1_@opentelemetry+api@1.9.0": { 205 | "integrity": "sha512-9Mb7q5ioFL4E4dDrc4wC/A3NTHDat44v4I3p2pLPSxRvqUbDIQyMVr9uK+EU69+HWhlET1VaSrRzwdckWqY15Q==", 206 | "dependencies": [ 207 | "@opentelemetry/api", 208 | "@opentelemetry/core", 209 | "@opentelemetry/resources", 210 | "lodash.merge" 211 | ] 212 | }, 213 | "@opentelemetry/sdk-trace-base@1.25.1_@opentelemetry+api@1.9.0": { 214 | "integrity": "sha512-C8k4hnEbc5FamuZQ92nTOp8X/diCY56XUTnMiv9UTuJitCzaNNHAVsdm5+HLCdI8SLQsLWIrG38tddMxLVoftw==", 215 | "dependencies": [ 216 | "@opentelemetry/api", 217 | "@opentelemetry/core", 218 | "@opentelemetry/resources", 219 | "@opentelemetry/semantic-conventions@1.25.1" 220 | ] 221 | }, 222 | "@opentelemetry/semantic-conventions@1.25.1": { 223 | "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" 224 | }, 225 | "@opentelemetry/semantic-conventions@1.26.0": { 226 | "integrity": "sha512-U9PJlOswJPSgQVPI+XEuNLElyFWkb0hAiMg+DExD9V0St03X2lPHGMdxMY/LrVmoukuIpXJ12oyrOtEZ4uXFkw==" 227 | }, 228 | "@protobufjs/aspromise@1.1.2": { 229 | "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" 230 | }, 231 | "@protobufjs/base64@1.1.2": { 232 | "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" 233 | }, 234 | "@protobufjs/codegen@2.0.4": { 235 | "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" 236 | }, 237 | "@protobufjs/eventemitter@1.1.0": { 238 | "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" 239 | }, 240 | "@protobufjs/fetch@1.1.0": { 241 | "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", 242 | "dependencies": [ 243 | "@protobufjs/aspromise", 244 | "@protobufjs/inquire" 245 | ] 246 | }, 247 | "@protobufjs/float@1.0.2": { 248 | "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" 249 | }, 250 | "@protobufjs/inquire@1.1.0": { 251 | "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" 252 | }, 253 | "@protobufjs/path@1.1.2": { 254 | "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" 255 | }, 256 | "@protobufjs/pool@1.1.0": { 257 | "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" 258 | }, 259 | "@protobufjs/utf8@1.1.0": { 260 | "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" 261 | }, 262 | "@types/node@18.16.19": { 263 | "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==" 264 | }, 265 | "@types/node@22.0.0": { 266 | "integrity": "sha512-VT7KSYudcPOzP5Q0wfbowyNLaVR8QWUdw+088uFWwfvpY6uCWaXpqV6ieLAu9WBcnTa7H4Z5RLK8I5t2FuOcqw==", 267 | "dependencies": [ 268 | "undici-types" 269 | ] 270 | }, 271 | "lodash.merge@4.6.2": { 272 | "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" 273 | }, 274 | "long@5.2.3": { 275 | "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" 276 | }, 277 | "protobufjs@7.4.0": { 278 | "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", 279 | "dependencies": [ 280 | "@protobufjs/aspromise", 281 | "@protobufjs/base64", 282 | "@protobufjs/codegen", 283 | "@protobufjs/eventemitter", 284 | "@protobufjs/fetch", 285 | "@protobufjs/float", 286 | "@protobufjs/inquire", 287 | "@protobufjs/path", 288 | "@protobufjs/pool", 289 | "@protobufjs/utf8", 290 | "@types/node@22.0.0", 291 | "long" 292 | ] 293 | }, 294 | "undici-types@6.11.1": { 295 | "integrity": "sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ==" 296 | } 297 | }, 298 | "workspace": { 299 | "dependencies": [ 300 | "jsr:@deno/dnt@~0.41.3", 301 | "jsr:@logtape/logtape@~0.4.3", 302 | "jsr:@std/dotenv@~0.225.1", 303 | "npm:@opentelemetry/api-logs@~0.52.1", 304 | "npm:@opentelemetry/api@^1.9.0", 305 | "npm:@opentelemetry/exporter-logs-otlp-http@~0.52.1", 306 | "npm:@opentelemetry/otlp-exporter-base@~0.52.1", 307 | "npm:@opentelemetry/resources@^1.25.1", 308 | "npm:@opentelemetry/sdk-logs@~0.52.1", 309 | "npm:@opentelemetry/semantic-conventions@^1.26.0" 310 | ] 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /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 | const importMap = ".dnt-import-map.json"; 7 | await Deno.writeTextFile( 8 | importMap, 9 | JSON.stringify({ 10 | imports: { 11 | ...metadata.imports, 12 | "@logtape/logtape": metadata.imports["@logtape/logtape"] 13 | .replace(/^jsr:/, "npm:"), 14 | }, 15 | }), 16 | ); 17 | 18 | await build({ 19 | package: { 20 | name: "@logtape/otel", 21 | version: Deno.args[0] ?? metadata.version, 22 | description: "LogTape OpenTelemetry Sink", 23 | keywords: ["LogTape", "OpenTelemetry", "otel"], 24 | license: "MIT", 25 | author: { 26 | name: "Hong Minhee", 27 | email: "hong@minhee.org", 28 | url: "https://hongminhee.org/", 29 | }, 30 | homepage: "https://github.com/dahlia/logtape-otel", 31 | repository: { 32 | type: "git", 33 | url: "git+https://github.com/dahlia/logtape-otel.git", 34 | }, 35 | bugs: { 36 | url: "https://github.com/dahlia/logtape-otel/issues", 37 | }, 38 | funding: [ 39 | "https://github.com/sponsors/dahlia", 40 | ], 41 | }, 42 | outDir: "./npm", 43 | entryPoints: ["./mod.ts"], 44 | importMap: ".dnt-import-map.json", 45 | shims: { 46 | deno: "dev", 47 | }, 48 | typeCheck: "both", 49 | declaration: "separate", 50 | declarationMap: true, 51 | compilerOptions: { 52 | lib: ["ES2021", "DOM"], 53 | }, 54 | async postBuild() { 55 | await Deno.copyFile("LICENSE", "npm/LICENSE"); 56 | await Deno.copyFile("README.md", "npm/README.md"); 57 | }, 58 | }); 59 | 60 | await Deno.remove(importMap); 61 | 62 | // cSpell: ignore Minhee 63 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getLogger, 3 | type Logger, 4 | type LogRecord, 5 | type Sink, 6 | } from "@logtape/logtape"; 7 | import { diag, type DiagLogger, DiagLogLevel } from "@opentelemetry/api"; 8 | import { 9 | type AnyValue, 10 | type LoggerProvider as LoggerProviderBase, 11 | type LogRecord as OTLogRecord, 12 | SeverityNumber, 13 | } from "@opentelemetry/api-logs"; 14 | import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; 15 | import type { OTLPExporterNodeConfigBase } from "@opentelemetry/otlp-exporter-base"; 16 | import { Resource } from "@opentelemetry/resources"; 17 | import { 18 | LoggerProvider, 19 | type LogRecordProcessor, 20 | SimpleLogRecordProcessor, 21 | } from "@opentelemetry/sdk-logs"; 22 | import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; 23 | import process from "node:process"; 24 | import metadata from "./deno.json" with { type: "json" }; 25 | 26 | /** 27 | * The OpenTelemetry logger provider. 28 | */ 29 | type ILoggerProvider = LoggerProviderBase & { 30 | /** 31 | * Adds a new {@link LogRecordProcessor} to this logger. 32 | * @param processor the new LogRecordProcessor to be added. 33 | */ 34 | addLogRecordProcessor(processor: LogRecordProcessor): void; 35 | 36 | /** 37 | * Flush all buffered data and shut down the LoggerProvider and all registered 38 | * LogRecordProcessor. 39 | * 40 | * Returns a promise which is resolved when all flushes are complete. 41 | */ 42 | shutdown?: () => Promise; 43 | }; 44 | 45 | /** 46 | * The way to render the object in the log record. If `"json"`, 47 | * the object is rendered as a JSON string. If `"inspect"`, 48 | * the object is rendered using `util.inspect` in Node.js/Bun, or 49 | * `Deno.inspect` in Deno. 50 | */ 51 | export type ObjectRenderer = "json" | "inspect"; 52 | 53 | type Message = (string | null | undefined)[]; 54 | 55 | /** 56 | * Custom `body` attribute formatter. 57 | * @since 0.3.0 58 | */ 59 | export type BodyFormatter = (message: Message) => AnyValue; 60 | 61 | /** 62 | * Options for creating an OpenTelemetry sink. 63 | */ 64 | export interface OpenTelemetrySinkOptions { 65 | /** 66 | * The OpenTelemetry logger provider to use. 67 | */ 68 | loggerProvider?: ILoggerProvider; 69 | 70 | /** 71 | * The way to render the message in the log record. If `"string"`, 72 | * the message is rendered as a single string with the values are 73 | * interpolated into the message. If `"array"`, the message is 74 | * rendered as an array of strings. `"string"` by default. 75 | * 76 | * Or even fully customizable with a {@link BodyFormatter} function. 77 | * @since 0.2.0 78 | */ 79 | messageType?: "string" | "array" | BodyFormatter; 80 | 81 | /** 82 | * The way to render the object in the log record. If `"json"`, 83 | * the object is rendered as a JSON string. If `"inspect"`, 84 | * the object is rendered using `util.inspect` in Node.js/Bun, or 85 | * `Deno.inspect` in Deno. `"inspect"` by default. 86 | */ 87 | objectRenderer?: ObjectRenderer; 88 | 89 | /** 90 | * Whether to log diagnostics. Diagnostic logs are logged to 91 | * the `["logtape", "meta", "otel"]` category. 92 | * Turned off by default. 93 | */ 94 | diagnostics?: boolean; 95 | 96 | /** 97 | * The OpenTelemetry OTLP exporter configuration to use. 98 | * Ignored if `loggerProvider` is provided. 99 | */ 100 | otlpExporterConfig?: OTLPExporterNodeConfigBase; 101 | 102 | /** 103 | * The service name to use. If not provided, the service name is 104 | * taken from the `OTEL_SERVICE_NAME` environment variable. 105 | * Ignored if `loggerProvider` is provided. 106 | */ 107 | serviceName?: string; 108 | } 109 | 110 | /** 111 | * Creates a sink that forwards log records to OpenTelemetry. 112 | * @param options Options for creating the sink. 113 | * @returns The sink. 114 | */ 115 | export function getOpenTelemetrySink( 116 | options: OpenTelemetrySinkOptions = {}, 117 | ): Sink { 118 | if (options.diagnostics) { 119 | diag.setLogger(new DiagLoggerAdaptor(), DiagLogLevel.DEBUG); 120 | } 121 | 122 | let loggerProvider: ILoggerProvider; 123 | if (options.loggerProvider == null) { 124 | const resource = Resource.default().merge( 125 | new Resource({ 126 | [ATTR_SERVICE_NAME]: options.serviceName ?? 127 | process.env.OTEL_SERVICE_NAME, 128 | }), 129 | ); 130 | loggerProvider = new LoggerProvider({ resource }); 131 | const otlpExporter = new OTLPLogExporter(options.otlpExporterConfig); 132 | loggerProvider.addLogRecordProcessor( 133 | // @ts-ignore: it works anyway... 134 | new SimpleLogRecordProcessor(otlpExporter), 135 | ); 136 | } else { 137 | loggerProvider = options.loggerProvider; 138 | } 139 | const objectRenderer = options.objectRenderer ?? "inspect"; 140 | const logger = loggerProvider.getLogger(metadata.name, metadata.version); 141 | const sink = (record: LogRecord) => { 142 | const { category, level, message, timestamp, properties } = record; 143 | if ( 144 | category[0] === "logtape" && category[1] === "meta" && 145 | category[2] === "otel" 146 | ) { 147 | return; 148 | } 149 | const severityNumber = mapLevelToSeverityNumber(level); 150 | const attributes = convertToAttributes(properties, objectRenderer); 151 | attributes["category"] = [...category]; 152 | logger.emit( 153 | { 154 | severityNumber, 155 | severityText: level, 156 | body: typeof options.messageType === "function" 157 | ? convertMessageToCustomBodyFormat( 158 | message, 159 | objectRenderer, 160 | options.messageType, 161 | ) 162 | : options.messageType === "array" 163 | ? convertMessageToArray(message, objectRenderer) 164 | : convertMessageToString(message, objectRenderer), 165 | attributes, 166 | timestamp: new Date(timestamp), 167 | } satisfies OTLogRecord, 168 | ); 169 | }; 170 | if (loggerProvider.shutdown != null) { 171 | const shutdown = loggerProvider.shutdown.bind(loggerProvider); 172 | sink[Symbol.asyncDispose] = shutdown; 173 | } 174 | return sink; 175 | } 176 | 177 | function mapLevelToSeverityNumber(level: string): number { 178 | switch (level) { 179 | case "debug": 180 | return SeverityNumber.DEBUG; 181 | case "info": 182 | return SeverityNumber.INFO; 183 | case "warning": 184 | return SeverityNumber.WARN; 185 | case "error": 186 | return SeverityNumber.ERROR; 187 | case "fatal": 188 | return SeverityNumber.FATAL; 189 | default: 190 | return SeverityNumber.UNSPECIFIED; 191 | } 192 | } 193 | 194 | function convertToAttributes( 195 | properties: Record, 196 | objectRenderer: ObjectRenderer, 197 | ): Record { 198 | const attributes: Record = {}; 199 | for (const [name, value] of Object.entries(properties)) { 200 | const key = `attributes.${name}`; 201 | if (value == null) continue; 202 | if (Array.isArray(value)) { 203 | let t = null; 204 | for (const v of value) { 205 | if (v == null) continue; 206 | if (t != null && typeof v !== t) { 207 | attributes[key] = value.map((v) => 208 | convertToString(v, objectRenderer) 209 | ); 210 | break; 211 | } 212 | t = typeof v; 213 | } 214 | attributes[key] = value; 215 | } else { 216 | const encoded = convertToString(value, objectRenderer); 217 | if (encoded == null) continue; 218 | attributes[key] = encoded; 219 | } 220 | } 221 | return attributes; 222 | } 223 | 224 | function convertToString( 225 | value: unknown, 226 | objectRenderer: ObjectRenderer, 227 | ): string | null | undefined { 228 | if (value === null || value === undefined || typeof value === "string") { 229 | return value; 230 | } 231 | if (objectRenderer === "inspect") return inspect(value); 232 | if (typeof value === "number" || typeof value === "boolean") { 233 | return value.toString(); 234 | } else if (value instanceof Date) return value.toISOString(); 235 | else return JSON.stringify(value); 236 | } 237 | 238 | function convertMessageToArray( 239 | message: readonly unknown[], 240 | objectRenderer: ObjectRenderer, 241 | ): AnyValue { 242 | const body: (string | null | undefined)[] = []; 243 | for (let i = 0; i < message.length; i += 2) { 244 | const msg = message[i] as string; 245 | body.push(msg); 246 | if (message.length <= i + 1) break; 247 | const val = message[i + 1]; 248 | body.push(convertToString(val, objectRenderer)); 249 | } 250 | return body; 251 | } 252 | 253 | function convertMessageToString( 254 | message: readonly unknown[], 255 | objectRenderer: ObjectRenderer, 256 | ): AnyValue { 257 | let body = ""; 258 | for (let i = 0; i < message.length; i += 2) { 259 | const msg = message[i] as string; 260 | body += msg; 261 | if (message.length <= i + 1) break; 262 | const val = message[i + 1]; 263 | const extra = convertToString(val, objectRenderer); 264 | body += extra ?? JSON.stringify(extra); 265 | } 266 | return body; 267 | } 268 | 269 | function convertMessageToCustomBodyFormat( 270 | message: readonly unknown[], 271 | objectRenderer: ObjectRenderer, 272 | bodyFormatter: BodyFormatter, 273 | ): AnyValue { 274 | const body = message.map((msg) => convertToString(msg, objectRenderer)); 275 | return bodyFormatter(body); 276 | } 277 | 278 | /** 279 | * A platform-specific inspect function. In Deno, this is {@link Deno.inspect}, 280 | * and in Node.js/Bun it is {@link util.inspect}. If neither is available, it 281 | * falls back to {@link JSON.stringify}. 282 | * 283 | * @param value The value to inspect. 284 | * @returns The string representation of the value. 285 | */ 286 | const inspect: (value: unknown) => string = 287 | // @ts-ignore: Deno global 288 | "Deno" in globalThis && "inspect" in globalThis.Deno && 289 | // @ts-ignore: Deno global 290 | typeof globalThis.Deno.inspect === "function" 291 | // @ts-ignore: Deno global 292 | ? globalThis.Deno.inspect 293 | // @ts-ignore: Node.js global 294 | : "util" in globalThis && "inspect" in globalThis.util && 295 | // @ts-ignore: Node.js global 296 | globalThis.util.inspect === "function" 297 | // @ts-ignore: Node.js global 298 | ? globalThis.util.inspect 299 | : JSON.stringify; 300 | 301 | class DiagLoggerAdaptor implements DiagLogger { 302 | logger: Logger; 303 | 304 | constructor() { 305 | this.logger = getLogger(["logtape", "meta", "otel"]); 306 | } 307 | 308 | #escape(msg: string): string { 309 | return msg.replaceAll("{", "{{").replaceAll("}", "}}"); 310 | } 311 | 312 | error(msg: string, ...values: unknown[]): void { 313 | this.logger.error(`${this.#escape(msg)}: {values}`, { values }); 314 | } 315 | 316 | warn(msg: string, ...values: unknown[]): void { 317 | this.logger.warn(`${this.#escape(msg)}: {values}`, { values }); 318 | } 319 | 320 | info(msg: string, ...values: unknown[]): void { 321 | this.logger.info(`${this.#escape(msg)}: {values}`, { values }); 322 | } 323 | 324 | debug(msg: string, ...values: unknown[]): void { 325 | this.logger.debug(`${this.#escape(msg)}: {values}`, { values }); 326 | } 327 | 328 | verbose(msg: string, ...values: unknown[]): void { 329 | this.logger.debug(`${this.#escape(msg)}: {values}`, { values }); 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /sample.ts: -------------------------------------------------------------------------------- 1 | import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; 2 | import { getOpenTelemetrySink } from "@logtape/otel"; 3 | import "@std/dotenv/load"; 4 | 5 | await configure({ 6 | sinks: { 7 | console: getConsoleSink(), 8 | otel: getOpenTelemetrySink({ 9 | messageType: "array", 10 | diagnostics: true, 11 | }), 12 | }, 13 | filters: {}, 14 | loggers: [ 15 | { category: [], sinks: ["console", "otel"], level: "debug" }, 16 | ], 17 | }); 18 | 19 | getLogger(["test", "app"]).debug("hello {world} at {timestamp}", { 20 | world: "debug", 21 | timestamp: new Date(), 22 | }); 23 | getLogger(["test", "app"]).info("hello {world} with {object}", { 24 | world: "info", 25 | object: new Uint8Array([1, 2, 3]), 26 | }); 27 | getLogger(["test", "app"]).warn("hello {world}", { world: "warning" }); 28 | --------------------------------------------------------------------------------