From 13c39face269e1ac4b1b18e3c51395f242f3f201 Mon Sep 17 00:00:00 2001 From: Victor Carvalho Date: Mon, 12 Oct 2026 22:21:00 -0300 Subject: [PATCH 01/12] chore: scaffold @codelittinc/otel-node package pnpm, Node 22, TypeScript built to dist/, vitest, ESM. Dependency pins match Backbone's packages/telemetry. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + package.json | 82 ++ pnpm-lock.yaml | 2264 +++++++++++++++++++++++++++++++++++++++++++ tsconfig.build.json | 4 + tsconfig.json | 24 + vitest.config.ts | 8 + 6 files changed, 2385 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab68e59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.claude/worktrees/ +*.tgz diff --git a/package.json b/package.json new file mode 100644 index 0000000..55fe30e --- /dev/null +++ b/package.json @@ -0,0 +1,82 @@ +{ + "name": "@codelittinc/otel-node", + "version": "0.1.0", + "description": "In-process OpenTelemetry for Node services: SDK boot, layer-1 PII scrub, OTLP logs.", + "license": "UNLICENSED", + "repository": "github:codelittinc/otel-node", + "type": "module", + "packageManager": "pnpm@10.33.1", + "engines": { + "node": ">=22" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "default": "./dist/testing.js" + }, + "./register": { + "types": "./dist/register.d.ts", + "default": "./dist/register.js" + }, + "./url-credentials": { + "types": "./dist/url-credentials.d.ts", + "default": "./dist/url-credentials.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@opentelemetry/api": "1.9.1", + "@opentelemetry/api-logs": "0.222.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/exporter-logs-otlp-proto": "0.222.0", + "@opentelemetry/exporter-metrics-otlp-proto": "0.222.0", + "@opentelemetry/exporter-trace-otlp-proto": "0.222.0", + "@opentelemetry/instrumentation": "0.222.0", + "@opentelemetry/instrumentation-http": "0.222.0", + "@opentelemetry/instrumentation-ioredis": "0.70.0", + "@opentelemetry/instrumentation-pg": "0.74.0", + "@opentelemetry/instrumentation-runtime-node": "0.35.0", + "@opentelemetry/instrumentation-undici": "0.32.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/sdk-logs": "0.222.0", + "@opentelemetry/sdk-metrics": "2.11.0", + "@opentelemetry/sdk-node": "0.222.0", + "@opentelemetry/sdk-trace-base": "2.11.0", + "@opentelemetry/semantic-conventions": "1.43.0", + "@pyroscope/nodejs": "0.6.4", + "bullmq-otel": "2.0.1", + "import-in-the-middle": "3.5.1", + "pino": "10.3.1" + }, + "devDependencies": { + "@opentelemetry/context-async-hooks": "2.11.0", + "@types/node": "22.19.17", + "bullmq": "6.3.8", + "typescript": "5.9.3", + "vitest": "3.2.7" + }, + "peerDependencies": { + "bullmq": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "bullmq": { + "optional": true + } + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..26d49f8 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2264 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@opentelemetry/api': + specifier: 1.9.1 + version: 1.9.1 + '@opentelemetry/api-logs': + specifier: 0.222.0 + version: 0.222.0 + '@opentelemetry/core': + specifier: 2.11.0 + version: 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': + specifier: 0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: 0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: 0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': + specifier: 0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': + specifier: 0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': + specifier: 0.70.0 + version: 0.70.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': + specifier: 0.74.0 + version: 0.74.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-runtime-node': + specifier: 0.35.0 + version: 0.35.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': + specifier: 0.32.0 + version: 0.32.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: 2.11.0 + version: 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': + specifier: 0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: 2.11.0 + version: 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: 0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': + specifier: 2.11.0 + version: 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': + specifier: 1.43.0 + version: 1.43.0 + '@pyroscope/nodejs': + specifier: 0.6.4 + version: 0.6.4 + bullmq-otel: + specifier: 2.0.1 + version: 2.0.1(bullmq@6.3.8) + import-in-the-middle: + specifier: 3.5.1 + version: 3.5.1 + pino: + specifier: 10.3.1 + version: 10.3.1 + devDependencies: + '@opentelemetry/context-async-hooks': + specifier: 2.11.0 + version: 2.11.0(@opentelemetry/api@1.9.1) + '@types/node': + specifier: 22.19.17 + version: 22.19.17 + bullmq: + specifier: 6.3.8 + version: 6.3.8(bullmq-otel@2.0.1) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 3.2.7 + version: 3.2.7(@types/node@22.19.17)(yaml@2.9.1) + +packages: + + '@datadog/pprof@5.14.4': + resolution: {integrity: sha512-egEZDD9v98RBI8ijbHyaWQeY8rW0WEu004As5D7SUkdqSMORhrnh7ZdsM46PUzQgAc85IaEZoukWS9UhMvWn9w==} + engines: {node: '>=16'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@grpc/grpc-js@1.14.6': + resolution: {integrity: sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@opentelemetry/api-logs@0.222.0': + resolution: {integrity: sha512-9mb1If+IF6u0ZVXkHQ6ogEae5HwA6ajIVUgpSDQyRASxft6BSXHvBvPooRle3yFN/fKnCdSOnuu0OC3PLcF6+g==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/configuration@0.222.0': + resolution: {integrity: sha512-zNWGmEvyX7CUbS6F6PKi7hf3CF/SffzeBYyWVQAfadZ8hODrvLmqV3IUGVt1SiqyYanRPHc8VuhoO0Qh9OEuug==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/context-async-hooks@2.11.0': + resolution: {integrity: sha512-Tr79DyWI8itsBdg+jH+opjfrwLzX+erk1/ExkIwhWoAVjVrJIn2y5+cGjTC0Vy8fyNIA/y8wuJPZwr1T3xCZeQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.11.0': + resolution: {integrity: sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.222.0': + resolution: {integrity: sha512-HH4Oi/E8HXBKtcjgcVPJGsrg+lSstdw1m93brRdpVj5CjMpyTkv/O0ZYMXP6QXVEaLjjFivtzUu4Y8+i1UJpjw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.222.0': + resolution: {integrity: sha512-GqwLZohJTf/sttYlOpT1oFTJ9ScYaKnk0sOx4Qe/+KmXYVOa+v94cfnkUkFKDuvTODWhCL50Hz3gvsZD6ngQpg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.222.0': + resolution: {integrity: sha512-LlCDJuaznfFPzjHoKXjN/r9T5pgCZpmVheZ/EmwMUhMvpxI7RXDmsdLXoYpl5suYQNkwyXAUZhVEXKe3lev8Hg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.222.0': + resolution: {integrity: sha512-nPU2gpQWJfQttf+A71aLkKEOQoO6n6QGs5QTtPagi/rb3Lc1I55bqRXoLQEZsWqWimc8eas2Vo168NkTaByxQw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.222.0': + resolution: {integrity: sha512-6Ko+0bgNP6V4G/RsmNBSk5lO1B/lo9z6tvLpFY3ykPPevXNzFmOW3C9hEmTt0zKCvHovyrol3TYlg4HVb5gwjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.222.0': + resolution: {integrity: sha512-s1ZPwS8Ig7cpRmL3kCobPaFbemM81dJ03LNPrsuN32h3mCDvNF2NQ1pRHruOju0P0o1TZ24LkZA685rtQal1KA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.222.0': + resolution: {integrity: sha512-I7nalnnBZrhlTgjlqsVeEjJGcQCFK5kKAYVk7XTz/cbubvj5fvjLOal/Many0taHF2GIkOWmegsbQ8vbQJjhFg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.222.0': + resolution: {integrity: sha512-uisilePEdOMa3hIfXW82XXU6FofF8gw3iv+PoboIMWEUBC7XbJB3XnIHSdrSDsqCw3njR/If08qD0yzOY1U9Sg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.222.0': + resolution: {integrity: sha512-RCnPWcHppwiquQ+cV3nWvNwdf0MG1w26e5jewW2T83nTZOlXgcg88sY9ulCVgagGHcq1mj0L0GP6YHgzk2v8oA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.222.0': + resolution: {integrity: sha512-imLrLzNhnABJt3tUyT63h6MqfZrG3Gdv3TE7+OUQTvwD088RfCUx3rF01lcgfu2KXBFk13vwi0yKZLvFEalkIA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.11.0': + resolution: {integrity: sha512-mFibA45yXLEETIj+VcTVycFFe4R91iD41d+r3CU0f3DDWJCrICiHxUIjmYBTCZIuywXEeNo++2lncQeWnH7lWQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-http@0.222.0': + resolution: {integrity: sha512-HhMHW32LyGzIJEmUElxaML4oUe6JiCq0Hvjt/Awxb60SlMsSFetFLFjed45g/0wrPr1n7z+xW+x1B/584WBAkg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.70.0': + resolution: {integrity: sha512-AUyn8K5vlSGkIF2AXPlUsa5d5ZXRidHMXlAHNAykvgW3u8juDZqGTgsOKJMYnLfllawygawLrqK6Fr2lnPcBUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.74.0': + resolution: {integrity: sha512-8i59Z7M0FjEwp2hF1eRqEr+NZtiwjvPYGl2xsGWEfL/hF6ffmPERWbySyK1EXa7gGyHCArwNkHLShsMfFywqpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-runtime-node@0.35.0': + resolution: {integrity: sha512-/cOW4Qlv4nnr4QXDCkv0TEtOBa9R9lXHu//JuD5+nRPYR74g9iLmf5tadcYvn32oz2R8vQnV8FTnMLzjfAGMCQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.32.0': + resolution: {integrity: sha512-Ms8oPkrCHa6hJOYodcFC8QOt1AFlns3KD1CqXBEihXyTFeLXDO84tIXYyKlXNHr8jMrl1Gm2velDvsSDXAuh9w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation@0.222.0': + resolution: {integrity: sha512-fhbRDuAgzPKpq1k5+hX3uS+3QAYNca0on0Ngot/R8dDsQtuFaADeZnG2uSgxABuvcI7S2phAaRhbct9jieeKZw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.222.0': + resolution: {integrity: sha512-YbywG3veEm2Fb6TbdxRkuquWob6eVWXuA8/Ba1tXz9jHfUqpdE3keilOHEtPboC4CvS1bjeeVfNkWGOOrLj+lw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.222.0': + resolution: {integrity: sha512-Kw5WqDBYYpCgY+edHWZS/Jp3bdguqNkCyEWOsqFhf8RmtcEuWYzPFA9i+L6UsNoJXTs8ngeQB6dwELH1czNYjA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.222.0': + resolution: {integrity: sha512-/F3BZ89+CJQnZkMh2tCrtcdB+XT2Dxhj4FFE+WPQ//413hmFL0/RfEX6vgOIWGhiSzrkHWTK3+6SiT7K5/g/jQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@2.11.0': + resolution: {integrity: sha512-ldyRZXx6qsqu9Spqd7rVxo6SNAUJqP8Ir3vwIi4N8xyZLXNl/2XeQmIHvFneiVOqNwWkU+JGFg2O9/T1fefU0w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.11.0': + resolution: {integrity: sha512-RUOqgoIqXOPQTOhpBrs07Y8sBlxVoLY5Fka6ueZ0prW+k6fWQsba/nHDY4gm+gmfuwtRqbX5Y9YKhDvktjbIqQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.38.3': + resolution: {integrity: sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resources@2.11.0': + resolution: {integrity: sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.222.0': + resolution: {integrity: sha512-+19YHODIjaUCArxleaJtuufFZVpz/xvvK+VllQqE+W8hHolxdoRwHfK/s667zezwh1hkx6FFF+oYzetYgqK+Bg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.11.0': + resolution: {integrity: sha512-7GXXcObyHyDUUSG+L+kJoquty01bzm7ivE7+SSgXXJcHuPzGviptxwARmI2c+bnnxjexGQbJnyNlN8HxBP/Y7A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.222.0': + resolution: {integrity: sha512-t8tlFN/ezYV7iI4k6u3t2jFbIReX99Z0iVMVSYLJscE4McxIW9YGHhdOldeY1elHeoq8mjEO4B9s4Fk7+zJbsg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.11.0': + resolution: {integrity: sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.11.0': + resolution: {integrity: sha512-CuvCMJmZxswhNLlM2LfuLOW3h3fZujA4hsG4B+Sz4dX2zvaXO8Ng74cnDHWD64gLszTlhiG3c0iNUjj4g+0/sA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.11.0': + resolution: {integrity: sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.42.0': + resolution: {integrity: sha512-nwUwUU+8O8a4bnLqk6CodWeegGMEANgC94KTAhXcpGWLrW/2/hek/0ajNbjXnSOoNuCX+nteUPs46HFHhou9Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + + '@pyroscope/nodejs@0.6.4': + resolution: {integrity: sha512-pSRR7QOtToxhYuKJJRxFHR74pElI2jSFV20b/j+zN3BMEoOOUCA/RxgEK3gjGB66/c85RGeRy5emOIf6+OAghw==} + engines: {node: '>=20.20.2'} + peerDependencies: + express: ^4.0.0 || ^5.0.0 + fastify: ^5.8.5 + peerDependenciesMeta: + express: + optional: true + fastify: + optional: true + + '@rollup/rollup-android-arm-eabi@4.64.5': + resolution: {integrity: sha512-MrfgRQSpD5hkkRg6XI16B6EDLb+6+xp8xje6bpdSwkYw+qxrBKbUVrRAQu9h82UT+QumSIVQ8HXsXjPd2dlhqw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.64.5': + resolution: {integrity: sha512-+uu2p/R9Tzn8GpoQyX1pAo5dmKT1DhXevfaCzqzmPFhnLjHLWw7uWkaVGlxmhwYoRiYYhsGfRcD5cTBpL7rgSw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.64.5': + resolution: {integrity: sha512-17dys+YEmzwzzFVHQAVDONg5FALXY9kd99HFEpfk4gJVHYxoh5bGea3D4t1fXXnwkTcOe25wPyd6nWS0I8IdLQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.64.5': + resolution: {integrity: sha512-wg0zQEKkEvFZf1UaaX1UELOoPs//OSKUG7D+yITuqOsAvza7LvHTv5Wu4nmMfzNG7EYI2jp2PJC7Id6OETmsYQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.64.5': + resolution: {integrity: sha512-CoJmWcpEMuwin+TZ/ev/H9uDyF/uxUKQSWXdmIPn/UBx/uGV9+4ebMp0FwnlhWR9PE1AdzYsKf3bZevhif38+A==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.64.5': + resolution: {integrity: sha512-PxZnLAaTUAL5xG0W2BXLeYPKrK2b3LIASV2/e/Kf7PZvSLI1iAQIm9B9aJD+YyFIT7hc6qaMV3+OOO35R28LfA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.64.5': + resolution: {integrity: sha512-uPo3IvnONjhbFXwWgroBeFwOicuhT7ZwQrnzkzmOtBJzeQbkUJUE1SJDfDI31ijQ5+ZUIA6sPrdUMxTSnxUWAg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.64.5': + resolution: {integrity: sha512-ywI8Jyuc4LyHYTjC0mw4yA1ulvZBkHusdpWnoiEBsmBuHvSnt90btTBGCZ+s/2y+YNNfVAEKy33nPo5UI9zTmg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.64.5': + resolution: {integrity: sha512-tmuXJoQ/3ovSr637aykZMdQteU/CB2APAz2CrvsbibuoHZi1BS1Db13cVViBdEARGwXCLn1DSJ8wsjoThLJPGA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.64.5': + resolution: {integrity: sha512-5FMjSYNcUOHx7oW8Qu9KB6jGPpvF/8sG7+BDHwU/Ihh1/6FMJ9ySmsFG4tJ1qbNRuP8OLzCBDso4sF0mQaLuFQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.64.5': + resolution: {integrity: sha512-K8ZJ+neLDbUkIrL8dqF/5LtuVCrKwo27kREgOlxjBEjlOXwHvDnHLU8Z/UKv/B49qi7e8hsSDOyoJwhwqhvX4w==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.64.5': + resolution: {integrity: sha512-NlVxsq7PFPv0DBKGuNWRDKixQYQCjM53zl04mxEcKMBmJbCBIUm1JdPfIWDRHyOOFzzYPmLQzfeEwh4716ayQg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.64.5': + resolution: {integrity: sha512-ARhRHJYs5eCFet5azfrHD97vOkD8OzNW2RSMlvo5CcBqXaTOLfp0OHDtUQh7/K5AL5fUYoFp2fgC9KDg4vVYMQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.64.5': + resolution: {integrity: sha512-atSgreM5C7jpxd9aEXN+6vJ5wTd05iYw+S3FiQpw7VsoEl8B90llbq35ARON+GOHj5QlVQEq2vOKWCHTob/BSQ==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.64.5': + resolution: {integrity: sha512-vmlmwBFGnjVWxSRoVabNkyYuscSmg52Pd0fM8jEbNT3Jmka4A6HcB84x4uAmuumznWfBVaxKOqU6jbEgomWKCA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.64.5': + resolution: {integrity: sha512-hhJrP6S9WkbsbcHgUkvCBS5Cn8IdsG8XjlLZyxjnv9+NdoYfbbZE4TWJYr8apdaj1M5t5Y1id2StnEslxpNFyA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.64.5': + resolution: {integrity: sha512-NNVCyN9pGrwFmQa4bf+Cnf5aVH3g6ZpnlOg1Cs/H9swIHT9bRixijfzUqyEIWm1WtiAuR1LJ+rJH6zpBm8kgQA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.64.5': + resolution: {integrity: sha512-dR9nd6OKnvSS60Ifz/L7Mj5zzYyoqyRn2NwTuaOONKaa3xsVGXyp8YusMbvFyEOR94DxnwgcbgsFqkHd29ZSzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.64.5': + resolution: {integrity: sha512-1tzsMjaqhsh3bAO6SJiJqh8hIr9sSPRd+i2vPPxJHod8/XYnf9TEnK0z/u3r1z1AkI347X0KbRSg9dWcxeGahw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.64.5': + resolution: {integrity: sha512-01mWMQblKoXXsmk28KXtpbA96w7g/aOWUoV3AC16fdHdqkykGalqyyM+5u8M7uP5/MEMiiPv69Hv7iv/hEH/dA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.64.5': + resolution: {integrity: sha512-trdnMPb/DNm4V/CgsaoGgr90HfQQ071wfK0KoIs4YpLvjBgVQGXhGabg2Z6nK4EcYNilrb9ZZPcsP6+zpJIdxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.64.5': + resolution: {integrity: sha512-x2LxZlfdfsw99T6/qb9EqE/Qi84JWzibjyOJyhJsgFJy6CafaTiqIXCCnslCGrV5XWNh7fwrS3FbRENcuKnooA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.64.5': + resolution: {integrity: sha512-/Qd/9aDQIo0+jmItgBmPIs8195YVbDxRRApn47GyufgbutCDOh7eP5vSKKlI4QNFeaI+wKQAkSMsPnF3EuMpaA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.64.5': + resolution: {integrity: sha512-ngUJ/XvOWrqbd2IMsfXfh2xXFLTP3sY7sCKvxbGvYdBZ1KmXFd3ewDZ9n3/GNzAUgzRuhLT/UvJRivbNoAQRBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.64.5': + resolution: {integrity: sha512-IhV6ew/DuzUWwHaYmlh7MrPCV3lKFUq/amTEwysi4r/mwNPAEfAMh5dYWeTSPTeMy2pFHGMEWYtPkHzfX3tG5A==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@types/pg-pool@2.0.7': + resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + bullmq-otel@2.0.1: + resolution: {integrity: sha512-w+nO+oo3VBL62z2oVlqIPgc2g5+CNy6O3FQEy+M/220togI/NBaqanGdTQt0pvzE8o++oz5D+GtROuRqD9+8qg==} + peerDependencies: + bullmq: '>=6.0.0' + + bullmq@6.3.8: + resolution: {integrity: sha512-09zcOQ1uycyCK2C9iY2Pm+/JPa/gIXiZqGBUiIlO1v3VshwBgarmAgWRTWDL/IAvI8NFE1zqrkdKFgRfXHHM5A==} + engines: {node: '>=14.17.0'} + peerDependencies: + bullmq-otel: '>=2.0.0' + ioredis: '>=5.0.0' + pg: '>=8.0.0' + redis: '>=5.0.0' + peerDependenciesMeta: + bullmq-otel: + optional: true + ioredis: + optional: true + pg: + optional: true + redis: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + cjs-module-lexer@2.3.0: + resolution: {integrity: sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + cron-parser@5.10.1: + resolution: {integrity: sha512-pKRrRagItwk9rGIStcUyMxFX34x56zoX3KDf9HJ4ttwkXIK1qxK63EvXvEDmlxI9AdPJ+Y7TEKRftRlW5eSS7A==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-module-lexer@3.0.3: + resolution: {integrity: sha512-yWGe1O/j+almnV4hOYrmZo8u58JdnmXEMneRQAYkWtqSiT86yoMIEKF4rpqEDj52tbYl3xceBD3DQIBwcsC9Pw==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + import-in-the-middle@3.5.1: + resolution: {integrity: sha512-mPKuL8bPQzecui2KK6Gb+M8JvJoHnhS1FeYGa22QopBmlevF5F0FE6ued/B5EgHDeIoMTONIpDWWFKUPOG0DBQ==} + engines: {node: '>=18'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.1.0: + resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + p-limit@7.3.3: + resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} + engines: {node: '>=20'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.16.1: + resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + postcss@8.5.30: + resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + pprof-format@2.3.2: + resolution: {integrity: sha512-fcMM+sgKMOveSurz7i0BMHfy1hHNnI9+VWjd4kdclqQjSTuiHWaP69DGwvkw3zcLkM/F76LhVIKvPyrxmp6iiw==} + + process-warning@5.1.1: + resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==} + + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + engines: {node: '>=12.0.0'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + + rollup@4.64.5: + resolution: {integrity: sha512-EAP5ZHcz2ijbgCWwPkpI7zh6bfsri2Ez5FtCy2dCp1k62duXzfG7DLGGh7RU2QQXWwavT+u4qpvDF/TWOiiGTw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.7: + resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + +snapshots: + + '@datadog/pprof@5.14.4': + dependencies: + node-gyp-build: 4.8.4 + pprof-format: 2.3.2 + source-map: 0.7.6 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@grpc/grpc-js@1.14.6': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.6 + yargs: 17.7.3 + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@js-sdsl/ordered-map@4.4.2': {} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@opentelemetry/api-logs@0.222.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/configuration@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + yaml: 2.9.1 + + '@opentelemetry/context-async-hooks@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-logs-otlp-grpc@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-http@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-metrics-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-metrics-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/instrumentation-http@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.70.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.3 + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.74.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sql-common': 0.42.0(@opentelemetry/api@1.9.1) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-runtime-node@0.35.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.32.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + import-in-the-middle: 3.5.1 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.6 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-b3@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/redis-common@0.38.3': {} + + '@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/configuration': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/semantic-conventions@1.43.0': {} + + '@opentelemetry/sql-common@0.42.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + + '@pinojs/redact@0.4.0': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + + '@pyroscope/nodejs@0.6.4': + dependencies: + '@datadog/pprof': 5.14.4 + debug: 4.4.3 + p-limit: 7.3.3 + pprof-format: 2.3.2 + regenerator-runtime: 0.14.1 + source-map: 0.7.6 + transitivePeerDependencies: + - supports-color + + '@rollup/rollup-android-arm-eabi@4.64.5': + optional: true + + '@rollup/rollup-android-arm64@4.64.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.64.5': + optional: true + + '@rollup/rollup-darwin-x64@4.64.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.64.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.64.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.64.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.64.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.64.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.64.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.64.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.64.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.64.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.64.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.64.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.64.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.64.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.64.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.64.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.64.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.64.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.64.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.64.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.64.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.64.5': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@types/pg-pool@2.0.7': + dependencies: + '@types/pg': 8.15.6 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 22.19.17 + pg-protocol: 1.16.1 + pg-types: 2.2.0 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.7(@types/node@22.19.17)(yaml@2.9.1))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.7(@types/node@22.19.17)(yaml@2.9.1) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + assertion-error@2.0.1: {} + + atomic-sleep@1.0.0: {} + + bullmq-otel@2.0.1(bullmq@6.3.8): + dependencies: + '@opentelemetry/api': 1.9.1 + bullmq: 6.3.8(bullmq-otel@2.0.1) + tslib: 2.8.1 + + bullmq@6.3.8(bullmq-otel@2.0.1): + dependencies: + cron-parser: 5.10.1 + msgpackr: 2.1.0 + node-abort-controller: 3.1.1 + semver: 7.8.5 + tslib: 2.8.1 + optionalDependencies: + bullmq-otel: 2.0.1(bullmq@6.3.8) + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + cjs-module-lexer@2.3.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + cron-parser@5.10.1: + dependencies: + luxon: 3.7.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + detect-libc@2.1.2: + optional: true + + emoji-regex@8.0.0: {} + + es-module-lexer@1.7.0: {} + + es-module-lexer@3.0.3: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + forwarded-parse@2.1.2: {} + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + import-in-the-middle@3.5.1: + dependencies: + cjs-module-lexer: 2.3.0 + es-module-lexer: 3.0.3 + module-details-from-path: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + js-tokens@9.0.1: {} + + lodash.camelcase@4.3.0: {} + + long@5.3.2: {} + + loupe@3.2.1: {} + + luxon@3.7.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + module-details-from-path@1.0.4: {} + + ms@2.1.3: {} + + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.1.0: + optionalDependencies: + msgpackr-extract: 3.0.4 + + nanoid@3.3.20: {} + + node-abort-controller@3.1.1: {} + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + node-gyp-build@4.8.4: {} + + on-exit-leak-free@2.1.2: {} + + p-limit@7.3.3: + dependencies: + yocto-queue: 1.2.2 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + pg-int8@1.0.1: {} + + pg-protocol@1.16.1: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.1 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + postcss@8.5.30: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + pprof-format@2.3.2: {} + + process-warning@5.1.1: {} + + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 22.19.17 + long: 5.3.2 + + quick-format-unescaped@4.0.4: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + regenerator-runtime@0.14.1: {} + + require-directory@2.1.1: {} + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + + rollup@4.64.5: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.64.5 + '@rollup/rollup-android-arm64': 4.64.5 + '@rollup/rollup-darwin-arm64': 4.64.5 + '@rollup/rollup-darwin-x64': 4.64.5 + '@rollup/rollup-freebsd-arm64': 4.64.5 + '@rollup/rollup-freebsd-x64': 4.64.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.64.5 + '@rollup/rollup-linux-arm-musleabihf': 4.64.5 + '@rollup/rollup-linux-arm64-gnu': 4.64.5 + '@rollup/rollup-linux-arm64-musl': 4.64.5 + '@rollup/rollup-linux-loong64-gnu': 4.64.5 + '@rollup/rollup-linux-loong64-musl': 4.64.5 + '@rollup/rollup-linux-ppc64-gnu': 4.64.5 + '@rollup/rollup-linux-ppc64-musl': 4.64.5 + '@rollup/rollup-linux-riscv64-gnu': 4.64.5 + '@rollup/rollup-linux-riscv64-musl': 4.64.5 + '@rollup/rollup-linux-s390x-gnu': 4.64.5 + '@rollup/rollup-linux-x64-gnu': 4.64.5 + '@rollup/rollup-linux-x64-musl': 4.64.5 + '@rollup/rollup-openbsd-x64': 4.64.5 + '@rollup/rollup-openharmony-arm64': 4.64.5 + '@rollup/rollup-win32-arm64-msvc': 4.64.5 + '@rollup/rollup-win32-ia32-msvc': 4.64.5 + '@rollup/rollup-win32-x64-gnu': 4.64.5 + '@rollup/rollup-win32-x64-msvc': 4.64.5 + fsevents: 2.3.3 + + safe-stable-stringify@2.5.0: {} + + semver@7.8.5: {} + + siginfo@2.0.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.2: {} + + source-map@0.7.6: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + vite-node@3.2.4(@types/node@22.19.17)(yaml@2.9.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.7(@types/node@22.19.17)(yaml@2.9.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.7(@types/node@22.19.17)(yaml@2.9.1): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.30 + rollup: 4.64.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + yaml: 2.9.1 + + vitest@3.2.7(@types/node@22.19.17)(yaml@2.9.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.7(@types/node@22.19.17)(yaml@2.9.1)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.7(@types/node@22.19.17)(yaml@2.9.1) + vite-node: 3.2.4(@types/node@22.19.17)(yaml@2.9.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yaml@2.9.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@1.2.2: {} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..5bbd1ff --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/__tests__/**"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b9c823a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2c3932e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + include: ["src/**/__tests__/**/*.test.ts"], + }, +}); From 417ff6d90100e15056aab7fce2f3c48caa06dc6e Mon Sep 17 00:00:00 2001 From: Victor Carvalho Date: Mon, 12 Oct 2026 22:25:09 -0300 Subject: [PATCH 02/12] feat: move Backbone's telemetry package with its tests Backbone's SDK boot, two-layer scrub's layer 1, OTLP logger and trace helpers move from packages/telemetry. Its constants become options: logScope, metricViews, instrumentations (Prisma leaves the default list), redactKeys and onError. PostHog error tracking and Backbone's metric attribute list stay in Backbone. url-credentials has its own browser-safe entry. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/bounded.test.ts | 27 ++ src/__tests__/config.test.ts | 46 +++ src/__tests__/fatal.test.ts | 91 ++++++ src/__tests__/http-route.test.ts | 43 +++ src/__tests__/log-errors.test.ts | 266 ++++++++++++++++ src/__tests__/log-format-args.test.ts | 67 ++++ src/__tests__/log-money-paths.test.ts | 46 +++ src/__tests__/log-otlp-helpers.ts | 42 +++ src/__tests__/log-otlp-safety.test.ts | 120 ++++++++ src/__tests__/log-otlp.test.ts | 162 ++++++++++ src/__tests__/log-redact.test.ts | 219 +++++++++++++ src/__tests__/log-safely.test.ts | 32 ++ src/__tests__/log-scrub-equivalence.test.ts | 218 +++++++++++++ src/__tests__/log-scrub-linear.test.ts | 94 ++++++ src/__tests__/log-scrub-userinfo.test.ts | 29 ++ src/__tests__/log-scrub.test.ts | 48 +++ src/__tests__/logger-first-arg.test.ts | 130 ++++++++ src/__tests__/logger.test.ts | 269 ++++++++++++++++ src/__tests__/metric-views.test.ts | 54 ++++ src/__tests__/options.test.ts | 97 ++++++ src/__tests__/otel-lockstep.test.ts | 51 ++++ src/__tests__/profiling-wiring.test.ts | 51 ++++ src/__tests__/profiling.test.ts | 94 ++++++ .../redacting-exporter-paths.test.ts | 158 ++++++++++ src/__tests__/redacting-exporter.test.ts | 287 ++++++++++++++++++ src/__tests__/scrub-contract.test.ts | 45 +++ src/__tests__/scrub-samples.ts | 86 ++++++ src/__tests__/sdk-boot-shutdown.test.ts | 65 ++++ src/__tests__/sdk-lazy.test.ts | 23 ++ src/__tests__/sdk-lifecycle.test.ts | 267 ++++++++++++++++ src/__tests__/sdk.test.ts | 78 +++++ src/__tests__/shared-log.test.ts | 129 ++++++++ src/__tests__/trace-context.test.ts | 151 +++++++++ src/__tests__/url-credentials-entry.test.ts | 51 ++++ src/__tests__/userinfo-cases.ts | 194 ++++++++++++ src/bounded.ts | 18 ++ src/bullmq.ts | 10 + src/config.ts | 33 ++ src/fatal.ts | 65 ++++ src/http-route.ts | 16 + src/index.ts | 10 + src/instrumentations.ts | 35 +++ src/log-errors.ts | 183 +++++++++++ src/log-fields.ts | 8 + src/log-message.ts | 54 ++++ src/log-otlp.ts | 136 +++++++++ src/log-redact.ts | 214 +++++++++++++ src/log-safely.ts | 31 ++ src/log-scrub.ts | 91 ++++++ src/log-serializer.ts | 18 ++ src/log-sink.ts | 32 ++ src/logger.ts | 111 +++++++ src/meter.ts | 11 + src/metric-views.ts | 16 + src/options.ts | 65 ++++ src/profiling.ts | 61 ++++ src/redact.ts | 139 +++++++++ src/redacting-exporter.ts | 61 ++++ src/scrub-contract.json | 128 ++++++++ src/scrub-descriptor.ts | 24 ++ src/sdk-boot.ts | 147 +++++++++ src/sdk.ts | 109 +++++++ src/shared-log.ts | 51 ++++ src/testing.ts | 14 + src/trace-context.ts | 87 ++++++ src/url-credentials.ts | 163 ++++++++++ src/url-path.ts | 65 ++++ 67 files changed, 6036 insertions(+) create mode 100644 src/__tests__/bounded.test.ts create mode 100644 src/__tests__/config.test.ts create mode 100644 src/__tests__/fatal.test.ts create mode 100644 src/__tests__/http-route.test.ts create mode 100644 src/__tests__/log-errors.test.ts create mode 100644 src/__tests__/log-format-args.test.ts create mode 100644 src/__tests__/log-money-paths.test.ts create mode 100644 src/__tests__/log-otlp-helpers.ts create mode 100644 src/__tests__/log-otlp-safety.test.ts create mode 100644 src/__tests__/log-otlp.test.ts create mode 100644 src/__tests__/log-redact.test.ts create mode 100644 src/__tests__/log-safely.test.ts create mode 100644 src/__tests__/log-scrub-equivalence.test.ts create mode 100644 src/__tests__/log-scrub-linear.test.ts create mode 100644 src/__tests__/log-scrub-userinfo.test.ts create mode 100644 src/__tests__/log-scrub.test.ts create mode 100644 src/__tests__/logger-first-arg.test.ts create mode 100644 src/__tests__/logger.test.ts create mode 100644 src/__tests__/metric-views.test.ts create mode 100644 src/__tests__/options.test.ts create mode 100644 src/__tests__/otel-lockstep.test.ts create mode 100644 src/__tests__/profiling-wiring.test.ts create mode 100644 src/__tests__/profiling.test.ts create mode 100644 src/__tests__/redacting-exporter-paths.test.ts create mode 100644 src/__tests__/redacting-exporter.test.ts create mode 100644 src/__tests__/scrub-contract.test.ts create mode 100644 src/__tests__/scrub-samples.ts create mode 100644 src/__tests__/sdk-boot-shutdown.test.ts create mode 100644 src/__tests__/sdk-lazy.test.ts create mode 100644 src/__tests__/sdk-lifecycle.test.ts create mode 100644 src/__tests__/sdk.test.ts create mode 100644 src/__tests__/shared-log.test.ts create mode 100644 src/__tests__/trace-context.test.ts create mode 100644 src/__tests__/url-credentials-entry.test.ts create mode 100644 src/__tests__/userinfo-cases.ts create mode 100644 src/bounded.ts create mode 100644 src/bullmq.ts create mode 100644 src/config.ts create mode 100644 src/fatal.ts create mode 100644 src/http-route.ts create mode 100644 src/index.ts create mode 100644 src/instrumentations.ts create mode 100644 src/log-errors.ts create mode 100644 src/log-fields.ts create mode 100644 src/log-message.ts create mode 100644 src/log-otlp.ts create mode 100644 src/log-redact.ts create mode 100644 src/log-safely.ts create mode 100644 src/log-scrub.ts create mode 100644 src/log-serializer.ts create mode 100644 src/log-sink.ts create mode 100644 src/logger.ts create mode 100644 src/meter.ts create mode 100644 src/metric-views.ts create mode 100644 src/options.ts create mode 100644 src/profiling.ts create mode 100644 src/redact.ts create mode 100644 src/redacting-exporter.ts create mode 100644 src/scrub-contract.json create mode 100644 src/scrub-descriptor.ts create mode 100644 src/sdk-boot.ts create mode 100644 src/sdk.ts create mode 100644 src/shared-log.ts create mode 100644 src/testing.ts create mode 100644 src/trace-context.ts create mode 100644 src/url-credentials.ts create mode 100644 src/url-path.ts diff --git a/src/__tests__/bounded.test.ts b/src/__tests__/bounded.test.ts new file mode 100644 index 0000000..09fbcbb --- /dev/null +++ b/src/__tests__/bounded.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { boundedLabel } from "../bounded.js"; + +const PROVIDERS = ["stripe", "stripe_v2", "postgrid", "resend", "happyco", "funnel"] as const; + +/** A metric label is a closed set: anything outside the allowlist collapses into one value. */ +describe("boundedLabel", () => { + it("passes an allowlisted value through", () => { + expect(boundedLabel("stripe", PROVIDERS)).toBe("stripe"); + expect(boundedLabel("funnel", new Set(PROVIDERS))).toBe("funnel"); + }); + + it.each([ + ["a path traversal", "/v1/webhooks/../../x"], + ["an empty string", ""], + ["a 10 000-character string", "s".repeat(10_000)], + ["a near miss in case", "Stripe"], + ["an id", "3f2c1a9e-5b7d-4c11-9a0e-2d6f8b1c4e77"], + ])("maps %s to other", (_, value) => { + expect(boundedLabel(value, PROVIDERS)).toBe("other"); + }); + + it("maps a non-string to other", () => { + expect(boundedLabel(undefined, PROVIDERS)).toBe("other"); + expect(boundedLabel(42, PROVIDERS)).toBe("other"); + }); +}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts new file mode 100644 index 0000000..c2757ec --- /dev/null +++ b/src/__tests__/config.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { resolveTelemetryConfig } from "../config.js"; + +describe("resolveTelemetryConfig", () => { + it("is disabled when no endpoint is configured", () => { + expect(resolveTelemetryConfig("api", {}).enabled).toBe(false); + expect(resolveTelemetryConfig("api", { OTEL_EXPORTER_OTLP_ENDPOINT: " " }).enabled).toBe( + false, + ); + }); + it("is disabled by the standard kill switch even with an endpoint", () => { + const env = { OTEL_EXPORTER_OTLP_ENDPOINT: "http://c:4318", OTEL_SDK_DISABLED: "TRUE" }; + expect(resolveTelemetryConfig("api", env).enabled).toBe(false); + }); + it("names the service, version and environment from Railway", () => { + const config = resolveTelemetryConfig("api", { + OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.railway.internal:4318", + RAILWAY_GIT_COMMIT_SHA: "abc123", + RAILWAY_ENVIRONMENT_NAME: " cody-dev-env ", + RAILWAY_REPLICA_ID: " 7f3c-replica ", + }); + expect(config).toEqual({ + enabled: true, + serviceName: "api", + serviceVersion: "abc123", + environment: "cody-dev-env", + serviceInstanceId: "7f3c-replica", + }); + }); + it("prefers OTEL_SERVICE_NAME and falls back to unknown", () => { + const config = resolveTelemetryConfig("api", { OTEL_SERVICE_NAME: "api-canary" }); + expect(config.serviceName).toBe("api-canary"); + expect(config.serviceVersion).toBe("unknown"); + expect(resolveTelemetryConfig("api", { RAILWAY_DEPLOYMENT_ID: "dep-1" }).serviceVersion).toBe( + "dep-1", + ); + expect(config.environment).toBe("unknown"); + }); + it("falls back to one random instance id per process", () => { + const first = resolveTelemetryConfig("api", {}).serviceInstanceId; + expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(resolveTelemetryConfig("worker", { RAILWAY_REPLICA_ID: " " }).serviceInstanceId).toBe( + first, + ); + }); +}); diff --git a/src/__tests__/fatal.test.ts b/src/__tests__/fatal.test.ts new file mode 100644 index 0000000..8d666ce --- /dev/null +++ b/src/__tests__/fatal.test.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { installFatalHandlers } from "../fatal.js"; +import { log } from "../shared-log.js"; + +function setup( + flushTelemetry: (timeoutMs: number) => Promise, + report: (err: unknown) => void = () => undefined, +) { + const target = new EventEmitter(); + const order: string[] = []; + const exit = vi.fn((code: number) => void order.push(`exit ${code}`)); + installFatalHandlers({ + target: target as unknown as Pick, + stopIntake: () => void order.push("stop intake"), + report: (err) => { + order.push("report"); + report(err); + }, + flushTelemetry: (timeoutMs) => { + order.push(`flush telemetry ${timeoutMs}`); + return flushTelemetry(timeoutMs); + }, + exit, + }); + return { target, order, exit }; +} + +// A crashed worker must not pick up new jobs while it flushes, and a dead collector must not keep +// a crashed process alive past the flush budget. +describe("installFatalHandlers", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + process.exitCode = undefined; + }); + + it("logs, stops intake before any await, flushes, then exits 1", async () => { + const fatal = vi.spyOn(log, "fatal").mockImplementation(() => undefined); + const { target, order, exit } = setup(() => Promise.resolve()); + const err = new Error("boom"); + target.emit("uncaughtException", err); + expect(order).toEqual(["stop intake", "report"]); + expect(fatal).toHaveBeenCalledWith({ err }, "uncaught exception"); + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); + expect(order).toEqual(["stop intake", "report", "flush telemetry 2000", "exit 1"]); + expect(process.exitCode).toBe(1); + }); + + it("exits after two seconds when the flush never finishes", async () => { + vi.useFakeTimers(); + vi.spyOn(log, "fatal").mockImplementation(() => undefined); + vi.spyOn(log, "flush").mockImplementation(() => undefined); + const { target, exit } = setup(() => new Promise(() => undefined)); + target.emit("unhandledRejection", new Error("lost")); + await vi.advanceTimersByTimeAsync(1_999); + expect(exit).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("logs a second fatal but stops and exits only once", async () => { + const fatal = vi.spyOn(log, "fatal").mockImplementation(() => undefined); + const { target, order, exit } = setup(() => Promise.resolve()); + target.emit("uncaughtException", new Error("one")); + target.emit("unhandledRejection", "two"); + await vi.waitFor(() => expect(exit).toHaveBeenCalled()); + expect(fatal).toHaveBeenCalledTimes(2); + expect(order.filter((o) => o === "stop intake")).toHaveLength(1); + expect(order.filter((o) => o === "report")).toHaveLength(1); + expect(exit).toHaveBeenCalledTimes(1); + }); + + // WHY: the error report goes to PostHog before the flush that sends it; a reporter that throws + // must not keep a crashed process alive. + it("reports the error once and still exits when the reporter throws", async () => { + vi.spyOn(log, "fatal").mockImplementation(() => undefined); + const reported: unknown[] = []; + const err = new Error("boom"); + const { target, exit } = setup( + () => Promise.resolve(), + (e) => { + reported.push(e); + throw new Error("posthog down"); + }, + ); + target.emit("uncaughtException", err); + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); + expect(reported).toEqual([err]); + }); +}); diff --git a/src/__tests__/http-route.test.ts b/src/__tests__/http-route.test.ts new file mode 100644 index 0000000..795fde1 --- /dev/null +++ b/src/__tests__/http-route.test.ts @@ -0,0 +1,43 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { context, INVALID_SPAN_CONTEXT, ROOT_CONTEXT, trace } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { RPCType, setRPCMetadata, type RPCMetadata } from "@opentelemetry/core"; +import { setHttpRoute } from "../http-route.js"; +import { applyOptions, resetOptions } from "../options.js"; + +beforeAll(() => { + context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); + applyOptions({ serviceName: "api", logScope: "backbone" }); +}); +afterAll(() => { + context.disable(); + resetOptions(); +}); + +describe("setHttpRoute", () => { + it("records the route on the HTTP request metadata", () => { + const rpc: RPCMetadata = { + type: RPCType.HTTP, + span: trace.wrapSpanContext(INVALID_SPAN_CONTEXT), + }; + context.with(setRPCMetadata(ROOT_CONTEXT, rpc), () => setHttpRoute("/v1/leases/:id", "GET")); + expect(rpc.route).toBe("/v1/leases/:id"); + }); + + it("renames the active span and tags the request id", () => { + const span = { + updateName: vi.fn(), + setAttribute: vi.fn(), + spanContext: () => INVALID_SPAN_CONTEXT, + }; + context.with(trace.setSpan(ROOT_CONTEXT, span as never), () => + setHttpRoute("/v1/leases/:id", "GET", "req-1"), + ); + expect(span.updateName).toHaveBeenCalledWith("GET /v1/leases/:id"); + expect(span.setAttribute).toHaveBeenCalledWith("backbone.request_id", "req-1"); + }); + + it("does nothing without an active span", () => { + expect(() => setHttpRoute("/x", "GET")).not.toThrow(); + }); +}); diff --git a/src/__tests__/log-errors.test.ts b/src/__tests__/log-errors.test.ts new file mode 100644 index 0000000..bc7949e --- /dev/null +++ b/src/__tests__/log-errors.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from "vitest"; +import { createTestLogger } from "../testing.js"; + +const named = (name: string, message: string, extra: object = {}) => + Object.assign(new Error(message), { name }, extra); + +function logged(fields: Record): { line: string; err: Record } { + const { logger, records } = createTestLogger(); + logger.error(fields, "failed"); + return { line: JSON.stringify(records[0]), err: records[0]!.err as Record }; +} + +// An error's own properties are whatever a library attached: request bodies, commands, row values. +// Only the listed fields may leave, so a new library cannot leak by adding a property. +describe("deny-by-default error serialization", () => { + it("drops an ioredis ReplyError's command args", () => { + const err = named("ReplyError", "WRONGTYPE Operation against a key", { + command: { name: "set", args: ["bull:email:1", '{"to":"jane@example.com","jobId":"j-1"}'] }, + }); + const { line, err: out } = logged({ err }); + expect(line).not.toMatch(/jane@example\.com|bull:email:1/); + expect(out).toMatchObject({ type: "Error", name: "ReplyError", message: err.message }); + }); + + it("drops the input of a Node ERR_INVALID_URL", () => { + let err: unknown; + try { + new URL("https://jane:hunter2@exa mple.com/x"); + } catch (caught) { + err = caught; + } + const { line, err: out } = logged({ err }); + expect(line).not.toMatch(/hunter2|jane/); + expect(out).toMatchObject({ + type: "TypeError", + code: "ERR_INVALID_URL", + message: "Invalid URL", + }); + }); + + it("drops a pg DatabaseError's detail", () => { + const err = named("error", 'new row violates check constraint "chk_token"', { + code: "23514", + detail: "Failing row contains (t-1, Bearer SENTINEL-TOKEN-9, 2026-01-01).", + table: "api_tokens", + }); + const { line, err: out } = logged({ err }); + expect(line).not.toContain("SENTINEL-TOKEN-9"); + expect(out).toMatchObject({ code: "23514", message: err.message }); + expect(out).not.toHaveProperty("detail"); + }); + + it("keeps only type, name, message, stack and codes on an unknown error", () => { + const err = named("CustomError", "bad", { + code: "E1", + errorCode: "X", + status: 500, + statusCode: 502, + body: { first_name: "Jane" }, + response: { data: "r" }, + config: { headers: { Authorization: "Bearer SENTINEL-3" } }, + raw: "raw", + headers: { a: "b" }, + input: "i", + }); + const { err: out } = logged({ err }); + expect(Object.keys(out).sort()).toEqual( + ["code", "errorCode", "message", "name", "stack", "status", "statusCode", "type"].sort(), + ); + }); + + it("sanitizes every link of the cause chain the same way", () => { + const inner = named("ReplyError", "inner", { command: { args: ["SENTINEL-C"] }, code: "E2" }); + const err = new Error("outer", { cause: inner }); + const { line, err: out } = logged({ err }); + expect(line).not.toContain("SENTINEL-C"); + expect(out.cause).toMatchObject({ name: "ReplyError", message: "inner", code: "E2" }); + }); + + it("serializes AggregateError members", () => { + const err = new AggregateError( + [named("PrismaClientKnownRequestError", "x=jane@example.com", { code: "P2002" })], + "all failed", + ); + const { line, err: out } = logged({ err }); + expect(line).not.toContain("jane@example.com"); + expect(out.errors).toEqual([ + expect.objectContaining({ message: "PrismaClientKnownRequestError P2002" }), + ]); + }); +}); + +describe("error families", () => { + it("keeps value-free code for Prisma errors, including init errors' errorCode", () => { + const known = named("PrismaClientKnownRequestError", "Unique failed on jane@example.com", { + code: "P2002", + meta: { target: ["email"], cause: "jane@example.com" }, + }); + const init = named("PrismaClientInitializationError", "Can't reach jane@db.example.com", { + errorCode: "P1001", + }); + const a = logged({ err: known }); + const b = logged({ err: init }); + expect(a.line + b.line).not.toContain("jane@"); + expect(a.err).toMatchObject({ + message: "PrismaClientKnownRequestError P2002", + meta: { target: ["email"] }, + }); + expect(b.err).toMatchObject({ message: "PrismaClientInitializationError P1001" }); + }); + + it("keeps value-free code for DriverAdapterError and drops its cause", () => { + const err = named("DriverAdapterError", "dup key (ssn)=(123-45-6789)", { + cause: named("Error", "value 123-45-6789", { originalCode: "23505" }), + }); + const { line, err: out } = logged({ err }); + expect(line).not.toContain("123-45-6789"); + expect(out).toMatchObject({ message: "DriverAdapterError 23505" }); + expect(out).not.toHaveProperty("cause"); + }); + + it("drops ZodError issues and values", () => { + const err = named("ZodError", "received bob@secret.test", { + issues: [{ code: "invalid_string", received: "bob@secret.test" }], + }); + const { line, err: out } = logged({ err }); + expect(line).not.toContain("bob@secret.test"); + expect(out).toMatchObject({ message: "ZodError 1 issue(s)" }); + expect(out).not.toHaveProperty("issues"); + }); + + it("keeps only allowlisted fields of a Stripe error", () => { + class StripeCardError extends Error {} + const err = Object.assign(new StripeCardError("Your card was declined."), { + name: "StripeCardError", + type: "StripeCardError", + code: "card_declined", + decline_code: "insufficient_funds", + statusCode: 402, + requestId: "req_1", + raw: { payment_method: { billing_details: { name: "Jane Q. Resident" } } }, + headers: { "idempotency-key": "k" }, + }); + const { line, err: out } = logged({ err }); + expect(line).not.toMatch(/Jane Q\. Resident|idempotency-key/); + expect(out).toMatchObject({ + type: "StripeCardError", + code: "card_declined", + decline_code: "insufficient_funds", + statusCode: 402, + requestId: "req_1", + message: "Your card was declined.", + }); + }); + + it("drops the response body of a HappyCo error but keeps status and outcome", () => { + const err = named("HappyCoError", "HappyCo answered 500", { + status: 500, + outcome: "UNKNOWN", + body: { errors: [{ message: "tenant Jane at 42 Privet Drive" }] }, + }); + const { line, err: out } = logged({ err }); + expect(line).not.toMatch(/Jane|Privet/); + expect(out).toMatchObject({ status: 500, outcome: "UNKNOWN", message: "HappyCo answered 500" }); + }); +}); + +describe("error text scrubbing", () => { + it("scrubs SSN, email and long digit runs from a message and its stack header", () => { + const err = new Error("lookup failed for alice@example.com 123-45-6789 acct 000123456789"); + const { err: out } = logged({ err }); + expect(out.message).toBe("lookup failed for [redacted] [redacted] acct [redacted]"); + expect(out.stack).not.toMatch(/alice@example\.com|123-45-6789|000123456789/); + }); + + // Provider ids are prefixed or UUIDs, so the digit scrub must leave them readable. + it("leaves UUIDs and prefixed provider ids alone", () => { + const text = "pi_12345678901 for 123e4567-e89b-12d3-a456-426614174000 req_00000000"; + const { err: out } = logged({ err: new Error(text) }); + expect(out.message).toBe(text); + }); + + it("keeps stack frames intact, including a pnpm store path", () => { + const err = new Error("boom for jane@example.com"); + const frame = + " at run (/app/node_modules/.pnpm/@hono+node-server@1.19.5_hono@4.9.0/node_modules/x.js:1:2)"; + err.stack = `Error: boom for jane@example.com\n${frame}`; + const { err: out } = logged({ err }); + expect(out.stack).toBe(`Error: boom for [redacted]\n${frame}`); + }); + + it("keeps the original stack frames when a chain is copied", () => { + function thrownHere() { + return new Error("outer", { cause: new Error("inner") }); + } + const { err: out } = logged({ err: thrownHere() }); + expect(String(out.stack)).toContain("thrownHere"); + }); + + it("keeps the message of a plain Error", () => { + expect(logged({ err: new Error("queue is down") }).err).toMatchObject({ + message: "queue is down", + }); + }); +}); + +describe("error serializer robustness", () => { + it("does not throw on a frozen Prisma error", () => { + const err = Object.freeze( + named("PrismaClientKnownRequestError", "x=jane@example.com", { code: "P2002" }), + ); + expect(logged({ err }).line).not.toContain("jane@example.com"); + }); + + it("does not throw on a throwing cause getter", () => { + const err = new Error("boom"); + Object.defineProperty(err, "cause", { + get() { + throw new Error("nope"); + }, + }); + expect(logged({ err }).err).toMatchObject({ message: "boom" }); + }); + + it("serializes errors under `error` and `e` keys", () => { + const mk = () => + named("PrismaClientKnownRequestError", "x=jane@example.com", { code: "P2002" }); + const { logger, records } = createTestLogger(); + logger.error({ error: mk(), e: mk() }, "f"); + expect(JSON.stringify(records[0])).not.toContain("jane@example.com"); + expect(records[0]).toMatchObject({ error: { message: "PrismaClientKnownRequestError P2002" } }); + }); + + it("drops causes beyond the depth cap", () => { + let err: Error = named("PrismaClientKnownRequestError", "x=jane@example.com", { + code: "P2002", + }); + for (let i = 0; i < 15; i++) err = new Error(`level ${i}`, { cause: err }); + expect(logged({ err }).line).not.toContain("jane@example.com"); + }); + + it("scrubs the name and type of a non-Error err object", () => { + const { err, line } = logged({ err: { name: "jane@example.com", message: "m" } }); + expect(line).not.toContain("jane@example.com"); + expect(err).toMatchObject({ type: "[redacted]", name: "[redacted]" }); + }); + + it("marks a cause cycle and a self-referencing errors array as circular", () => { + const cyclic = new Error("loop"); + cyclic.cause = cyclic; + expect(logged({ err: cyclic }).err).toMatchObject({ message: "loop", cause: "[circular]" }); + + const aggregate = new AggregateError([], "many"); + (aggregate as { errors: unknown[] }).errors = [aggregate]; + expect(logged({ err: aggregate }).err).toMatchObject({ errors: ["[circular]"] }); + }); + + it("serializes a shared, non-cyclic error everywhere it appears", () => { + const shared = new Error("shared"); + const { err } = logged({ err: new AggregateError([shared, shared], "two") }); + expect(err.errors).toEqual([ + expect.objectContaining({ message: "shared" }), + expect.objectContaining({ message: "shared" }), + ]); + }); +}); diff --git a/src/__tests__/log-format-args.test.ts b/src/__tests__/log-format-args.test.ts new file mode 100644 index 0000000..e3e6c5f --- /dev/null +++ b/src/__tests__/log-format-args.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { createTestLogger } from "../testing.js"; + +// A rethrown pg error: its `detail` and message carry the row values that failed. +function pgError(): Error { + return Object.assign(new Error("duplicate key for jane@example.com"), { + code: "23505", + detail: "Key (email)=(jane@example.com) already exists PG-DETAIL", + table: "Tenant", + }); +} + +// Each value hides PII or provider text somewhere a format arg can carry it. +const CASES: [string, () => Record | unknown[]][] = [ + ["a string under err", () => ({ tenantId: "t-1", err: "jane@example.com raw" })], + [ + "an array of error-shaped objects under err", + () => ({ tenantId: "t-1", err: [{ detail: "ARR-DETAIL", message: "a" }] }), + ], + ["an error under cause", () => ({ tenantId: "t-1", cause: pgError() })], + ["an array holding an error", () => [{ tenantId: "t-1" }, pgError()]], + ["an email under a key that is not PII", () => ({ tenantId: "t-1", user: "jane@example.com" })], + ["a deep ssn key", () => ({ tenantId: "t-1", nested: { deep: { ssn: "123-45-6789" } } })], + [ + "an email in a URL path", + () => ({ tenantId: "t-1", url: new URL("https://x.io/residents/jane@example.com/statements") }), + ], + ["a mailto URL", () => ({ tenantId: "t-1", url: new URL("mailto:jane@example.com") })], +]; +const LEAK = /jane@example\.com|123-45-6789|PG-DETAIL|ARR-DETAIL|"detail"/; + +// Format args and the log object share one sanitizer, so the same value is safe in either place. +describe("format args are sanitized like the log object", () => { + it.each(["%j", "%o"])("leaks nothing through a %s format arg", (fmt) => { + for (const [, build] of CASES) { + const { logger, records } = createTestLogger(); + const info = logger.info.bind(logger) as (msg: string, ...args: unknown[]) => void; + info(`ctx ${fmt}`, build()); + const msg = String(records[0]!.msg); + expect(msg).not.toMatch(LEAK); + expect(msg).toContain("t-1"); + } + }); + + it.each(CASES.filter(([, build]) => !Array.isArray(build())))( + "leaks nothing through the log object: %s", + (_, build) => { + const { logger, records } = createTestLogger(); + logger.info(build() as Record, "ctx"); + expect(JSON.stringify(records[0])).not.toMatch(LEAK); + expect(records[0]).toMatchObject({ tenantId: "t-1", msg: "ctx" }); + }, + ); + + it("passes a primitive format arg through the same string scrub", () => { + const { logger, records } = createTestLogger(); + logger.info("ctx %s %d", "jane@example.com", 42); + expect(records[0]!.msg).toBe("ctx [redacted] 42"); + }); + + it("keeps an error's code and scrubbed message in a format arg", () => { + const { logger, records } = createTestLogger(); + logger.info("ctx %j", { err: pgError() }); + expect(String(records[0]!.msg)).toContain('"code":"23505"'); + expect(String(records[0]!.msg)).toContain('"message":"duplicate key for [redacted]"'); + }); +}); diff --git a/src/__tests__/log-money-paths.test.ts b/src/__tests__/log-money-paths.test.ts new file mode 100644 index 0000000..713c77f --- /dev/null +++ b/src/__tests__/log-money-paths.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { createTestLogger } from "../testing.js"; + +// The payment paths log record ids so a failure can be traced to its row, and they log provider +// errors whose payloads hold bank and card data. The ids must survive; the payloads must not. +describe("money-path log lines", () => { + it("keeps record ids and drops account numbers, PostGrid detail and Stripe raw", () => { + const { logger, records } = createTestLogger(); + const postgrid = Object.assign(new Error("Invalid bank account 000123456789"), { + name: "PostgridError", + status: 400, + body: { error: { message: "account 000123456789 routing 021000021 for Jane Resident" } }, + }); + const stripe = Object.assign(new Error("Your bank account was declined."), { + name: "StripeInvalidRequestError", + type: "StripeInvalidRequestError", + code: "bank_account_declined", + raw: { bank_account: { account_holder_name: "Jane Resident", last4: "6789" } }, + }); + logger.error( + { + event: "postgrid_registration_failed", + bankAccountId: "ba-1", + emailId: "em-1", + resendEmailId: "re-1", + paymentId: "pay-1", + accountNumber: "000123456789", + routingNumber: "021000021", + bankAccount: { accountNumber: "000123456789" }, + err: postgrid, + cause: stripe, + }, + "registration failed", + ); + const line = JSON.stringify(records[0]); + expect(line).not.toMatch(/000123456789|021000021|Jane Resident|6789/); + expect(records[0]).toMatchObject({ + bankAccountId: "ba-1", + emailId: "em-1", + resendEmailId: "re-1", + paymentId: "pay-1", + err: { type: "Error", name: "PostgridError", status: 400 }, + cause: { name: "StripeInvalidRequestError", code: "bank_account_declined" }, + }); + }); +}); diff --git a/src/__tests__/log-otlp-helpers.ts b/src/__tests__/log-otlp-helpers.ts new file mode 100644 index 0000000..aa773fc --- /dev/null +++ b/src/__tests__/log-otlp-helpers.ts @@ -0,0 +1,42 @@ +import { Writable } from "node:stream"; +import { + InMemoryLogRecordExporter, + LoggerProvider, + SimpleLogRecordProcessor, + type ReadableLogRecord, +} from "@opentelemetry/sdk-logs"; +import { setLogLineSink } from "../log-sink.js"; +import { logLineSink, ScopedLogRecordProcessor } from "../log-otlp.js"; + +/** Backbone's log scope, passed where the app supplies one. */ +export const LOG_SCOPE = "backbone"; + +export function capture(): { lines: string[]; stream: Writable } { + const lines: string[] = []; + const stream = new Writable({ + write(chunk: Buffer, _encoding, callback) { + lines.push(chunk.toString()); + callback(); + }, + }); + return { lines, stream }; +} + +/** Installs the real sink over an in-memory exporter behind the production scope filter. */ +export function exporting() { + const exporter = new InMemoryLogRecordExporter(); + const provider = new LoggerProvider({ + processors: [ + new ScopedLogRecordProcessor(new SimpleLogRecordProcessor({ exporter }), LOG_SCOPE), + ], + }); + setLogLineSink(logLineSink(provider.getLogger(LOG_SCOPE))); + return { + provider, + /** Records exported so far, once the sink's queued emissions have run. */ + records: async (): Promise => { + await Promise.resolve(); + return exporter.getFinishedLogRecords(); + }, + }; +} diff --git a/src/__tests__/log-otlp-safety.test.ts b/src/__tests__/log-otlp-safety.test.ts new file mode 100644 index 0000000..a8bf14b --- /dev/null +++ b/src/__tests__/log-otlp-safety.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + LoggerProvider, + type LogRecordProcessor, + type SdkLogRecord, +} from "@opentelemetry/sdk-logs"; +import { logLineSink, ScopedLogRecordProcessor } from "../log-otlp.js"; +import { createLogger } from "../logger.js"; +import { forwardLogLine, setLogLineSink } from "../log-sink.js"; +import { buildInstrumentations } from "../instrumentations.js"; +import { capture, exporting, LOG_SCOPE } from "./log-otlp-helpers.js"; + +afterEach(() => setLogLineSink(undefined)); + +describe("OTLP log export safety", () => { + // WHY: an exporter failure is logged as an otel_diag line; exporting that line would feed the + // failing exporter its own failures, once per batch, forever. + it("does not export OTel's own diagnostics", async () => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream); + logger.child({ component: "telemetry" }).error({ event: "otel_diag" }, "export failed"); + expect(await records()).toHaveLength(0); + }); + + // WHY: the boot and health lines tell an operator from Loki whether telemetry is running. + it("exports the telemetry package's other lines", async () => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream).child({ component: "telemetry" }); + logger.info({ event: "telemetry_started" }, "telemetry_started"); + logger.warn({ event: "telemetry_start_failed" }, "telemetry_start_failed"); + expect((await records()).map((r) => r.attributes.event)).toEqual([ + "telemetry_started", + "telemetry_start_failed", + ]); + }); + + // WHY: an instrumentation that emits its own records (a raw exception message and stack) must + // never reach the exporter around the scrubbing logger. + it("exports no record from a logger scope other than backbone", async () => { + const { provider, records } = exporting(); + provider.getLogger("@opentelemetry/instrumentation-runtime-node").emit({ + body: "uncaught jane@example.com", + attributes: { "exception.message": "jane@example.com" }, + }); + expect(await records()).toHaveLength(0); + }); + + it("keeps the runtime instrumentation's uncaught-exception capture off", () => { + const runtime = buildInstrumentations([]).find( + (i) => i.instrumentationName === "@opentelemetry/instrumentation-runtime-node", + ); + expect(runtime?.getConfig()).toMatchObject({ captureUncaughtException: false }); + }); + + // WHY: a sink that logs (an SDK diagnostic raised while exporting) must not recurse into itself. + it("does not re-enter the sink for a line logged while the sink runs", () => { + const seen: string[] = []; + const out = capture(); + const logger = createLogger(() => "api", out.stream); + setLogLineSink((line) => { + seen.push(line); + logger.warn("logged while exporting"); + }); + logger.info("outer"); + expect(seen).toHaveLength(1); + expect(seen[0]).toContain('"msg":"outer"'); + expect(out.lines).toHaveLength(2); + }); + + it("never throws into the caller when the sink throws, and still writes the line", () => { + setLogLineSink(() => { + throw new Error("exporter down"); + }); + const out = capture(); + const logger = createLogger(() => "api", out.stream); + expect(() => logger.info("still logged")).not.toThrow(); + expect(out.lines).toHaveLength(1); + }); + + it("returns the line unchanged whether or not a sink is installed", () => { + expect(forwardLogLine("x\n")).toBe("x\n"); + setLogLineSink(() => undefined); + expect(forwardLogLine("x\n")).toBe("x\n"); + }); + + // WHY: an SDK diagnostic raised while emitting a record must follow the line it is about. + it("emits the record after the line is written, never inside the log call", async () => { + const out = capture(); + const logger = createLogger(() => "api", out.stream); + let reported = false; + const noting: LogRecordProcessor = { + onEmit: () => { + if (reported) return; + reported = true; + logger.warn({ event: "sdk_note" }, "about the record"); + }, + forceFlush: () => Promise.resolve(), + shutdown: () => Promise.resolve(), + }; + const provider = new LoggerProvider({ processors: [noting] }); + setLogLineSink(logLineSink(provider.getLogger(LOG_SCOPE))); + logger.info("the line"); + expect(reported).toBe(false); + await Promise.resolve(); + expect(out.lines.map((l) => (JSON.parse(l) as { msg: string }).msg)).toEqual([ + "the line", + "about the record", + ]); + }); + + it("drops another scope's record in onEmit even when enabled() is bypassed", () => { + const inner = { onEmit: vi.fn(), forceFlush: vi.fn(), shutdown: vi.fn() }; + const scoped = new ScopedLogRecordProcessor(inner as unknown as LogRecordProcessor, LOG_SCOPE); + const record = (name: string) => ({ instrumentationScope: { name } }) as SdkLogRecord; + scoped.onEmit(record("@opentelemetry/instrumentation-runtime-node")); + expect(inner.onEmit).not.toHaveBeenCalled(); + scoped.onEmit(record("backbone")); + expect(inner.onEmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/log-otlp.test.ts b/src/__tests__/log-otlp.test.ts new file mode 100644 index 0000000..0308491 --- /dev/null +++ b/src/__tests__/log-otlp.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { context, ROOT_CONTEXT, trace, TraceFlags } from "@opentelemetry/api"; +import { SeverityNumber } from "@opentelemetry/api-logs"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { createLogger } from "../logger.js"; +import { hasLogLineSink, setLogLineSink } from "../log-sink.js"; +import { logLineSink, NO_MESSAGE } from "../log-otlp.js"; +import { capture, exporting, LOG_SCOPE } from "./log-otlp-helpers.js"; + +const PII = /jane@example\.com|123-45-6789|SENTINEL-PII|Jane|000123456789/; + +beforeAll(() => { + context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +}); + +afterEach(() => setLogLineSink(undefined)); + +describe("OTLP log export", () => { + // WHY: the exporter must see only what the scrubbing logger writes, never the raw call. + it("exports a record with no PII from the message, nested fields, error or query", async () => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream); + logger.error( + { + tenant: { firstName: "Jane", contact: { email: "jane@example.com" } }, + account: { accountNumber: "000123456789" }, + err: new Error("charge failed for jane@example.com ssn 123-45-6789"), + url: new URL("https://api.test/v1/tenants?search=SENTINEL-PII"), + path: "GET /v1/tenants?search=SENTINEL-PII", + }, + "lookup for jane@example.com 123-45-6789 at /v1/tenants?search=SENTINEL-PII", + ); + const [record] = await records(); + expect(record).toBeDefined(); + expect(JSON.stringify({ body: record!.body, attributes: record!.attributes })).not.toMatch(PII); + }); + + it("exports exactly the fields of the line the logger writes", async () => { + const { records } = exporting(); + const out = capture(); + const logger = createLogger(() => "worker", out.stream); + logger.child({ component: "jobs" }).warn({ jobId: "j-1", nested: { n: 1 } }, "job slow"); + const { + msg, + level, + time: _time, + ...rest + } = JSON.parse(out.lines[0]!) as Record; + const [record] = await records(); + expect(record!.body).toBe(msg); + expect(record!.severityText).toBe(level); + expect(record!.attributes).toEqual(rest); + }); + + it.each([ + ["trace", SeverityNumber.TRACE], + ["debug", SeverityNumber.DEBUG], + ["info", SeverityNumber.INFO], + ["warn", SeverityNumber.WARN], + ["error", SeverityNumber.ERROR], + ["fatal", SeverityNumber.FATAL], + ] as const)("maps pino level %s to its OTel severity", async (level, severity) => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream); + logger.level = "trace"; + logger[level]("x"); + const [record] = await records(); + expect(record!.severityNumber).toBe(severity); + expect(record!.severityText).toBe(level); + }); + + it("gives a level it does not know an unspecified severity, keeping its name", async () => { + const { records, provider } = exporting(); + logLineSink(provider.getLogger(LOG_SCOPE))('{"level":"notice","msg":"x"}'); + const [record] = await records(); + expect(record!.severityNumber).toBe(SeverityNumber.UNSPECIFIED); + expect(record!.severityText).toBe("notice"); + }); + + // WHY: a record must be placed at the moment the line was logged, not when it was exported. + it("takes the record time from the line's time field", async () => { + const { records, provider } = exporting(); + logLineSink(provider.getLogger(LOG_SCOPE))( + '{"level":"info","time":"2024-01-02T03:04:05.678Z","msg":"past"}', + ); + const [record] = await records(); + expect(record!.hrTime).toEqual([ + Math.floor(Date.parse("2024-01-02T03:04:05.678Z") / 1000), + 678e6, + ]); + }); + + it("uses event as the body of a line with no message, else a fixed marker", async () => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream); + logger.info({ event: "job_done" }); + logger.info({ jobId: "j-1" }); + const [withEvent, bare] = await records(); + expect(withEvent!.body).toBe("job_done"); + expect(bare!.body).toBe(NO_MESSAGE); + }); + + it("carries the active span's trace and span ids as the record context", async () => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream); + const spanContext = { + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + traceFlags: TraceFlags.SAMPLED, + }; + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => logger.info("inside")); + const [record] = await records(); + expect(record!.spanContext).toMatchObject(spanContext); + expect(record!.attributes).not.toHaveProperty("trace_id"); + expect(record!.attributes).not.toHaveProperty("span_id"); + }); + + // WHY: a caller's own `trace_id` (a Stripe payout's) must not become the record's trace. + it("keeps a caller's trace_id as a renamed attribute, never as the record context", async () => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream); + logger.info({ trace_id: "0af7651916cd43dd8448eb211c80319c", span_id: "b7ad6b7169203331" }); + const [record] = await records(); + expect(record!.spanContext).toBeUndefined(); + expect(record!.attributes).toMatchObject({ + "field.trace_id": "0af7651916cd43dd8448eb211c80319c", + "field.span_id": "b7ad6b7169203331", + }); + }); + + // WHY: the SDK keeps a record's first 128 attributes, and the logger writes `app` last. + it("keeps app, env, component and event on a 200-field line", async () => { + const { records } = exporting(); + const logger = createLogger(() => "api", capture().stream).child({ component: "jobs" }); + const wide: Record = {}; + for (let i = 0; i < 200; i++) wide[`f${i}`] = i; + logger.info({ ...wide, event: "wide_line" }, "wide"); + const [record] = await records(); + expect(Object.keys(record!.attributes)).toHaveLength(128); + expect(record!.attributes).toMatchObject({ + app: "api", + env: expect.any(String), + component: "jobs", + event: "wide_line", + }); + }); + + it("writes byte-for-byte the same stdout line with and without export", () => { + const app = () => "api"; + const plain = capture(); + createLogger(app, plain.stream).info({ a: 1 }, "same"); + exporting(); + const teed = capture(); + createLogger(app, teed.stream).info({ a: 1 }, "same"); + const strip = (line: string) => line.replace(/"time":"[^"]+"/, ""); + expect(strip(teed.lines[0]!)).toBe(strip(plain.lines[0]!)); + }); + + it("installs no sink until the SDK boots one", () => { + expect(hasLogLineSink()).toBe(false); + }); +}); diff --git a/src/__tests__/log-redact.test.ts b/src/__tests__/log-redact.test.ts new file mode 100644 index 0000000..66290f4 --- /dev/null +++ b/src/__tests__/log-redact.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import { createTestLogger } from "../testing.js"; +import { isSensitiveKey } from "../log-redact.js"; + +const VALUE = "SENTINEL-VALUE-42"; + +// Redaction is by key name, so the key table is the policy: these keys hold PII or credentials. +const REDACTED_KEYS = [ + ...[ + "ssnEnc", + "dobEnc", + "coApplicantSsnEnc", + "coApplicantDobEnc", + "ssn", + "social_security_number", + ], + ...["accountNumberEnc", "routingNumberEnc", "routing_number", "accountNumber", "bankAccount"], + ...[ + "taxIdEnc", + "taxId", + "tax_id", + "taxpayerId", + "ein", + "employerEin", + "employerEIN", + "ein_number", + ], + ...["signingSecretEnc", "claimToken", "postgridRegistrationToken", "apiKey", "x-api-key"], + ...["applicantEmail", "contactEmail", "callerEmail", "submittedByVendorEmail", "email"], + ...["contactPhone", "callerPhone", "accountHolderName", "date_of_birth", "dateOfBirth"], + ...["destinationAccountNumberEncSnapshot", "sourceAccountNumberEncSnapshot", "birthday"], + ...["set-cookie", "authorization", "x-clerk-auth", "stripe-signature", "svix-signature"], + ...["protectedPayloadEnc", "iban", "passport", "driversLicense", "abaNumber"], + // person names + ...["firstName", "lastName", "middleName", "fullName", "legalName", "preferredName"], + ...["first_name", "last_name", "middle_name", "full_name", "legal_name", "preferred_name"], + ...["contactName", "callerName", "occupantName", "payeeName", "billToName", "surname"], + // addresses + ...["streetAddress", "mailingAddress", "homeAddress", "street_address", "mailing_address"], + ...["address", "address1", "addressLine1", "address_line_1", "postalCode", "postal_code"], + ...["zip", "zipCode", "zip_code"], + ...["forwardingAddress", "forwarding_address", "propertyAddress", "ipAddress", "street"], + ...["street1", "street2", "line1", "line2", "Line1", "mobile", "mobilePhone"], + ...["nameOnAccount", "name_on_account"], +]; + +// These must stay readable: record ids, and names that are not a person's. +const KEPT_KEYS = [ + ...["tenantId", "leaseId", "applicationId", "emailId", "bankAccountId", "resendEmailId"], + ...["phoneIds", "token_id", "accountNumberId", "trace_id", "span_id"], + ...["name", "queueName", "jobName", "categoryName", "targetName", "propertyName", "fileName"], + ...["migrationName", "fromName", "vendorName", "businessName", "className", "bankName"], + ...["amount", "status", "route", "unitNumber", "totalAmount", "balance", "currency"], + ...["description", "protein", "reference", "audience", "ipAddressCount", "addressee"], + ...["forwardingAddressId", "streetId", "line1Id", "lineItems", "line3", "streetsCount"], + ...["addressCount", "lineNumber", "mobileId", "accountName", "nameOnAccountId"], +]; + +describe("key-pattern redaction", () => { + it.each(REDACTED_KEYS)("redacts %s at depth 1 and depth 4", (key) => { + expect(isSensitiveKey(key)).toBe(true); + const { logger, records } = createTestLogger(); + logger.info({ [key]: VALUE, a: { b: { c: { [key]: VALUE } } } }, "m"); + expect(JSON.stringify(records[0])).not.toContain(VALUE); + }); + + it.each(KEPT_KEYS)("keeps %s", (key) => { + expect(isSensitiveKey(key)).toBe(false); + const { logger, records } = createTestLogger(); + logger.info({ nested: { [key]: VALUE } }, "m"); + expect(records[0]).toMatchObject({ nested: { [key]: VALUE } }); + }); + + it("redacts inside arrays of objects", () => { + const { logger, records } = createTestLogger(); + logger.info({ rows: [{ ssn: VALUE }, { nested: [{ contactEmail: VALUE }] }] }, "m"); + expect(JSON.stringify(records[0])).not.toContain(VALUE); + }); + + it("handles cycles and leaves the caller's object untouched", () => { + const { logger, records } = createTestLogger(); + const input: Record = { ssn: VALUE, tenantId: "t-1" }; + input.self = input; + logger.info(input, "m"); + expect(JSON.stringify(records[0])).not.toContain(VALUE); + expect(input.ssn).toBe(VALUE); + expect(input.self).toBe(input); + }); + + it("fails closed when a getter throws", () => { + const { logger, records } = createTestLogger(); + const bad = { + get boom(): string { + throw new Error("no"); + }, + }; + expect(() => logger.info({ bad }, "m")).not.toThrow(); + expect(records[0]).toMatchObject({ log_redaction_failed: true }); + }); +}); + +describe("walker bounds", () => { + it("bounds breadth", () => { + const { logger, records } = createTestLogger(); + const wide = Object.fromEntries(Array.from({ length: 5000 }, (_, i) => [`k${i}`, i])); + logger.info({ items: Array.from({ length: 5000 }, (_, i) => i), wide }, "m"); + const line = JSON.stringify(records[0]); + expect(line.length).toBeLessThan(20_000); + expect(line).toContain("[truncated]"); + }); + + // Depth and breadth caps alone still allow 1,000^8 nodes; the per-line budget stops that. + it("caps the total number of values walked per line", () => { + const { logger, records } = createTestLogger(); + const row = Object.fromEntries(Array.from({ length: 900 }, (_, i) => [`c${i}`, { v: i }])); + logger.info({ rows: Array.from({ length: 900 }, () => row) }, "m"); + const line = JSON.stringify(records[0]); + expect(line).toContain("[truncated]"); + expect(line.length).toBeLessThan(200_000); + }); + + // Strings count toward the budget too: wide arrays of short strings must not scrub a million. + it("counts every value, strings included, and checks the budget per array item", () => { + const { logger, records } = createTestLogger(); + const rows = Array.from({ length: 1000 }, () => Array.from({ length: 1000 }, () => "s")); + const started = performance.now(); + logger.info({ rows }, "m"); + const elapsed = performance.now() - started; + expect(JSON.stringify(records[0])).toContain("[truncated]"); + expect(elapsed).toBeLessThan(200); + }); +}); + +describe("object keys", () => { + // A key is logged like a value: an email or SSN used as a map key must not leak. + it("redacts a key with an email or SSN shape and keeps colliding keys apart", () => { + const { logger, records } = createTestLogger(); + logger.info({ counts: { ["jane@example.com"]: 2, ["123-45-6789"]: 3, ok: 1 } }, "m"); + expect(JSON.stringify(records[0])).not.toMatch(/jane@example\.com|123-45-6789/); + const counts = records[0]!.counts as Record; + expect(Object.values(counts).sort()).toEqual([1, 2, 3]); + expect(counts).toMatchObject({ "[redacted]": 2, ok: 1 }); + }); + + // A caller key that looks like a marker must not overwrite, or be overwritten by, a real value. + it("keeps every value when a literal marker key is also present", () => { + const { logger, records } = createTestLogger(); + const counts = { "jane@example.com": 2, "bob@example.com": 3, "[redacted]": 9 }; + logger.info({ counts, more: { "[redacted]#1": 7, "a@b.co": 8, "[truncated]": 6 } }, "m"); + const out = records[0]!.counts as Record; + expect(Object.values(out).sort()).toEqual([2, 3, 9]); + expect(out["[redacted]"]).toBe(9); + expect(Object.values(records[0]!.more as object).sort()).toEqual([6, 7, 8]); + }); + + it("keeps a literal [truncated] key apart from the breadth marker", () => { + const { logger, records } = createTestLogger(); + const wide: Record = { "[truncated]": "real" }; + for (let i = 0; i < 1500; i++) wide[`k${i}`] = i; + logger.info({ wide }, "m"); + const out = records[0]!.wide as Record; + expect(out["[truncated]"]).toBe("real"); + expect(Object.values(out)).toContain(true); + }); +}); + +describe("value conversion", () => { + it("strips the query and userinfo of a URL value", () => { + const { logger, records } = createTestLogger(); + logger.info({ target: new URL("https://jane:pw@api.example.com/v1/x?token=SENTINEL-Q") }, "m"); + expect(records[0]).toMatchObject({ target: "https://api.example.com/v1/x" }); + }); + + it("scrubs email shapes from a URL value", () => { + const { logger, records } = createTestLogger(); + logger.info( + { + page: new URL("https://x.io/residents/jane@example.com/statements"), + mail: new URL("mailto:jane@example.com"), + }, + "m", + ); + expect(records[0]).toMatchObject({ + page: "https://x.io/residents/[redacted]/statements", + mail: "mailto:[redacted]", + }); + }); + + it("logs a Buffer as its length", () => { + const { logger, records } = createTestLogger(); + logger.info({ body: Buffer.from("first_name=Jane") }, "m"); + expect(records[0]).toMatchObject({ body: { bytes: 15 } }); + }); + + it("walks the result of toJSON and keeps Date and Decimal-like values", () => { + const when = new Date("2026-01-01T00:00:00Z"); + const money = { toJSON: () => "12.3400" }; + const payload = { toJSON: () => ({ contactEmail: VALUE, status: "ok" }) }; + const { logger, records } = createTestLogger(); + logger.info({ when, money, payload }, "m"); + expect(JSON.stringify(records[0])).not.toContain(VALUE); + expect(records[0]).toMatchObject({ + when: when.toISOString(), + money: "12.3400", + payload: { status: "ok" }, + }); + }); + + it("walks class instances", () => { + class Applicant { + ssn = VALUE; + name = "n"; + } + const { logger, records } = createTestLogger(); + logger.info({ a: new Applicant() }, "m"); + expect(JSON.stringify(records[0])).not.toContain(VALUE); + expect(records[0]).toMatchObject({ a: { name: "n" } }); + }); +}); diff --git a/src/__tests__/log-safely.test.ts b/src/__tests__/log-safely.test.ts new file mode 100644 index 0000000..83bc494 --- /dev/null +++ b/src/__tests__/log-safely.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; +import { diagLogger, log } from "../log-safely.js"; + +// OTel's diagnostics carry exporter errors and URLs; they must go through the redacting logger +// rather than straight to the console. +describe("diagLogger", () => { + it("routes each diag level to the telemetry logger, keeping only an Error argument", () => { + const warn = vi.spyOn(log, "warn").mockImplementation(() => undefined); + const trace = vi.spyOn(log, "trace").mockImplementation(() => undefined); + try { + const err = new Error("export failed"); + diagLogger.warn("exporter failed", { url: "https://x?key=k" }, err); + diagLogger.verbose("detail", "extra"); + expect(warn).toHaveBeenCalledWith({ event: "otel_diag", err }, "exporter failed"); + expect(trace).toHaveBeenCalledWith({ event: "otel_diag" }, "detail"); + } finally { + warn.mockRestore(); + trace.mockRestore(); + } + }); + + it("never throws when the logger does", () => { + const error = vi.spyOn(log, "error").mockImplementation(() => { + throw new Error("stream gone"); + }); + try { + expect(() => diagLogger.error("boom")).not.toThrow(); + } finally { + error.mockRestore(); + } + }); +}); diff --git a/src/__tests__/log-scrub-equivalence.test.ts b/src/__tests__/log-scrub-equivalence.test.ts new file mode 100644 index 0000000..db7fce2 --- /dev/null +++ b/src/__tests__/log-scrub-equivalence.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "vitest"; +import { scrubErrorText, scrubMessage, scrubStack } from "../log-scrub.js"; +import { stripQuery } from "../redact.js"; +import { samples } from "./scrub-samples.js"; + +// The scrub written plainly: correct, but quadratic on long runs. Short inputs keep it fast here. +const REFERENCE = { + ssn: /\b\d{3}-\d{2}-\d{4}\b/g, + email: /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/g, + digits: /(?"; +const isSpace = (char: string) => /\s/.test(char); + +/** The end of the scheme-character run at `i`, when it holds a letter; else -1. */ +function schemeRun(token: string, i: number): number { + let run = i; + while (run < token.length && SCHEME_CHAR.test(token[run]!)) run++; + return /[a-z]/i.test(token.slice(i, run)) ? run : -1; +} + +/** The first URL at or after `from`, as [start, authority start]. */ +function nextUrlStart(token: string, from: number): [number, number] | undefined { + for (let i = from; i < token.length; i++) { + const before = token[i - 1]; + if (i === 0 || !SCHEME_CHAR.test(before!)) { + const run = schemeRun(token, i); + if (run !== -1 && token.startsWith("://", run)) return [i, run + 3]; + if (run !== -1 && token.startsWith(":\\/\\/", run)) return [i, run + 5]; + } + if (i === 0 || !NOT_A_DELIMITER.test(before!)) { + const run = schemeRun(token, i); + if (run !== -1 && token.startsWith(":/", run) && token[run + 2] !== "/") return [i, run + 2]; + if (token.startsWith("//", i)) return [i, i + 2]; + } + } + return undefined; +} + +/** A quote at the token's end or before whitespace or a JSON or list delimiter. */ +function isQuoteEnd(token: string, i: number): boolean { + if (token[i] !== '"' && token[i] !== "'") return false; + return i + 1 === token.length || /[\s,;:}\])>"'&]/.test(token[i + 1]!); +} + +function firstIndex(token: string, from: number, ends: (i: number) => boolean): number { + let i = from; + while (i < token.length && !ends(i)) i++; + return i; +} + +/** A bracketed host, or a non-empty host with no `:` or bracket, then nothing or `:` and digits. */ +function isHostAndPort(head: string): boolean { + if (head.startsWith("[")) { + const close = head.indexOf("]"); + return close !== -1 && /^(:\d*)?$/.test(head.slice(close + 1)); + } + const colon = head.indexOf(":"); + const host = colon === -1 ? head : head.slice(0, colon); + const port = colon === -1 ? "" : head.slice(colon + 1); + return host !== "" && !host.includes("[") && !host.includes("]") && /^\d*$/.test(port); +} + +/** The userinfo's length at the start of a URL's authority text. */ +function userinfoLength(url: string): number { + const at = url.indexOf("@"); + if (at === -1) return 0; + const delimiter = url.search(/[/?#\\]/); + if (delimiter !== -1 && delimiter < at) { + const head = url.slice(0, delimiter); + if (head.includes(":") || head.startsWith("[")) { + const evidence = + head.startsWith("[") || + url[delimiter] === "/" || + (url[delimiter] === "\\" + ? url[delimiter + 1] === "/" + : /[=/]/.test(url.slice(delimiter, at))); + if (evidence && isHostAndPort(head)) return 0; + } else if (url.slice(0, at).includes("/")) { + return 0; + } + } + const after = url.slice(at + 1).search(/[/?#\\]/); + return url.lastIndexOf("@", after === -1 ? url.length - 1 : at + after) + 1; +} + +function referenceUrlToken(token: string): string { + let next = nextUrlStart(token, 0); + if (!next) return token; + let out = token.slice(0, next[0]); + while (next) { + const [start, authority] = next; + const userinfoEnd = firstIndex( + token, + authority, + (i) => + HARD_END.includes(token[i]!) || + (isQuoteEnd(token, i) && !(token[i] === "'" && ")'".includes(token[i + 1] ?? "-"))), + ); + const host = authority + userinfoLength(token.slice(authority, userinfoEnd)); + const end = firstIndex( + token, + host, + (i) => isSpace(token[i]!) || HARD_END.includes(token[i]!) || isQuoteEnd(token, i), + ); + let following = nextUrlStart(token, host); + const url = token.slice(host, following ? Math.min(following[0], end) : end); + const cut = url.search(/[?#;]/); + out += token.slice(start, authority); + let resume = host; + if (cut !== -1) { + out += url.slice(0, cut); + resume = firstIndex(token, host + cut, (i) => isSpace(token[i]!)); + if (following && following[0] < resume) following = nextUrlStart(token, resume); + } + out += token.slice(resume, following ? following[0] : token.length); + next = following; + } + return out; +} + +/** A URL value: userinfo after a leading `scheme:` chain and its slashes, then the query. */ +function referenceStripQuery(value: string): string { + let i = 0; + while (i < value.length && isSpace(value[i]!)) i++; + let authority = -1; + if (value.startsWith("//", i)) { + authority = i + 2; + } else { + let schemes = 0; + for (let m = /^[0-9+.-]*[a-z][a-z0-9+.-]*:/i.exec(value.slice(i)); m; schemes++) { + i += m[0].length; + m = /^[0-9+.-]*[a-z][a-z0-9+.-]*:/i.exec(value.slice(i)); + } + const slashes = ["//", "/", "\\/\\/"].find((lead) => value.startsWith(lead, i)); + if (schemes > 0 && slashes) authority = i + slashes.length; + } + const url = + authority === -1 + ? value + : value.slice(0, authority) + value.slice(authority + userinfoLength(value.slice(authority))); + const cut = url.search(/[?#;]/); + return cut === -1 ? url : url.slice(0, cut); +} + +function referenceMessage(text: string): string { + return text + .replace(/[^ \n\r]+/g, referenceUrlToken) + .replace(REFERENCE.path, "$1$2") + .replace(REFERENCE.ssn, "[redacted]") + .replace(REFERENCE.email, "[redacted]"); +} + +function referenceStack(stack: string): string { + const match = REFERENCE.frame.exec(stack); + const frames = match ? stack.slice(match.index) : ""; + const head = match ? stack.slice(0, match.index) : stack; + return `${referenceMessage(head).replace(REFERENCE.digits, "[redacted]")}${frames}`; +} + +// WHY: the linear rewrite must redact exactly what the original patterns redacted, no less. +describe("linear scrub matches the original patterns", () => { + it("on 20,000 generated strings", () => { + for (const input of samples(20_000)) { + expect(scrubMessage(input), JSON.stringify(input)).toBe(referenceMessage(input)); + expect(scrubErrorText(input), JSON.stringify(input)).toBe( + referenceMessage(input).replace(REFERENCE.digits, "[redacted]"), + ); + expect(scrubStack(input), JSON.stringify(input)).toBe(referenceStack(input)); + expect(stripQuery(input), JSON.stringify(input)).toBe(referenceStripQuery(input)); + } + }); + + it.each([ + "a@b.com.x@c.com", + "a@b.com@c.com", + "x?y=http://h?z", + "1http://x?q", + "123://x?q http://y?z", + "jane@example.com+bob@example.org", + "foo\n\n at bar", + "x \n at y", + "x\nat jane@example.com", + "x\n at jane@example.com", + "x \t\n\n at jane@x.io 12345678", + "postgres://u:pw1@pw2.ab@db:5432/x", + "postgres://u:p?ss@db/x#y", + "postgres://u:p/ss@db/x", + "https://host:8080/@handle", + 'x="//u:p@h/x?q",y=http://a:b@c', + "a://b://c@d?e//f:g@h", + '{"u":"https://h","e":"a@b.io"}', + "https://ghp_ab#c@github.com/x", + "http://[::1]:80/p?u=a@b.com", + "https://h:/p?u=a@b", + "postgres://u:p,//x@db/x", + "postgres://u:ab://c@db/x", + " jdbc:pg://u:p@h/x?q", + "postgres://u:it's@db/x", + '{"d":"pg://u:p\\"x@db/x"}', + "postgres://admin:2024#S@db/x", + "https://h/x;j=S", + "https:/u:p@h/x", + "pg:\\/\\/u:p@db\\/x", + "pg://u:p\tq@db/x", + "pg://app:\\abc/def@db/x", + "pg://app:c')%22'5@db/x", + ])("on %j", (input) => { + expect(scrubMessage(input)).toBe(referenceMessage(input)); + expect(scrubStack(input)).toBe(referenceStack(input)); + expect(stripQuery(input)).toBe(referenceStripQuery(input)); + }); +}); diff --git a/src/__tests__/log-scrub-linear.test.ts b/src/__tests__/log-scrub-linear.test.ts new file mode 100644 index 0000000..e05f0cb --- /dev/null +++ b/src/__tests__/log-scrub-linear.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { scrubErrorText, scrubMessage, scrubStack } from "../log-scrub.js"; +import { stripQuery } from "../redact.js"; +import { createTestLogger } from "../testing.js"; + +const SIZE = 200_000; +/** Generous: a linear scrub of 200 KB takes a few milliseconds; a quadratic one takes minutes. */ +const BOUND_MS = 100; + +function repeat(unit: string, size = SIZE): string { + return unit.repeat(Math.ceil(size / unit.length)).slice(0, size); +} + +const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/** Long runs that hold no complete email, URL, SSN or stack frame, each a backtracking trap. */ +const ADVERSARIAL: Record = { + letters: repeat("a"), + dotted: repeat("a."), + base64: Array.from({ length: SIZE }, (_, i) => BASE64[(i * 7919) % 64]).join(""), + "email-ish without @": repeat("a.b-c_d%e+"), + "repeated a@": repeat("a@"), + "repeated a@a.": repeat("a@a."), + "domain without a TLD": `x@${repeat("a.a1")}`, + "http-ish": repeat("http"), + "scheme colons": repeat("http:"), + "repeated a://": repeat("a://"), + "digit-letter": repeat("1a"), + digits: repeat("1"), + "slash path": repeat("/a"), + "spaced paths": repeat(" /a"), + newlines: repeat("\n"), + "newline-space": repeat("\n "), + spaces: repeat(" "), + "hyphenated runs": repeat("a-1."), + "many @, // and ://": repeat('u:p@//a://b@:1/?@"//'), + "userinfo with no @": `x://u:${repeat("p/")}`, + "port-like digits": `x://u:${repeat("1")}`, + "@ runs after a delimiter": `x://u:p/${repeat("@a")}`, + 'dense @ // :// : [ "': repeat('[u:p@//a:"b://[:1@"//:[@,//x?#'), + "nested // with no @": `x://${repeat("u:p,//")}`, + "heads before one far @": `x://${repeat(",//a:1/,//b?")}@h`, + "quotes, backslashes and tabs": repeat(`x://u:"p'\\/\\/@\t:1#=;a:/`), + "soft spaces before one far @": `x://${repeat("u:p\t\u00a0")}@h`, +}; + +const MILLION = 1_000_000; +const DENSE_MILLION = '[u:p@//a:"b://[:1@"//:[@,//x?#\'\\/\t;h:/' + .repeat(Math.ceil(MILLION / 20)) + .slice(0, MILLION); + +/** The fastest of three runs: a GC pause or a busy CI worker slows one run, never all three. */ +function bestOfThree(fn: () => unknown): number { + let best = Infinity; + for (let i = 0; i < 3; i++) { + const start = performance.now(); + fn(); + best = Math.min(best, performance.now() - start); + } + return best; +} + +// WHY: the scrubbers run on every log line, with telemetry on or off. A regex that rescans a long +// run from every start position blocks the event loop for seconds on one large value. +describe("scrubbing stays linear on long strings", () => { + const scrubbers = { + scrubMessage, + scrubErrorText, + scrubStack: (s: string) => scrubStack(s), + stripQuery, + }; + for (const [name, scrub] of Object.entries(scrubbers)) { + it.each(Object.entries(ADVERSARIAL))(`${name}: %s`, (_, input) => { + expect(bestOfThree(() => scrub(input))).toBeLessThan(BOUND_MS); + }); + } + + it("scrubs a 1M-character dense input in linear time", () => { + for (const scrub of Object.values(scrubbers)) { + expect(bestOfThree(() => scrub(DENSE_MILLION))).toBeLessThan(5 * BOUND_MS); + } + }); + + it("logs a 200 KB message, field and Error without stalling", () => { + const { logger, records } = createTestLogger(); + const big = ADVERSARIAL.letters!; + expect(bestOfThree(() => logger.info(big))).toBeLessThan(5 * BOUND_MS); + expect(bestOfThree(() => logger.info({ detail: big }, "x"))).toBeLessThan(5 * BOUND_MS); + expect(bestOfThree(() => logger.error({ err: new Error(big) }, "x"))).toBeLessThan( + 5 * BOUND_MS, + ); + expect(records).toHaveLength(9); + }); +}); diff --git a/src/__tests__/log-scrub-userinfo.test.ts b/src/__tests__/log-scrub-userinfo.test.ts new file mode 100644 index 0000000..d716c2f --- /dev/null +++ b/src/__tests__/log-scrub-userinfo.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { scrubMessage } from "../log-scrub.js"; +import { scrubAttributes, scrubSpanName, stripQuery } from "../redact.js"; +import { + MESSAGE_USERINFO_CASES, + SPAN_NAME_CASES, + URL_ATTRIBUTE_CASES, + URL_USERINFO_CASES, +} from "./userinfo-cases.js"; + +// WHY: a DSN password often holds an unencoded `@`, `?`, `#` or `/`; a scrub that stops early +// exports the rest of it. +describe("URL userinfo", () => { + it.each(MESSAGE_USERINFO_CASES)("scrubMessage(%j) is %j", (input, expected) => { + expect(scrubMessage(input)).toBe(expected); + }); + + it.each(URL_USERINFO_CASES)("stripQuery(%j) is %j", (input, expected) => { + expect(stripQuery(input)).toBe(expected); + }); + + it.each(SPAN_NAME_CASES)("scrubSpanName(%j) is %j", (input, expected) => { + expect(scrubSpanName(input, undefined)).toBe(expected); + }); + + it.each(URL_ATTRIBUTE_CASES)("scrubAttributes %s %j is %j", (key, value, expected) => { + expect(scrubAttributes({ [key]: value })[key]).toBe(expected); + }); +}); diff --git a/src/__tests__/log-scrub.test.ts b/src/__tests__/log-scrub.test.ts new file mode 100644 index 0000000..f895d0a --- /dev/null +++ b/src/__tests__/log-scrub.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { scrubErrorText, scrubMessage, scrubStack } from "../log-scrub.js"; + +describe("scrubMessage", () => { + it("replaces SSN and email shapes", () => { + expect(scrubMessage("jane@example.com 123-45-6789")).toBe("[redacted] [redacted]"); + }); + + // WHY: a failed fetch quotes its URL, and the query is what staff searched for. + it("strips the query and fragment from a URL inside a message", () => { + expect(scrubMessage("fetch https://api.test/v1/tenants?search=Jane%20Doe#x failed")).toBe( + "fetch https://api.test/v1/tenants failed", + ); + }); + + it("strips the query from an absolute path inside a message", () => { + expect(scrubMessage("GET /v1/tenants?search=Jane%20Doe returned 500")).toBe( + "GET /v1/tenants returned 500", + ); + }); + + // WHY: a connection error quotes its DSN, credentials included. + it("strips the userinfo from a URL inside a message, whatever the scheme's case", () => { + expect(scrubMessage("connect postgres://app:s3cret@db:5432/x failed")).toBe( + "connect postgres://db:5432/x failed", + ); + expect(scrubMessage("GET HTTPS://user:pass@host/p?q=1 failed")).toBe( + "GET HTTPS://host/p failed", + ); + expect(scrubMessage("fetch https://host/p failed")).toBe("fetch https://host/p failed"); + }); + + it("leaves an email or a scheme-less user@host to the other rules", () => { + expect(scrubMessage("sent to a@b.com")).toBe("sent to [redacted]"); + expect(scrubMessage("ssh deploy@buildbox failed")).toBe("ssh deploy@buildbox failed"); + }); + + it("strips URL userinfo from error text and a stack's header", () => { + expect(scrubErrorText("redis://:pw@cache:6379 refused")).toBe("redis://cache:6379 refused"); + expect(scrubStack("Error: amqp://u:p@mq/v\n at f (x.js:1:1)")).toBe( + "Error: amqp://mq/v\n at f (x.js:1:1)", + ); + }); + + it("leaves a question mark that is not part of a URL", () => { + expect(scrubMessage("why? because")).toBe("why? because"); + }); +}); diff --git a/src/__tests__/logger-first-arg.test.ts b/src/__tests__/logger-first-arg.test.ts new file mode 100644 index 0000000..95ca507 --- /dev/null +++ b/src/__tests__/logger-first-arg.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { createTestLogger } from "../testing.js"; + +// The first argument is merged with the logger's own fields; it must reach the redactor in the +// shape JSON would give it, or a toJSON that hides a secret is bypassed. +describe("first-argument object", () => { + it("honours a class's toJSON, so the fields it hides stay hidden", () => { + class Credentials { + privateKey = "SENTINEL-PK"; + keyId = "k-1"; + toJSON() { + return { keyId: this.keyId }; + } + } + const { logger, records } = createTestLogger(); + logger.info(new Credentials(), "loaded"); + expect(JSON.stringify(records[0])).not.toContain("SENTINEL-PK"); + expect(records[0]).not.toHaveProperty("privateKey"); + expect(records[0]).toMatchObject({ keyId: "k-1", msg: "loaded" }); + expect(records[0]).toHaveProperty("app"); + }); + + it("logs a Buffer as its length, not as indexed bytes", () => { + const { logger, records } = createTestLogger(); + logger.info(Buffer.from("abc"), "m"); + expect(records[0]).toMatchObject({ bytes: 3, msg: "m" }); + expect(records[0]).not.toHaveProperty("0"); + expect(records[0]).toHaveProperty("app"); + }); + + it("logs a URL under value as a scrubbed string without its query", () => { + const { logger, records } = createTestLogger(); + logger.info(new URL("https://h.test/a/jane@example.com?token=SENTINEL-Q"), "m"); + expect(JSON.stringify(records[0])).not.toMatch(/jane@example\.com|SENTINEL-Q/); + expect(records[0]).toMatchObject({ value: "https://h.test/a/[redacted]", msg: "m" }); + expect(records[0]).toHaveProperty("app"); + }); + + it("converts before reading msg, so a msg property cannot bypass toJSON", () => { + class Hide { + privateKey = "SENTINEL-PK"; + msg = "hello"; + toJSON() { + return { ok: 1 }; + } + } + const { logger, records } = createTestLogger(); + logger.info(new Hide(), "m"); + logger.info(new Hide()); + for (const record of records) expect(JSON.stringify(record)).not.toContain("SENTINEL-PK"); + }); + + it("derives msg from an error that toJSON returns", () => { + const { logger, records } = createTestLogger(); + logger.info({ toJSON: () => ({ err: new Error("") }) }); + expect(records[0]).toMatchObject({ msg: "Error" }); + }); + + it("logs arrays, dates, typed arrays and maps the way JSON would see them", () => { + const { logger, records } = createTestLogger(); + logger.info([1, "jane@example.com"], "a"); + logger.info(new Date(0), "d"); + logger.info(new Uint8Array([1, 2, 3]), "u"); + logger.info(new Map([["k", "v"]]), "m"); + expect(records[0]).toMatchObject({ value: [1, "[redacted]"] }); + expect(records[1]).toMatchObject({ value: "1970-01-01T00:00:00.000Z" }); + expect(records[2]).toMatchObject({ bytes: 3 }); + expect(records[2]).not.toHaveProperty("0"); + expect(records[3]).toMatchObject({ value: { k: "v" } }); + }); + + it("never throws from child() when the bindings' toJSON throws", () => { + const { logger, records } = createTestLogger(); + const bindings = { + toJSON() { + throw new Error("boom"); + }, + }; + expect(() => logger.child(bindings).info("c")).not.toThrow(); + expect(records[0]).toMatchObject({ log_redaction_failed: true, msg: "c" }); + }); + + it("follows a toJSON chain only one level, so an endless chain cannot recurse", () => { + class Chain { + toJSON(): unknown { + return new Chain(); + } + } + const { logger, records } = createTestLogger(); + expect(() => logger.info(new Chain(), "c")).not.toThrow(); + expect(() => logger.child(new Chain()).info("d")).not.toThrow(); + expect(records).toHaveLength(2); + }); + + it("keeps child bindings whose toJSON returns a date", () => { + const { logger, records } = createTestLogger(); + logger.child({ toJSON: () => new Date(0) } as object).info("c"); + expect(records[0]).toMatchObject({ value: "1970-01-01T00:00:00.000Z" }); + }); + + it("converts child bindings before renaming reserved keys", () => { + const { logger, records } = createTestLogger(); + logger.child({ toJSON: () => ({ level: "x", app: "spoof" }) } as object).info("c"); + expect(records[0]).toMatchObject({ "field.level": "x", "field.app": "spoof" }); + expect(records[0]!.level).not.toBe("x"); + }); +}); + +// An empty message would print `msg: ""`; the error type is the next most useful thing to search. +describe("an error with an empty message", () => { + class LockLost extends Error {} + + it("falls back to the error type for msg", () => { + for (const call of [ + (l: ReturnType["logger"]) => l.error(new LockLost("")), + (l: ReturnType["logger"]) => l.error({ err: new LockLost("") }), + (l: ReturnType["logger"]) => l.error({ e: new LockLost("") }), + ]) { + const t = createTestLogger(); + call(t.logger); + expect(t.records[0]!.msg).toBe("LockLost"); + } + }); + + it("keeps the caller's message when one is given", () => { + const { logger, records } = createTestLogger(); + logger.error({ err: new LockLost("") }, "lock lost"); + expect(records[0]!.msg).toBe("lock lost"); + }); +}); diff --git a/src/__tests__/logger.test.ts b/src/__tests__/logger.test.ts new file mode 100644 index 0000000..d0e6428 --- /dev/null +++ b/src/__tests__/logger.test.ts @@ -0,0 +1,269 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { context } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base"; +import { announceLogLevel, createLogger, resolveLogLevel } from "../logger.js"; +import { createTestLogger } from "../testing.js"; + +function capturing(): { lines: Record[]; write: (l: string) => void } { + const lines: Record[] = []; + return { lines, write: (l: string) => void lines.push(JSON.parse(l)) }; +} + +describe("thrown primitives", () => { + it("scrubs a string logged as err", () => { + const { logger, records } = createTestLogger(); + logger.error({ err: "jane@example.com 123-45-6789" }, "x"); + expect(JSON.stringify(records[0])).not.toMatch(/jane@example\.com|123-45-6789/); + }); +}); + +describe("createLogger", () => { + afterEach(() => { + context.disable(); + vi.unstubAllEnvs(); + }); + + it("redacts PII at the top level and in nested objects", () => { + const { logger, records } = createTestLogger(); + logger.info( + { + ssn: "123-45-6789", + tenant: { accountNumber: "000123456789", email: "jane@example.com" }, + application: { applicant: { phone: "555-0100" } }, + req: { headers: { authorization: "Bearer abc", cookie: "sid=1" } }, + tenantId: "t-1", + }, + "applied", + ); + expect(JSON.stringify(records[0])).not.toMatch( + /123-45-6789|000123456789|jane@example\.com|555-0100|Bearer abc|sid=1/, + ); + expect(records[0]).toMatchObject({ level: "info", msg: "applied", tenantId: "t-1" }); + }); + + it("stamps trace_id and span_id from the active span", () => { + context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); + const tracer = new BasicTracerProvider().getTracer("t"); + const { logger, records } = createTestLogger(); + tracer.startActiveSpan("op", (span) => { + logger.info("inside"); + span.end(); + expect(records[0]).toMatchObject({ + trace_id: span.spanContext().traceId, + span_id: span.spanContext().spanId, + }); + }); + logger.info("outside"); + expect(records[1]).not.toHaveProperty("trace_id"); + }); + + describe("children", () => { + it("redacts bindings and keeps chaining", () => { + const { logger, records } = createTestLogger(); + const child = logger.child({ tenantId: "t-1", applicantEmail: "a@b.co" }); + child.child({ requestId: "r-1", nested: { ssn: "SENTINEL-G" } }).info("x"); + expect(records[0]).toMatchObject({ tenantId: "t-1", requestId: "r-1" }); + expect(JSON.stringify(records[0])).not.toMatch(/SENTINEL-G|a@b\.co/); + }); + + it("inherits level and accumulates msgPrefix in grandchildren", () => { + const { logger, records } = createTestLogger(); + const a = logger.child({}, { level: "warn", msgPrefix: "[A] " }); + const b = a.child({}, { msgPrefix: "[B] " }); + b.info("dropped"); + b.warn("x"); + expect(records).toHaveLength(1); + expect(records[0]!.msg).toBe("[A] [B] x"); + }); + }); + + // Railway and the log search key on level, time, msg, app and env; a caller field of the same + // name would overwrite the logger's value when the line is parsed. + describe("reserved fields", () => { + it("renames a caller's level, time, msg, app and env", () => { + const { logger, records } = createTestLogger(); + logger.warn({ level: "L", time: "T", msg: "M", app: "A", env: "E", ok: 1 }, "real"); + expect(records[0]).toMatchObject({ + level: "warn", + msg: "real", + "field.level": "L", + "field.time": "T", + "field.msg": "M", + "field.app": "A", + "field.env": "E", + ok: 1, + }); + expect(records[0]!.time).not.toBe("T"); + expect(records[0]!.app).not.toBe("A"); + }); + + it("renames reserved child bindings", () => { + const { logger, records } = createTestLogger(); + logger.child({ level: "L", app: "A" }).info("x"); + expect(records[0]).toMatchObject({ level: "info", "field.level": "L", "field.app": "A" }); + expect(records[0]!.app).not.toBe("A"); + }); + }); + + describe("msg", () => { + const prismaErr = () => + Object.assign(new Error("Unique constraint failed: email=alice@example.com"), { + name: "PrismaClientKnownRequestError", + code: "P2002", + }); + + it("keeps raw error text out of msg when no message is given", () => { + for (const call of [ + (l: ReturnType["logger"]) => l.error(prismaErr()), + (l: ReturnType["logger"]) => l.error({ err: prismaErr() }), + (l: ReturnType["logger"]) => l.error({ error: prismaErr() }), + ]) { + const t = createTestLogger(); + call(t.logger); + expect(JSON.stringify(t.records[0])).not.toContain("alice@example.com"); + expect(t.records[0]!.msg).toBe("PrismaClientKnownRequestError P2002"); + } + }); + + it("scrubs the msg pino derives from a non-Error err object", () => { + const { logger, records } = createTestLogger(); + logger.error({ err: { message: "failed for jane@example.com", detail: "SENTINEL-D" } }); + expect(records[0]!.msg).toBe("failed for [redacted]"); + expect(JSON.stringify(records[0])).not.toMatch(/jane@example\.com|SENTINEL-D/); + }); + + it("scrubs a msg field", () => { + const { logger, records } = createTestLogger(); + logger.info({ msg: "sent to jane@example.com", ok: 1 }); + expect(records[0]).toMatchObject({ msg: "sent to [redacted]", ok: 1 }); + }); + + it("keeps a plain error's message as msg", () => { + const { logger, records } = createTestLogger(); + logger.error(new Error("queue is down")); + expect(records[0]!.msg).toBe("queue is down"); + }); + + it("scrubs SSN and email shapes from msg but not other digits", () => { + const { logger, records } = createTestLogger(); + logger.info("applicant 123-45-6789 mail bob@x.io amount 1234567890 id 000123456789"); + expect(records[0]!.msg).toBe( + "applicant [redacted] mail [redacted] amount 1234567890 id 000123456789", + ); + }); + + it("redacts object format args and serializes Errors in them", () => { + const { logger, records } = createTestLogger(); + const err = Object.assign(new Error("for jane@example.com"), { body: "SENTINEL-B" }); + logger.info("obj %j err %o", { email: "SENTINEL-F1", ok: 1 }, err); + expect(JSON.stringify(records[0])).not.toMatch(/SENTINEL-F1|SENTINEL-B|jane@example\.com/); + expect(records[0]!.msg).toContain('"ok":1'); + }); + + // A format arg takes the log object's sanitizer, so an error under err/error/e is serialized. + it.each(["%j", "%o"])("serializes err, error and e inside a %s format arg", (fmt) => { + for (const key of ["err", "error", "e"]) { + const err = Object.assign(new Error("m"), { + body: "jane@example.com", + command: { args: ["SECRET_ARG"] }, + }); + const { logger, records } = createTestLogger(); + if (fmt === "%j") logger.info("ctx %j", { [key]: err }); + else logger.info("ctx %o", { [key]: err }); + expect(JSON.stringify(records[0])).not.toMatch(/jane@example\.com|SECRET_ARG/); + } + // An error-shaped plain object (a rethrown pg error, a provider reply) is serialized too. + const errorLike = { + name: "X", + message: "m to jane@example.com", + body: "jane@example.com", + detail: "SENT-DETAIL", + }; + const { logger, records } = createTestLogger(); + if (fmt === "%j") logger.info("ctx %j", { err: errorLike }); + else logger.info("ctx %o", { err: errorLike }); + const msg = String(records[0]!.msg); + expect(msg).not.toMatch(/jane@example\.com|SENT-DETAIL|"body"|"detail"/); + expect(msg).toContain('"message":"m to [redacted]"'); + }); + + it("scrubs a msg field that is renamed field.msg", () => { + const { logger, records } = createTestLogger(); + logger.error({ err: new Error("boom"), msg: "user jane.doe@example.com" }); + logger.info({ msg: "user jane.doe@example.com" }, "real message"); + expect(records[0]).toMatchObject({ msg: "boom", "field.msg": "user [redacted]" }); + expect(records[1]).toMatchObject({ msg: "real message", "field.msg": "user [redacted]" }); + }); + + it("serializes and scrubs Errors at any depth", () => { + const { logger, records } = createTestLogger(); + const nested = Object.assign(new Error("for jane@example.com"), { command: "SENTINEL-N" }); + logger.error({ wrapped: { cause: nested }, list: [nested] }, "f"); + const line = JSON.stringify(records[0]); + expect(line).not.toMatch(/jane@example\.com|SENTINEL-N/); + expect(records[0]).toMatchObject({ wrapped: { cause: { message: "for [redacted]" } } }); + }); + }); + + describe("caller input", () => { + // A caller reuses its context object across calls; the logger's own fields must not leak in. + it("leaves the caller's object unchanged", () => { + const { logger } = createTestLogger(); + const ctx = { tenantId: "t-1" }; + const reserved = { tenantId: "t-1", level: "x" }; + logger.info(ctx, "m"); + logger.info(reserved, "m"); + expect(ctx).toEqual({ tenantId: "t-1" }); + expect(reserved).toEqual({ tenantId: "t-1", level: "x" }); + }); + + // If the arguments cannot be inspected, nothing raw reaches msg: a format arg could carry PII. + it("logs a placeholder with no format args when the arguments cannot be inspected", () => { + const { logger, records } = createTestLogger(); + const hostile = { + get msg(): string { + throw new Error("no"); + }, + }; + const info = logger.info.bind(logger) as (o: object, m: string, ...a: unknown[]) => void; + expect(() => info(hostile, "for %s", "jane@example.com")).not.toThrow(); + expect(records[0]!.msg).toBe("[unloggable]"); + expect(JSON.stringify(records[0])).not.toContain("jane@example.com"); + }); + }); + + describe("LOG_LEVEL", () => { + it("accepts any case and rejects silent, which would hide fatal lines", () => { + expect(resolveLogLevel("DEBUG")).toEqual({ level: "debug" }); + expect(resolveLogLevel(" Warn ")).toEqual({ level: "warn" }); + expect(resolveLogLevel("silent")).toEqual({ level: "info", invalid: "silent" }); + expect(resolveLogLevel("bogus")).toEqual({ level: "info", invalid: "bogus" }); + expect(resolveLogLevel(undefined)).toEqual({ level: "info" }); + }); + + // The warning waits for the process name, so it is searchable by app like every other line. + it("holds the invalid-level warning until announced, then emits it once", () => { + vi.stubEnv("LOG_LEVEL", "bogus"); + const { lines, write } = capturing(); + let app = "unknown"; + const logger = createLogger(() => app, { write }); + expect(lines).toHaveLength(0); + app = "worker"; + announceLogLevel(logger); + announceLogLevel(logger); + logger.debug("hidden"); + logger.info("shown"); + expect(lines.map((l) => l.msg)).toEqual(["invalid LOG_LEVEL, using info", "shown"]); + expect(lines[0]).toMatchObject({ level: "warn", requested: "bogus", app: "worker" }); + }); + + it("emits the held warning before the first line when never announced", () => { + vi.stubEnv("LOG_LEVEL", "silent"); + const { lines, write } = capturing(); + const logger = createLogger(() => "t", { write }); + logger.child({ component: "c" }).info("first"); + expect(lines.map((l) => l.msg)).toEqual(["invalid LOG_LEVEL, using info", "first"]); + }); + }); +}); diff --git a/src/__tests__/metric-views.test.ts b/src/__tests__/metric-views.test.ts new file mode 100644 index 0000000..6c9b2f0 --- /dev/null +++ b/src/__tests__/metric-views.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + AggregationTemporality, + InMemoryMetricExporter, + MeterProvider, + PeriodicExportingMetricReader, +} from "@opentelemetry/sdk-metrics"; +import { metricViews } from "../metric-views.js"; +import type { MetricViewOptions } from "../options.js"; + +const APP_VIEWS: MetricViewOptions = { + instrumentPrefix: "app.", + allowedAttributes: ["queue", "outcome"], +}; + +async function exportedAttributes( + instrument: string, + attributes: Record, + options: MetricViewOptions | undefined = APP_VIEWS, +) { + const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); + const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 }); + const provider = new MeterProvider({ readers: [reader], views: metricViews(options) }); + provider.getMeter("app-worker").createCounter(instrument).add(1, attributes); + await reader.forceFlush(); + const metric = exporter + .getMetrics() + .flatMap((r) => r.scopeMetrics.flatMap((s) => s.metrics)) + .find((m) => m.descriptor.name === instrument); + await provider.shutdown(); + return metric?.dataPoints[0]?.attributes; +} + +// WHY: a metric label is a time series per value. An identifier or a message on an app +// instrument would explode cardinality and put personal data in Prometheus, which keeps it. +describe("metricViews", () => { + it("exports only allowlisted labels from the app's prefixed instruments", async () => { + const attributes = await exportedAttributes("app.worker.job.duration", { + queue: "late-fees", + outcome: "failed", + tenantId: "tenant-123", + "error.message": "duplicate email jane@example.com", + }); + expect(attributes).toEqual({ queue: "late-fees", outcome: "failed" }); + }); + + it("leaves instruments outside the prefix to their own instrumentation", async () => { + const attributes = await exportedAttributes("http.server.request.duration", { + "http.route": "/v1/tenants", + "server.address": "api", + }); + expect(attributes).toEqual({ "http.route": "/v1/tenants", "server.address": "api" }); + }); +}); diff --git a/src/__tests__/options.test.ts b/src/__tests__/options.test.ts new file mode 100644 index 0000000..22b2156 --- /dev/null +++ b/src/__tests__/options.test.ts @@ -0,0 +1,97 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { InstrumentationBase } from "@opentelemetry/instrumentation"; +import { installFatalHandlers } from "../fatal.js"; +import { buildInstrumentations } from "../instrumentations.js"; +import { isSensitiveKey, resetSensitiveKeys } from "../log-redact.js"; +import { resetOptions } from "../options.js"; +import { shutdownTelemetry, startTelemetry } from "../sdk.js"; +import { log } from "../shared-log.js"; +import { createTestLogger } from "../testing.js"; + +class AppInstrumentation extends InstrumentationBase { + constructor() { + super("app-instrumentation", "1.0.0", {}); + } + protected init() { + return []; + } +} + +afterEach(async () => { + await shutdownTelemetry(100); + resetOptions(); + resetSensitiveKeys(); + vi.restoreAllMocks(); +}); + +describe("redactKeys", () => { + // WHY: an app logs its own PII fields under names the default patterns cannot know. + it("redacts an app key in any letter case on top of the defaults", async () => { + await startTelemetry({ serviceName: "api", env: {}, redactKeys: ["unitLabel"] }); + const { logger, records } = createTestLogger(); + logger.info({ unitLabel: "4B", UNITLABEL: "4C", ssn: "123-45-6789", queue: "q" }, "x"); + expect(records[0]).toMatchObject({ + unitLabel: "[redacted]", + UNITLABEL: "[redacted]", + ssn: "[redacted]", + queue: "q", + }); + }); + + it("never removes a default key", async () => { + await startTelemetry({ serviceName: "api", env: {}, redactKeys: ["unitLabel"] }); + expect(isSensitiveKey("email")).toBe(true); + expect(isSensitiveKey("taxId")).toBe(true); + }); +}); + +describe("instrumentations", () => { + it("appends the app's instrumentations to the package's own list", () => { + const app = new AppInstrumentation(); + const names = buildInstrumentations([], [app]).map((i) => i.instrumentationName); + expect(names).toEqual([ + "@opentelemetry/instrumentation-runtime-node", + "@opentelemetry/instrumentation-http", + "@opentelemetry/instrumentation-undici", + "@opentelemetry/instrumentation-ioredis", + "@opentelemetry/instrumentation-pg", + "app-instrumentation", + ]); + }); +}); + +describe("onError", () => { + function crash(): Error { + const target = new EventEmitter(); + vi.spyOn(log, "fatal").mockImplementation(() => undefined); + installFatalHandlers({ + target: target as unknown as Pick, + exit: () => undefined, + }); + const err = new Error("boom"); + target.emit("uncaughtException", err); + process.exitCode = undefined; + return err; + } + + // WHY: the app's error tracker (PostHog in Backbone) must see a crash even though the package + // knows nothing about it. + it("receives a fatal error, with the service name, when no report is given", async () => { + const onError = vi.fn(); + await startTelemetry({ serviceName: "api", env: {}, onError }); + const err = crash(); + expect(onError).toHaveBeenCalledWith(err, { service: "api" }); + }); + + it("never breaks the fatal path when it throws", async () => { + await startTelemetry({ + serviceName: "api", + env: {}, + onError: () => { + throw new Error("tracker down"); + }, + }); + expect(() => crash()).not.toThrow(); + }); +}); diff --git a/src/__tests__/otel-lockstep.test.ts b/src/__tests__/otel-lockstep.test.ts new file mode 100644 index 0000000..a07fa92 --- /dev/null +++ b/src/__tests__/otel-lockstep.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +type Manifest = Partial>>; + +const manifest = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), +) as Manifest; +const pins = Object.fromEntries( + Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }).filter(([name]) => + name.startsWith("@opentelemetry/"), + ), +); + +/** Released together from opentelemetry-js: the experimental (0.x) line. */ +const EXPERIMENTAL = [ + "@opentelemetry/api-logs", + "@opentelemetry/exporter-logs-otlp-proto", + "@opentelemetry/exporter-metrics-otlp-proto", + "@opentelemetry/exporter-trace-otlp-proto", + "@opentelemetry/instrumentation", + "@opentelemetry/instrumentation-http", + "@opentelemetry/sdk-logs", + "@opentelemetry/sdk-node", +]; +/** Released together from opentelemetry-js: the stable SDK line. */ +const STABLE = [ + "@opentelemetry/context-async-hooks", + "@opentelemetry/core", + "@opentelemetry/resources", + "@opentelemetry/sdk-metrics", + "@opentelemetry/sdk-trace-base", +]; + +// WHY: OTel packages share global state through @opentelemetry/api. Two versions of one package +// in an app's tree register two providers, and spans or metrics from one silently never export. +describe("OpenTelemetry pins", () => { + it("are exact", () => { + for (const [name, version] of Object.entries(pins)) { + expect(version, name).toMatch(/^\d+\.\d+\.\d+$/); + } + }); + + it.each([ + ["experimental", EXPERIMENTAL], + ["stable", STABLE], + ])("share one version across the %s family", (_family, names) => { + for (const name of names) expect(pins[name], name).toBeDefined(); + expect(new Set(names.map((name) => pins[name])).size).toBe(1); + }); +}); diff --git a/src/__tests__/profiling-wiring.test.ts b/src/__tests__/profiling-wiring.test.ts new file mode 100644 index 0000000..f312932 --- /dev/null +++ b/src/__tests__/profiling-wiring.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const init = vi.fn(); +const start = vi.fn(); +const stop = vi.fn<() => Promise>(async () => undefined); + +vi.mock("@pyroscope/nodejs", () => ({ default: { init, start, stop } })); + +describe("profiling wiring in startTelemetry and shutdownTelemetry", () => { + beforeEach(() => { + vi.clearAllMocks(); + stop.mockImplementation(async () => undefined); + }); + afterEach(async () => { + const { shutdownTelemetry } = await import("../sdk.js"); + await shutdownTelemetry(100); + }); + + it("profiles with only a Pyroscope address, without enabling OTLP export", async () => { + const { startTelemetry } = await import("../sdk.js"); + const config = await startTelemetry({ + serviceName: "api", + env: { PYROSCOPE_SERVER_ADDRESS: "http://p:4040" }, + }); + expect(config.enabled).toBe(false); + expect(init).toHaveBeenCalledOnce(); + expect(start).toHaveBeenCalledOnce(); + }); + + it("stops the profiler on shutdown", async () => { + const { startTelemetry, shutdownTelemetry } = await import("../sdk.js"); + await startTelemetry({ + serviceName: "api", + env: { PYROSCOPE_SERVER_ADDRESS: "http://p:4040" }, + }); + await shutdownTelemetry(1_000); + expect(stop).toHaveBeenCalledOnce(); + }); + + it("does not let a profiler stop that never resolves stall shutdown", async () => { + stop.mockImplementation(() => new Promise(() => undefined)); + const { startTelemetry, shutdownTelemetry } = await import("../sdk.js"); + await startTelemetry({ + serviceName: "api", + env: { PYROSCOPE_SERVER_ADDRESS: "http://p:4040" }, + }); + const began = Date.now(); + await expect(shutdownTelemetry(200)).resolves.toBeUndefined(); + expect(Date.now() - began).toBeLessThan(1_000); + }); +}); diff --git a/src/__tests__/profiling.test.ts b/src/__tests__/profiling.test.ts new file mode 100644 index 0000000..a2759f7 --- /dev/null +++ b/src/__tests__/profiling.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const init = vi.fn(); +const start = vi.fn(); +const stop = vi.fn(async () => undefined); +const loaded = vi.fn(); + +vi.mock("@pyroscope/nodejs", () => { + loaded(); + return { default: { init, start, stop } }; +}); + +const config = { + enabled: true, + serviceName: "api", + serviceVersion: "abc123", + serviceInstanceId: "replica-1", + environment: "cody-dev-env", +}; + +describe("startProfiling", () => { + beforeEach(async () => { + vi.clearAllMocks(); + await (await import("../profiling.js")).stopProfiling(); + }); + + it("returns false and loads nothing without an address", async () => { + const { startProfiling } = await import("../profiling.js"); + expect(await startProfiling(config, {})).toBe(false); + expect(loaded).not.toHaveBeenCalled(); + expect(init).not.toHaveBeenCalled(); + }); + + it("stays off under the kill switch even with an address", async () => { + const { startProfiling } = await import("../profiling.js"); + const env = { PYROSCOPE_SERVER_ADDRESS: "http://p:4040", OTEL_SDK_DISABLED: "true" }; + expect(await startProfiling(config, env)).toBe(false); + expect(init).not.toHaveBeenCalled(); + }); + + it("tags only env and version, so no request identifier reaches a profile", async () => { + const { startProfiling, stopProfiling } = await import("../profiling.js"); + expect(await startProfiling(config, { PYROSCOPE_SERVER_ADDRESS: "http://p:4040" })).toBe(true); + expect(init).toHaveBeenCalledWith({ + serverAddress: "http://p:4040", + appName: "api", + tags: { env: "cody-dev-env", version: "abc123" }, + // Literals, checked against @pyroscope/nodejs 0.6.4 dist/esm/profilers/pyroscope-profiler.js + // (the defaults are not exported): DEFAULT_FLUSH_DURATION_MS = 60000 (l.10), + // DEFAULT_SAMPLING_DURATION_MS = 1e3 * 60 (l.12), DEFAULT_SAMPLING_INTERVAL_MICROS = + // 1e6 / 100 Hz = 10000 (l.14), DEFAULT_SAMPLING_INTERVAL_BYTES = 1024 * 512 = 524288 (l.16), + // DEFAULT_STACK_DEPTH = 64 (l.17). + flushIntervalMs: 60_000, + wall: { samplingDurationMs: 60_000, samplingIntervalMicros: 10_000 }, + heap: { samplingIntervalBytes: 524_288, stackDepth: 64 }, + }); + expect(start).toHaveBeenCalledOnce(); + await stopProfiling(); + expect(stop).toHaveBeenCalledOnce(); + }); + + it("does not init twice on a second call", async () => { + const { startProfiling } = await import("../profiling.js"); + const env = { PYROSCOPE_SERVER_ADDRESS: "http://p:4040" }; + await startProfiling(config, env); + expect(await startProfiling(config, env)).toBe(true); + expect(init).toHaveBeenCalledOnce(); + }); + + it("logs profiling_start_failed when the profiler fails to start", async () => { + const { log } = await import("../log-safely.js"); + const warn = vi.spyOn(log, "warn").mockImplementation(() => undefined); + start.mockImplementationOnce(() => { + throw new Error("native binary missing"); + }); + const { startProfiling } = await import("../profiling.js"); + await startProfiling(config, { PYROSCOPE_SERVER_ADDRESS: "http://p:4040" }); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ event: "profiling_start_failed" }), + "profiling_start_failed", + ); + warn.mockRestore(); + }); + + it("returns false instead of throwing when the profiler fails to start", async () => { + start.mockImplementationOnce(() => { + throw new Error("native binary missing"); + }); + const { startProfiling } = await import("../profiling.js"); + await expect( + startProfiling(config, { PYROSCOPE_SERVER_ADDRESS: "http://p:4040" }), + ).resolves.toBe(false); + }); +}); diff --git a/src/__tests__/redacting-exporter-paths.test.ts b/src/__tests__/redacting-exporter-paths.test.ts new file mode 100644 index 0000000..4de259a --- /dev/null +++ b/src/__tests__/redacting-exporter-paths.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { SpanKind, type Attributes } from "@opentelemetry/api"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { RedactingSpanExporter } from "../redacting-exporter.js"; + +const TOKEN = "3f2b8c1e-9a4d-4e6f-b1a2-7c8d9e0f1a2b"; + +function exportSpan(kind: SpanKind, attributes: Attributes, name = "GET") { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan(name, { kind, attributes }); + span.addEvent("retry", { "url.full": `https://api.test/v1/applications/by-token/${TOKEN}` }); + span.end(); + return memory.getFinishedSpans()[0]!; +} + +// WHY: a bearer token in a URL path survives query stripping. The applicant's intake token +// (`/apply/` on web, `/v1/applications/by-token/` on the api) opens and submits +// the application for anyone who reads it out of a trace. +describe("RedactingSpanExporter — token-bearing paths", () => { + it("replaces the path in every URL attribute with the server span's route template", () => { + const span = exportSpan(SpanKind.SERVER, { + "http.route": "/v1/applications/by-token/:token", + "url.full": `https://api.test/v1/applications/by-token/${TOKEN}?x=1`, + "url.original": `/v1/applications/by-token/${TOKEN}`, + "url.path": `/v1/applications/by-token/${TOKEN}`, + "http.target": `/v1/applications/by-token/${TOKEN}?x=1`, + "http.url": `http://api.test:4000/v1/applications/by-token/${TOKEN}`, + }); + + expect(JSON.stringify([span.attributes, span.events])).not.toContain(TOKEN); + expect(span.attributes).toMatchObject({ + "url.full": "https://api.test/v1/applications/by-token/:token", + "url.original": "/v1/applications/by-token/:token", + "url.path": "/v1/applications/by-token/:token", + "http.target": "/v1/applications/by-token/:token", + "http.url": "http://api.test:4000/v1/applications/by-token/:token", + }); + }); + + it("uses the web route template for a Next.js span", () => { + const span = exportSpan(SpanKind.SERVER, { + "http.route": "/apply/[token]/submitted", + "http.target": `/apply/${TOKEN}/submitted`, + }); + expect(span.attributes["http.target"]).toBe("/apply/[token]/submitted"); + }); + + it("templates a client span to a token route, which carries no http.route", () => { + const span = exportSpan(SpanKind.CLIENT, { + "url.full": `https://api.test/v1/applications/by-token/${TOKEN}/submit`, + "url.path": `/v1/applications/by-token/${TOKEN}/submit`, + }); + + expect(JSON.stringify(span.attributes)).not.toContain(TOKEN); + expect(span.attributes["url.full"]).toBe( + "https://api.test/v1/applications/by-token/:token/submit", + ); + expect(span.attributes["url.path"]).toBeUndefined(); + }); + + it("drops url.path from a client span, whose path no route template covers", () => { + const span = exportSpan(SpanKind.CLIENT, { + "url.full": "https://api.stripe.com/v1/customers/cus_123?expand=x", + "url.path": "/v1/customers/cus_123", + "network.peer.address": "10.0.0.7", + }); + expect(span.attributes["url.path"]).toBeUndefined(); + expect(span.attributes["network.peer.address"]).toBeUndefined(); + expect(span.attributes["url.full"]).toBe("https://api.stripe.com/v1/customers/cus_123"); + }); + + it("keeps no trace of a token in a server span's url.path once it has a route", () => { + const span = exportSpan(SpanKind.SERVER, { + "url.path": "/v1/applications/by-token/SECRET", + "http.route": "/v1/applications/by-token/:token", + }); + expect(JSON.stringify([span.name, span.attributes, span.events])).not.toContain("SECRET"); + expect(span.attributes["url.path"]).toBe("/v1/applications/by-token/:token"); + }); + + it("drops the peer address, which is the caller's IP on a server span", () => { + const span = exportSpan(SpanKind.SERVER, { + "http.route": "/v1/tenants", + "network.peer.address": "203.0.113.9", + "network.peer.port": 51234, + }); + expect(span.attributes["network.peer.address"]).toBeUndefined(); + expect(span.attributes["network.peer.port"]).toBe(51234); + }); + + it("templates a web page path on a span with no route", () => { + const span = exportSpan(SpanKind.SERVER, { "http.target": `/apply/${TOKEN}?search=Jane` }); + expect(span.attributes["http.target"]).toBe("/apply/[token]"); + }); + + it("drops an unmatched span's url.path and keeps url.full without its query", () => { + const span = exportSpan(SpanKind.SERVER, { + "http.route": "unmatched", + "url.full": "https://api.test/v1/leases/abc-123?search=Jane", + "url.path": "/v1/leases/abc-123", + }); + expect(span.attributes["url.full"]).toBe("https://api.test/v1/leases/abc-123"); + expect(span.attributes["url.path"]).toBeUndefined(); + }); + + it("leaves a path that only mentions the word apply elsewhere", () => { + const span = exportSpan(SpanKind.CLIENT, { + "url.full": "https://x.test/v1/leases/abc/apply/credit", + }); + expect(span.attributes["url.full"]).toBe("https://x.test/v1/leases/abc/apply/credit"); + }); +}); + +// WHY: Next.js names its root span `${method} ${req.url}` and renames it only when a route +// resolves. An unrouted request (a 404 under /apply/) exports the raw URL, query +// included, as the span name and as `next.span_name`. +describe("RedactingSpanExporter — span names", () => { + it.each([ + ["GET /apply/tok_SECRET/unknown?x=1", "GET /apply/[token]/unknown"], + [ + "GET https://api.test/v1/applications/by-token/tok_SECRET?x=1", + "GET https://api.test/v1/applications/by-token/:token", + ], + ["GET /v1/foo?search=Jane%20Doe", "GET /v1/foo"], + ["POST https://user:pass@api.test/v1/x#frag", "POST https://api.test/v1/x"], + ])("scrubs %s in the name and next.span_name", (name, expected) => { + const span = exportSpan(SpanKind.SERVER, { "next.span_name": name }, name); + + expect(span.name).toBe(expected); + expect(span.attributes["next.span_name"]).toBe(expected); + expect(JSON.stringify([span.name, span.attributes])).not.toMatch(/tok_SECRET|Jane|pass/); + }); + + it("uses the route template for the name when the span has one", () => { + const span = exportSpan( + SpanKind.SERVER, + { "http.route": "/apply/[token]" }, + "GET /apply/tok_SECRET?x=1", + ); + expect(span.name).toBe("GET /apply/[token]"); + }); + + it.each([ + "GET", + "outbox.process x", + "GET /v1/applications/by-token/:token", + "render route (app) /apply/[token]", + ])("leaves %s unchanged", (name) => { + expect(exportSpan(SpanKind.INTERNAL, {}, name).name).toBe(name); + }); +}); diff --git a/src/__tests__/redacting-exporter.test.ts b/src/__tests__/redacting-exporter.test.ts new file mode 100644 index 0000000..867adeb --- /dev/null +++ b/src/__tests__/redacting-exporter.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from "vitest"; +import { SpanStatusCode } from "@opentelemetry/api"; +import type { ExportResult } from "@opentelemetry/core"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, + type ReadableSpan, +} from "@opentelemetry/sdk-trace-base"; +import { RedactingSpanExporter } from "../redacting-exporter.js"; +import { stripQuery } from "../redact.js"; + +function exportOne(attributes: Record) { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan("GET /v1/tenants", { attributes }); + span.addEvent("exception", { "url.full": "https://x/y?token=SECRET" }); + span.end(); + return memory.getFinishedSpans()[0]!; +} + +describe("RedactingSpanExporter", () => { + it("drops query strings, headers and redis statements", () => { + const span = exportOne({ + "url.full": "https://api.carboncrei.io/v1/tenants?search=Jane%20Doe#frag", + "url.query": "search=Jane%20Doe", + "url.path": "/v1/tenants/Jane%20Doe", + "http.request.header.authorization": "Bearer SECRET", + "db.system.name": "redis", + "db.query.text": 'hset bull:invoice-email:1 data {"email":"jane@example.com"}', + "http.route": "/v1/tenants", + }); + const serialized = JSON.stringify([span.attributes, span.events]); + expect(serialized).not.toMatch(/Jane|SECRET|jane@example\.com/); + expect(span.attributes["url.full"]).toBe("https://api.carboncrei.io/v1/tenants"); + expect(span.attributes["url.path"]).toBe("/v1/tenants"); + expect(span.attributes["http.route"]).toBe("/v1/tenants"); + expect(span.spanContext().traceId).toMatch(/^[0-9a-f]{32}$/); + }); + it("keeps parameterized SQL from Prisma", () => { + const span = exportOne({ + "db.system.name": "postgresql", + "db.query.text": "SELECT 1 WHERE id = $1", + }); + expect(span.attributes["db.query.text"]).toBe("SELECT 1 WHERE id = $1"); + }); + + it("scrubs links with url and header attributes", () => { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan("GET /v1/tenants", { + links: [ + { + context: provider.getTracer("t").startSpan("linked").spanContext(), + attributes: { + "url.full": "https://api.carboncrei.io/v1/tenants?search=Jane%20Doe", + "http.request.header.authorization": "Bearer SECRET", + }, + }, + ], + }); + span.end(); + const exported = memory.getFinishedSpans()[0]!; + const serialized = JSON.stringify(exported.links); + expect(serialized).not.toMatch(/Jane|SECRET/); + const linkAttrs = exported.links[0]?.attributes; + expect(linkAttrs?.["url.full"]).toBe("https://api.carboncrei.io/v1/tenants"); + expect(linkAttrs?.["http.request.header.authorization"]).toBeUndefined(); + }); + + it("scrubs exception.message and stacktrace with recorded exceptions", () => { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan("test"); + span.recordException(new Error("duplicate email jane@example.com")); + span.end(); + const exported = memory.getFinishedSpans()[0]!; + const serialized = JSON.stringify([exported.attributes, exported.events]); + expect(serialized).not.toMatch(/jane@example\.com/); + const exceptionEvent = exported.events.find((e) => e.name === "exception"); + expect(exceptionEvent?.attributes?.["exception.type"]).toBe("Error"); + expect(exceptionEvent?.attributes?.["exception.message"]).toBe("[redacted]"); + const stacktrace = exceptionEvent?.attributes?.["exception.stacktrace"] as string | undefined; + expect(stacktrace).toContain("Error: [redacted]"); + expect(stacktrace).toMatch(/\n\s+at /); + }); + + it("scrubs status.message but keeps status.code", () => { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan("test"); + span.setStatus({ code: SpanStatusCode.ERROR, message: "ssn 123-45-6789" }); + span.end(); + const exported = memory.getFinishedSpans()[0]!; + expect(exported.status.message).toBe("[redacted]"); + expect(exported.status.code).toBe(SpanStatusCode.ERROR); + }); + + it("drops db.query.parameter. and db.operation.parameter. prefixes", () => { + const span = exportOne({ + "db.query.parameter.id": "12345", + "db.operation.parameter.email": "jane@example.com", + "db.query.text": "SELECT 1", + }); + const attrs = span.attributes; + expect(attrs["db.query.parameter.id"]).toBeUndefined(); + expect(attrs["db.operation.parameter.email"]).toBeUndefined(); + expect(attrs["db.query.text"]).toBe("SELECT 1"); + }); + + it("drops enduser. and user. prefixes", () => { + const span = exportOne({ + "enduser.id": "tenant-123", + "enduser.email": "jane@example.com", + "user.agent": "chrome", + "user.ip.address": "192.168.1.1", + "http.route": "/api/test", + }); + const attrs = span.attributes; + expect(attrs["enduser.id"]).toBeUndefined(); + expect(attrs["enduser.email"]).toBeUndefined(); + expect(attrs["user.agent"]).toBeUndefined(); + expect(attrs["user.ip.address"]).toBeUndefined(); + expect(attrs["http.route"]).toBe("/api/test"); + }); + + it("drops client.address", () => { + const span = exportOne({ + "client.address": "192.168.1.1", + "http.route": "/api/test", + }); + const attrs = span.attributes; + expect(attrs["client.address"]).toBeUndefined(); + expect(attrs["http.route"]).toBe("/api/test"); + }); + + it("stripQuery removes userinfo from URLs", () => { + expect(stripQuery("https://user:pass@host/p?q")).toBe("https://host/p"); + expect(stripQuery("https://user:pass@host/p#frag")).toBe("https://host/p"); + expect(stripQuery("https://host/p")).toBe("https://host/p"); + expect(stripQuery("HTTPS://user:pass@host/p?q")).toBe("HTTPS://host/p"); + expect(stripQuery("Https://user:pass@host/p")).toBe("Https://host/p"); + }); + + // WHY: BullMQ's worker span carries the processor's return value and the thrown message, + // and Queue.updateJobProgress the progress payload, any of which can hold resident data. + it("redacts the BullMQ failure reason and drops job results and progress", () => { + const span = exportOne({ + "bullmq.job.failed.reason": "duplicate email jane@example.com", + "bullmq.job.result": '{"email":"jane@example.com"}', + "bullmq.job.progress": '{"tenant":"Jane Doe"}', + "bullmq.job.name": "invoice-email", + "bullmq.queue.name": "invoice-email", + }); + expect(JSON.stringify(span.attributes)).not.toMatch(/jane@example\.com|Jane/); + expect(span.attributes["bullmq.job.failed.reason"]).toBe("[redacted]"); + expect(span.attributes["bullmq.job.result"]).toBeUndefined(); + expect(span.attributes["bullmq.job.progress"]).toBeUndefined(); + expect(span.attributes["bullmq.job.name"]).toBe("invoice-email"); + expect(span.attributes["bullmq.queue.name"]).toBe("invoice-email"); + }); + + it("treats db.system.name: 'redis' + db.query.text as Redis", () => { + const span = exportOne({ + "db.system.name": "redis", + "db.query.text": 'hset bull:job data {"email":"jane@example.com"}', + }); + expect(span.attributes["db.query.text"]).toBeUndefined(); + }); + + it("treats db.system: 'redis' + db.statement as Redis", () => { + const span = exportOne({ + "db.system": "redis", + "db.statement": 'hset bull:job data {"email":"jane@example.com"}', + }); + const attrs = span.attributes; + expect(attrs["db.statement"]).toBeUndefined(); + }); + + it("proxies resource, parentSpanContext, instrumentationScope, startTime, endTime, duration, and spanContext unchanged", () => { + let rawSpan: ReadableSpan | undefined; + const memory = new InMemorySpanExporter(); + const captureRaw = { + export(spans: ReadableSpan[], done: (result: ExportResult) => void) { + rawSpan = spans[0]; + memory.export(spans, done); + }, + shutdown() { + return memory.shutdown(); + }, + forceFlush() { + return memory.forceFlush?.() ?? Promise.resolve(); + }, + }; + const provider = new BasicTracerProvider({ + spanProcessors: [ + new SimpleSpanProcessor(captureRaw), + new SimpleSpanProcessor(new RedactingSpanExporter(memory)), + ], + }); + const span = provider + .getTracer("t") + .startSpan("test", { attributes: { "exception.message": "leak" } }); + span.end(); + const exported = memory.getFinishedSpans()[0]!; + + expect(rawSpan).toBeDefined(); + const raw = rawSpan!; + expect(exported.resource).toBe(raw.resource); + expect(exported.parentSpanContext).toBe(raw.parentSpanContext); + expect(exported.instrumentationScope).toBe(raw.instrumentationScope); + expect(exported.startTime).toEqual(raw.startTime); + expect(exported.endTime).toEqual(raw.endTime); + expect(exported.duration).toEqual(raw.duration); + expect(exported.spanContext()).toEqual(raw.spanContext()); + }); + + it("forceFlush works with stub exporter having only export and shutdown", async () => { + const stub = { + export(_spans: ReadableSpan[], done: (result: ExportResult) => void) { + done({ code: 0 }); + }, + shutdown() { + return Promise.resolve(); + }, + }; + const redacting = new RedactingSpanExporter(stub); + const result = await redacting.forceFlush(); + expect(result).toBeUndefined(); + }); + + it("redacts multi-line exception messages with PII on non-first lines", () => { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan("test"); + span.recordException(new Error("first\nemail jane@x.com")); + span.end(); + const exported = memory.getFinishedSpans()[0]!; + const serialized = JSON.stringify(exported.events); + expect(serialized).not.toMatch(/jane@x\.com/); + const exceptionEvent = exported.events.find((e) => e.name === "exception"); + const stacktrace = exceptionEvent?.attributes?.["exception.stacktrace"] as string | undefined; + expect(stacktrace).toContain("Error: [redacted]"); + expect(stacktrace).toMatch(/\n\s+at /); + }); + + it("handles non-string exception.stacktrace by redacting it", () => { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan("test"); + span.addEvent("exception", { + "exception.stacktrace": 123, + "exception.type": "CustomError", + }); + span.end(); + const exported = memory.getFinishedSpans()[0]!; + const exceptionEvent = exported.events[0]!; + expect(exceptionEvent.attributes?.["exception.stacktrace"]).toBe("[redacted]"); + }); + + it("handles non-string exception.message (as number) by redacting it", () => { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const span = provider.getTracer("t").startSpan("test"); + span.addEvent("exception", { + "exception.message": 12345, + }); + span.end(); + const exported = memory.getFinishedSpans()[0]!; + const exceptionEvent = exported.events[0]!; + expect(exceptionEvent.attributes?.["exception.message"]).toBe("[redacted]"); + }); +}); diff --git a/src/__tests__/scrub-contract.test.ts b/src/__tests__/scrub-contract.test.ts new file mode 100644 index 0000000..8ff94bd --- /dev/null +++ b/src/__tests__/scrub-contract.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { LOG_REDACT_PATTERNS } from "../log-redact.js"; +import { LOG_SCRUB_PATTERNS } from "../log-scrub.js"; +import { REDACT_KEY_SETS, REDACT_LITERALS, REDACT_PATTERNS } from "../redact.js"; +import { EXPORTER_LITERALS } from "../redacting-exporter.js"; +import { TOKEN_PATH_CONTRACT, URL_PATH_LITERALS, URL_PATH_PATTERNS } from "../url-path.js"; + +const CONTRACT_FILE = fileURLToPath(new URL("../scrub-contract.json", import.meta.url)); + +const UPDATE_HINT = + "This repo (codelittinc/otel-node) is the contract's source: update src/scrub-contract.json " + + "here, then open a PR in codelittinc/carbon-observability, the follower, updating its copy at " + + "agent/contracts/otel-node-scrub-contract.json and agent/config/scrub.alloy and " + + "agent/config/scrub-logs-metrics.alloy to match."; + +/** Every layer-1 pattern and key set the agent's layer-2 scrub repeats. */ +const LAYER_1: Record = { + ...LOG_REDACT_PATTERNS, + ...LOG_SCRUB_PATTERNS, + ...REDACT_PATTERNS, + ...URL_PATH_PATTERNS, + TOKEN_PATH_TEMPLATES: TOKEN_PATH_CONTRACT, + ...REDACT_KEY_SETS, + ...REDACT_LITERALS, + ...URL_PATH_LITERALS, + ...EXPORTER_LITERALS, +}; + +function readContract(): Record { + return JSON.parse(readFileSync(CONTRACT_FILE, "utf8")) as Record; +} + +describe("scrub contract", () => { + const contract = readContract(); + + it("pins exactly the layer-1 patterns and key sets", () => { + expect(Object.keys(contract).sort(), UPDATE_HINT).toEqual(Object.keys(LAYER_1).sort()); + }); + + it.each(Object.entries(LAYER_1))("pins %s", (name, value) => { + expect(contract[name], UPDATE_HINT).toEqual(value); + }); +}); diff --git a/src/__tests__/scrub-samples.ts b/src/__tests__/scrub-samples.ts new file mode 100644 index 0000000..448dce3 --- /dev/null +++ b/src/__tests__/scrub-samples.ts @@ -0,0 +1,86 @@ +// Shared by the scrub tests. + +/** Deterministic pseudo-random strings over the characters every pattern turns on. */ +export function* samples(count: number): Generator { + const pieces = [ + "a", + "Z", + "1", + "9", + ".", + "-", + "_", + "%", + "+", + "@", + ":", + "/", + "//", + "://", + "?", + "#", + " ", + "\n", + "\t", + "at ", + "\n at ", + "com", + "x.io", + "http", + "https://", + "123-45-6789", + "12345678", + "jane@example.com", + "=", + "é", + "\r", + "\r\n at ", + "\u00a0", + "\u2028", + "\u3000", + "HTTP://", + "Https://h?q", + "u:p@", + "HTTPS://user:pw@h/p", + '"//', + "(//u:", + "p@s", + "p?s@", + ":8080/", + ":p/s@", + ",", + '"', + "'", + "<", + "`", + "[::1]", + "]", + ";", + ",//", + "u:ab://", + "#c@", + ":/", + " jdbc:pg://", + "://a:b@c@d/", + "'s@", + '"x@', + '\\"', + "\\/\\/", + "\\", + ":2024#", + "=", + " https:/", + "1pg://", + "h:/", + ";j=S", + ":\\a/", + "')", + "''", + ]; + let seed = 42; + const next = () => (seed = (seed * 1103515245 + 12345) % 2 ** 31); + for (let i = 0; i < count; i++) { + const length = 1 + (next() % 14); + yield Array.from({ length }, () => pieces[next() % pieces.length]).join(""); + } +} diff --git a/src/__tests__/sdk-boot-shutdown.test.ts b/src/__tests__/sdk-boot-shutdown.test.ts new file mode 100644 index 0000000..bba2a38 --- /dev/null +++ b/src/__tests__/sdk-boot-shutdown.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { logs } from "@opentelemetry/api-logs"; +import { LoggerProvider, SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs"; + +const state = vi.hoisted(() => ({ + provider: undefined as LoggerProvider | undefined, + /** Bodies of the records exported; kept after shutdown, unlike the in-memory exporter's. */ + exported: [] as unknown[], +})); + +// A NodeSDK whose logger provider exports to memory, and whose shutdown closes that provider at +// once, as the real one does. +vi.mock("@opentelemetry/sdk-node", () => ({ + NodeSDK: class { + start() { + const exporter = { + export(records: { body?: unknown }[], done: (result: { code: number }) => void) { + state.exported.push(...records.map((record) => record.body)); + done({ code: 0 }); + }, + shutdown: () => Promise.resolve(), + forceFlush: () => Promise.resolve(), + }; + state.provider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor({ exporter })], + }); + logs.setGlobalLoggerProvider(state.provider); + } + shutdown(): Promise { + return state.provider!.shutdown(); + } + }, +})); + +import { bootSdk } from "../sdk-boot.js"; +import { forwardLogLine, setLogLineSink } from "../log-sink.js"; + +const config = { + enabled: true, + serviceName: "api", + serviceVersion: "test", + environment: "test", + serviceInstanceId: "instance-1", +}; + +describe("bootSdk shutdown", () => { + afterEach(() => { + setLogLineSink(undefined); + logs.disable(); + }); + + // WHY: the sink emits a record on a microtask after the line is written. A process that logs + // its last line and shuts down in the same tick must still export that line. + it("exports a line logged in the same tick as the shutdown", async () => { + const booted = await bootSdk(config, { + logScope: "backbone", + ignoredPaths: [], + esmLoaderHook: false, + exportLogs: true, + }); + forwardLogLine(JSON.stringify({ level: "info", event: "worker_stopping", msg: "stopping" })); + await booted.shutdown(); + expect(state.exported).toEqual(["stopping"]); + }); +}); diff --git a/src/__tests__/sdk-lazy.test.ts b/src/__tests__/sdk-lazy.test.ts new file mode 100644 index 0000000..3d9779a --- /dev/null +++ b/src/__tests__/sdk-lazy.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; + +const loaded = vi.hoisted(() => ({ modules: [] as string[] })); + +vi.mock("@opentelemetry/sdk-node", () => { + loaded.modules.push("@opentelemetry/sdk-node"); + return { NodeSDK: class {} }; +}); +vi.mock("bullmq-otel", () => { + loaded.modules.push("bullmq-otel"); + return { BullMQOtel: class {} }; +}); + +// WHY: a process with no endpoint (local runs, CI, every environment until the stack is +// switched on) must not pay for loading the SDK, its exporters and instrumentations. +describe("@codelittinc/otel-node with no endpoint", () => { + it("loads neither the SDK nor the BullMQ adapter", async () => { + const telemetry = await import("../index.js"); + expect((await telemetry.startTelemetry({ serviceName: "api", env: {} })).enabled).toBe(false); + expect(telemetry.bullmqTelemetry()).toBeUndefined(); + expect(loaded.modules).toEqual([]); + }); +}); diff --git a/src/__tests__/sdk-lifecycle.test.ts b/src/__tests__/sdk-lifecycle.test.ts new file mode 100644 index 0000000..2c28782 --- /dev/null +++ b/src/__tests__/sdk-lifecycle.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + constructed: 0, + config: undefined as Record | undefined, + shutdownThrows: false, + register: vi.fn(), + registerOptions: { data: { marker: "iitm" } }, + acknowledged: vi.fn((): Promise => Promise.resolve()), + order: [] as string[], + interval: undefined as NodeJS.Timeout | undefined, +})); + +vi.mock("node:module", () => ({ register: state.register })); +vi.mock("import-in-the-middle", () => ({ + createAddHookMessageChannel: () => ({ + registerOptions: state.registerOptions, + // Like the real one: a referenced interval guards the wait until the ack lands. + waitForAllMessagesAcknowledged: () => { + state.interval = setInterval(() => undefined, 1_000); + state.order.push("acknowledged"); + return state.acknowledged().then(() => clearInterval(state.interval)); + }, + }), +})); +vi.mock("@opentelemetry/sdk-node", () => { + return { + NodeSDK: class { + constructor(config: Record) { + state.constructed += 1; + state.config = config; + } + start() { + state.order.push("start"); + } + shutdown(): Promise { + if (state.shutdownThrows) throw new Error("sync shutdown failure"); + return Promise.resolve(); + } + }, + }; +}); + +import { diag } from "@opentelemetry/api"; +import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs"; +import { hasLogLineSink } from "../log-sink.js"; +import { ScopedLogRecordProcessor } from "../log-otlp.js"; +import { bullmqTelemetry } from "../bullmq.js"; +import { shutdownTelemetry, startTelemetry } from "../sdk.js"; +import { diagLogger, log as telemetryLog } from "../log-safely.js"; + +const enabled = { OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:9" }; + +describe("telemetry lifecycle", () => { + beforeEach(() => { + state.constructed = 0; + state.shutdownThrows = false; + state.order.length = 0; + state.register.mockClear(); + }); + afterEach(async () => { + state.shutdownThrows = false; + await shutdownTelemetry(100); + }); + + it("hooks only instrumented modules and waits for the loader to acknowledge", async () => { + await startTelemetry({ serviceName: "api", env: enabled }); + expect(state.register).toHaveBeenCalledWith( + "import-in-the-middle/hook.mjs", + expect.any(String), + state.registerOptions, + ); + expect(state.order).toEqual(["start", "acknowledged"]); + }); + + it("names the replica on the resource, so Prometheus labels each series with its instance", async () => { + await startTelemetry({ + serviceName: "api", + env: { ...enabled, RAILWAY_REPLICA_ID: "replica-7", RAILWAY_GIT_COMMIT_SHA: "abc123" }, + }); + const resource = state.config?.resource as { attributes: Record }; + expect(resource.attributes).toEqual({ + "service.name": "api", + "service.version": "abc123", + "service.instance.id": "replica-7", + "deployment.environment.name": "unknown", + }); + }); + + // WHY: logs reach OTLP only through the scrubbing logger's own output, never through a + // logger instrumentation that could see a record before it is scrubbed. + it("exports logs through one scope-filtered batch processor fed by the scrubbing logger", async () => { + await startTelemetry({ serviceName: "api", env: enabled }); + const processors = state.config?.logRecordProcessors as unknown[]; + expect(processors).toHaveLength(1); + expect(processors[0]).toBeInstanceOf(ScopedLogRecordProcessor); + expect((processors[0] as unknown as { inner: unknown }).inner).toBeInstanceOf( + BatchLogRecordProcessor, + ); + expect(hasLogLineSink()).toBe(true); + const names = (state.config?.instrumentations as { instrumentationName: string }[]).map( + (i) => i.instrumentationName, + ); + expect(names.some((name) => /pino|winston|bunyan/.test(name))).toBe(false); + }); + + // WHY: Alloy refuses a whole OTLP request over its body limit, and the logger does not cap a + // line, so the batch is bounded by record count (see sdk-boot.ts for the arithmetic). + it("exports logs in batches of at most 64 records", async () => { + await startTelemetry({ serviceName: "api", env: enabled }); + const [scoped] = state.config?.logRecordProcessors as { inner: unknown }[]; + // Reads the processor's private limit: a rename in the SDK fails here, not in production. + expect((scoped?.inner as { _maxExportBatchSize: number })._maxExportBatchSize).toBe(64); + }); + + it("stops feeding the log exporter on shutdown", async () => { + await startTelemetry({ serviceName: "api", env: enabled }); + await shutdownTelemetry(100); + expect(hasLogLineSink()).toBe(false); + }); + + it("exports no logs when the host opts out", async () => { + await startTelemetry({ serviceName: "web", env: enabled, esmLoaderHook: false, logs: false }); + expect(state.config?.logRecordProcessors).toEqual([]); + expect(hasLogLineSink()).toBe(false); + }); + + it("creates no log pipeline while telemetry is off", async () => { + await startTelemetry({ serviceName: "api", env: {} }); + await startTelemetry({ + serviceName: "api", + env: { ...enabled, OTEL_SDK_DISABLED: "true" }, + }); + expect(state.constructed).toBe(0); + expect(hasLogLineSink()).toBe(false); + }); + + it("filters backbone.* metric labels through the allowlist view", async () => { + await startTelemetry({ + serviceName: "api", + env: enabled, + metricViews: { instrumentPrefix: "backbone.", allowedAttributes: ["queue", "outcome"] }, + }); + expect(state.config?.views).toEqual([ + expect.objectContaining({ instrumentName: "backbone.*" }), + ]); + }); + + // WHY: a Next.js server runs bundled CommonJS, and the hook's specifier cannot be resolved + // from a bundle chunk. A failed register would take the whole SDK down with it. + it("starts without the ESM loader hook when the host has no ESM to hook", async () => { + await startTelemetry({ serviceName: "web", env: enabled, esmLoaderHook: false }); + expect(state.register).not.toHaveBeenCalled(); + expect(state.constructed).toBe(1); + expect(state.order).toEqual(["start"]); + }); + + it("returns the same config and builds one SDK on a second enabled call", async () => { + const first = await startTelemetry({ serviceName: "api", env: enabled }); + const second = await startTelemetry({ serviceName: "api", env: enabled }); + expect(second).toBe(first); + expect(state.constructed).toBe(1); + }); + + it("starts telemetry after an earlier disabled call", async () => { + expect((await startTelemetry({ serviceName: "api", env: {} })).enabled).toBe(false); + expect(state.constructed).toBe(0); + expect((await startTelemetry({ serviceName: "api", env: enabled })).enabled).toBe(true); + expect(state.constructed).toBe(1); + }); + + it("resolves the config when the loader hook fails", async () => { + state.register.mockImplementationOnce(() => { + throw new Error("hook failure"); + }); + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + await expect(startTelemetry({ serviceName: "api", env: enabled })).resolves.toMatchObject({ + enabled: true, + }); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ event: "telemetry_start_failed" }), + "telemetry_start_failed", + ); + warn.mockRestore(); + }); + + it("hands every queue the one BullMQ adapter built at start", async () => { + await startTelemetry({ serviceName: "api", env: enabled }); + const adapter = bullmqTelemetry(); + expect(adapter?.tracer).toBeDefined(); + expect(bullmqTelemetry()).toBe(adapter); + }); + + // WHY: a typo'd endpoint or a rejected export is otherwise silent; the started line names the + // collector host so an operator can tell telemetry is on, without leaking credentials. + it("logs one started line with the endpoint host only, and SDK warnings", async () => { + const info = vi.spyOn(telemetryLog, "info").mockImplementation(() => undefined); + const setLogger = vi.spyOn(diag, "setLogger"); + await startTelemetry({ + serviceName: "worker", + env: { OTEL_EXPORTER_OTLP_ENDPOINT: "https://user:pw@alloy.internal:4318/otlp?key=k" }, + }); + expect(info.mock.calls).toEqual([ + [ + { event: "telemetry_started", serviceName: "worker", endpointHost: "alloy.internal:4318" }, + "telemetry_started", + ], + ]); + expect(setLogger).toHaveBeenCalledWith(diagLogger, expect.objectContaining({ logLevel: 50 })); + info.mockRestore(); + setLogger.mockRestore(); + }); + + // NodeSDK installs its own logger when OTEL_LOG_LEVEL is set; a second one makes it warn that + // the logger is overwritten on every boot. + it("leaves the diag logger to NodeSDK when OTEL_LOG_LEVEL is set", async () => { + const setLogger = vi.spyOn(diag, "setLogger"); + vi.spyOn(telemetryLog, "info").mockImplementation(() => undefined); + await startTelemetry({ serviceName: "api", env: { ...enabled, OTEL_LOG_LEVEL: "debug" } }); + expect(setLogger).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it("still resolves when the logger itself throws while reporting a failed start", async () => { + state.register.mockImplementationOnce(() => { + throw new Error("hook failure"); + }); + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => { + throw new Error("logger broken"); + }); + await expect(startTelemetry({ serviceName: "api", env: enabled })).resolves.toMatchObject({ + enabled: true, + }); + warn.mockRestore(); + }); + + it("abandons a loader acknowledgement that never arrives and keeps telemetry running", async () => { + vi.useFakeTimers(); + try { + state.acknowledged.mockImplementationOnce(() => new Promise(() => undefined)); + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + const pending = startTelemetry({ serviceName: "api", env: enabled }); + await vi.advanceTimersByTimeAsync(5_000); + await expect(pending).resolves.toMatchObject({ enabled: true }); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ event: "telemetry_hook_ack_timeout" }), + "telemetry_hook_ack_timeout", + ); + expect(warn).not.toHaveBeenCalledWith( + expect.objectContaining({ event: "telemetry_start_failed" }), + expect.anything(), + ); + // The abandoned wait's interval must not hold the process open. + expect(state.interval?.hasRef()).toBe(false); + clearInterval(state.interval); + warn.mockRestore(); + } finally { + vi.useRealTimers(); + } + }); + + it("never rejects when the SDK shutdown throws synchronously", async () => { + await startTelemetry({ serviceName: "api", env: enabled }); + state.shutdownThrows = true; + await expect(shutdownTelemetry(100)).resolves.toBeUndefined(); + }); +}); diff --git a/src/__tests__/sdk.test.ts b/src/__tests__/sdk.test.ts new file mode 100644 index 0000000..ea1d463 --- /dev/null +++ b/src/__tests__/sdk.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { IORedisInstrumentation } from "@opentelemetry/instrumentation-ioredis"; +import { PgInstrumentation } from "@opentelemetry/instrumentation-pg"; +import { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; +import type { IncomingMessage } from "node:http"; +import { trace } from "@opentelemetry/api"; +import { buildInstrumentations } from "../instrumentations.js"; +import { shutdownTelemetry, startTelemetry } from "../sdk.js"; + +describe("buildInstrumentations", () => { + it("traces redis only under a parent span, so BullMQ polling is silent", () => { + const redis = buildInstrumentations([]).find((i) => i instanceof IORedisInstrumentation)!; + expect(redis.getConfig()).toMatchObject({ requireParentSpan: true }); + }); + it("never records bound SQL values", () => { + const pg = buildInstrumentations([]).find((i) => i instanceof PgInstrumentation)!; + // packages/db's outbox-backlog sentinel test runs pg with these same options. + expect(pg.getConfig()).toMatchObject({ + enhancedDatabaseReporting: false, + requireParentSpan: true, + }); + }); + + // WHY: health probes and the PostHog proxy would each open a server span per request. An + // entry ignores its own path and everything under it, never a sibling that shares a prefix. + it.each([ + ["/health", true], + ["/ready?probe=1", true], + ["/health/deep", true], + ["/api/ready", true], + ["/ingest/e/?compression=gzip-js", true], + ["/ingest", true], + ["/readyz", false], + ["/ingestion", false], + ["/v1/tenants", false], + ])("ignoring %s: %s", (url, ignored) => { + const http = buildInstrumentations(["/health", "/ready", "/api/ready", "/ingest"]).find( + (i) => i instanceof HttpInstrumentation, + )!; + const hook = http.getConfig().ignoreIncomingRequestHook!; + expect(hook({ url } as IncomingMessage)).toBe(ignored); + }); +}); + +describe("startTelemetry", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("is a no-op without an endpoint", async () => { + expect((await startTelemetry({ serviceName: "api", env: {} })).enabled).toBe(false); + }); + + it("survives an unreachable collector without an unhandled rejection", async () => { + // The exporters read process.env themselves, so the endpoint must be stubbed there. + vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:9"); + vi.stubEnv("OTEL_EXPORTER_OTLP_TIMEOUT", "500"); + const onRejection = vi.fn(); + process.on("unhandledRejection", onRejection); + try { + const config = await startTelemetry({ + serviceName: "api", + env: { OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:9", OTEL_LOG_LEVEL: "NONE" }, + }); + expect(config.enabled).toBe(true); + trace.getTracer("t").startSpan("probe").end(); + const started = Date.now(); + await expect(shutdownTelemetry(2_000)).resolves.toBeUndefined(); + expect(Date.now() - started).toBeLessThan(3_000); + // Let any late rejection from the exporter surface before the listener is removed. + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setTimeout(resolve, 50)); + } finally { + process.off("unhandledRejection", onRejection); + } + expect(onRejection).not.toHaveBeenCalled(); + }, 10_000); +}); diff --git a/src/__tests__/shared-log.test.ts b/src/__tests__/shared-log.test.ts new file mode 100644 index 0000000..fc821d5 --- /dev/null +++ b/src/__tests__/shared-log.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { symbols } from "pino"; +import type { Logger } from "pino"; +import type * as SharedLogModule from "../shared-log.js"; + +type SharedLog = typeof SharedLogModule; + +/** A fresh copy of the module, so each case starts with an unconfigured process. */ +async function freshSharedLog(): Promise { + vi.resetModules(); + return import("../shared-log.js"); +} + +/** Points the shared logger at an in-memory stream; children created earlier write there too. */ +function capture(log: Logger): Record[] { + const records: Record[] = []; + (log as unknown as Record)[symbols.streamSym] = { + write: (line: string) => void records.push(JSON.parse(line) as Record), + }; + return records; +} + +describe("the shared logger", () => { + let mod: SharedLog; + let records: Record[]; + + beforeEach(async () => { + mod = await freshSharedLog(); + records = capture(mod.log); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("falls back to OTEL_SERVICE_NAME, then to unknown, until the process names itself", () => { + vi.stubEnv("OTEL_SERVICE_NAME", "from-otel"); + mod.log.info("a"); + vi.stubEnv("OTEL_SERVICE_NAME", ""); + mod.log.info("b"); + expect(records.map((r) => r.app)).toEqual(["from-otel", "unknown"]); + }); + + // Module-scope children are created at import, before the entry point calls configureLogger. + it("stamps the configured app on a child created before configuration", () => { + const early = mod.log.child({ component: "early" }); + mod.configureLogger({ service: "worker" }); + early.info("after"); + expect(records[0]).toMatchObject({ app: "worker", component: "early" }); + }); + + it("gives a package line both its component and the process app", () => { + mod.configureLogger({ service: "api" }); + mod.log.child({ component: "db" }).warn({ tenantId: "t-1" }, "slow query"); + expect(records[0]).toMatchObject({ + app: "api", + component: "db", + tenantId: "t-1", + msg: "slow query", + }); + }); + + it("keeps redacting child bindings", () => { + mod.log.child({ component: "db", applicantEmail: "a@b.co" }).info("m"); + expect(JSON.stringify(records[0])).not.toContain("a@b.co"); + }); + + // An imported module must not be able to rename the process after its entry point did. + it("keeps the first service name and warns when a later call names another", () => { + mod.configureLogger({ service: "api" }); + mod.configureLogger({ service: "api" }); + expect(records).toHaveLength(0); + + mod.configureLogger({ service: "mcp" }); + mod.log.info("x"); + expect(records[0]).toMatchObject({ + level: "warn", + event: "logger_reconfigure_ignored", + requested: "mcp", + app: "api", + }); + expect(records[1]).toMatchObject({ app: "api", msg: "x" }); + }); +}); + +describe("an invalid LOG_LEVEL", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("is reported when the process names itself, carrying the app", async () => { + vi.stubEnv("LOG_LEVEL", "verbose"); + const mod = await freshSharedLog(); + const records = capture(mod.log); + expect(records).toHaveLength(0); + mod.configureLogger({ service: "mcp" }); + expect(records).toEqual([ + expect.objectContaining({ event: "invalid_log_level", requested: "verbose", app: "mcp" }), + ]); + }); +}); + +describe("flushLog", () => { + it("resolves when flushing throws", async () => { + const mod = await freshSharedLog(); + const flush = vi.spyOn(mod.log, "flush").mockImplementation(() => { + throw new Error("stream gone"); + }); + try { + await expect(mod.flushLog(50)).resolves.toBeUndefined(); + } finally { + flush.mockRestore(); + } + }); + + it("resolves after the timeout when the flush never calls back", async () => { + const mod = await freshSharedLog(); + const flush = vi.spyOn(mod.log, "flush").mockImplementation(() => undefined); + vi.useFakeTimers(); + try { + const done = mod.flushLog(1_000); + await vi.advanceTimersByTimeAsync(1_000); + await expect(done).resolves.toBeUndefined(); + } finally { + vi.useRealTimers(); + flush.mockRestore(); + } + }); +}); diff --git a/src/__tests__/trace-context.test.ts b/src/__tests__/trace-context.test.ts new file mode 100644 index 0000000..4fa7b7a --- /dev/null +++ b/src/__tests__/trace-context.test.ts @@ -0,0 +1,151 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { context, SpanStatusCode, trace } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { suppressTracing } from "@opentelemetry/core"; +import { + currentTraceparent, + linkFromTraceparent, + withLinkedSpan, + withRootSpan, +} from "../trace-context.js"; + +const memory = new InMemorySpanExporter(); +const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(memory)] }); +context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +trace.setGlobalTracerProvider(provider); +const tracer = provider.getTracer("trace-context-test"); + +const STORED = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + +beforeEach(() => memory.reset()); +afterAll(async () => { + await provider.shutdown(); + trace.disable(); + context.disable(); +}); + +describe("currentTraceparent", () => { + it("is undefined outside any span", () => { + expect(currentTraceparent()).toBeUndefined(); + }); + + it("returns the W3C header of the active span", () => { + tracer.startActiveSpan("writer", (span) => { + const { traceId, spanId } = span.spanContext(); + expect(currentTraceparent()).toBe(`00-${traceId}-${spanId}-01`); + span.end(); + }); + }); +}); + +describe("linkFromTraceparent", () => { + it("links to the stored trace and span", () => { + expect(linkFromTraceparent(STORED)?.context).toMatchObject({ + traceId: "4bf92f3577b34da6a3ce929d0e0e4736", + spanId: "00f067aa0ba902b7", + }); + }); + + it.each([null, undefined, "", "garbage", "00-zz-00f067aa0ba902b7-01", `${STORED}-extra`])( + "gives no link for %j", + (value) => { + expect(linkFromTraceparent(value)).toBeUndefined(); + }, + ); + + it("gives no link for an all-zero trace ID", () => { + expect( + linkFromTraceparent("00-00000000000000000000000000000000-00f067aa0ba902b7-01"), + ).toBeUndefined(); + }); +}); + +describe("withLinkedSpan", () => { + it("runs the function inside a span linked to the stored trace", async () => { + const result = await withLinkedSpan("outbox.process X", STORED, async () => { + expect(trace.getActiveSpan()).toBeDefined(); + return 42; + }); + expect(result).toBe(42); + const [span] = memory.getFinishedSpans(); + expect(span!.name).toBe("outbox.process X"); + expect(span!.links[0]!.context.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736"); + }); + + it("has no link for a malformed value and still runs the function", async () => { + await expect(withLinkedSpan("outbox.process X", "not-a-header", async () => 1)).resolves.toBe( + 1, + ); + expect(memory.getFinishedSpans()[0]!.links).toEqual([]); + }); + + it("marks the span as an error and rethrows", async () => { + await expect( + withLinkedSpan("outbox.process X", STORED, async () => { + throw new Error("handler failed"); + }), + ).rejects.toThrow("handler failed"); + const [span] = memory.getFinishedSpans(); + expect(span!.status.code).toBe(2); + expect(span!.ended).toBe(true); + }); +}); + +// The HTTP instrumentation runs an ignored path (`/ready`) with tracing suppressed. A check that +// fails there still needs a trace for its log lines to point at. +describe("withRootSpan", () => { + const suppressed = (fn: () => Promise) => + context.with(suppressTracing(context.active()), fn); + + it("records a span, active for the function, even where tracing is suppressed", async () => { + const traceIds: (string | undefined)[] = []; + await suppressed(() => + withRootSpan("readiness.check", async () => { + traceIds.push(trace.getActiveSpan()?.spanContext().traceId); + return 200; + }), + ); + const [span] = memory.getFinishedSpans(); + expect(span!.name).toBe("readiness.check"); + expect(traceIds).toEqual([span!.spanContext().traceId]); + expect(span!.parentSpanContext).toBeUndefined(); + }); + + it("ends with OK when the result is not a failure", async () => { + await expect( + withRootSpan( + "readiness.check", + async () => 200, + (s) => s >= 500, + ), + ).resolves.toBe(200); + expect(memory.getFinishedSpans()[0]!.status.code).toBe(SpanStatusCode.OK); + }); + + it("ends with ERROR when the result is a failure", async () => { + await expect( + withRootSpan( + "readiness.check", + async () => 503, + (s) => s >= 500, + ), + ).resolves.toBe(503); + expect(memory.getFinishedSpans()[0]!.status.code).toBe(SpanStatusCode.ERROR); + }); + + it("ends with ERROR and rethrows when the function throws", async () => { + await expect( + withRootSpan("readiness.check", async () => { + throw new Error("check failed"); + }), + ).rejects.toThrow("check failed"); + const [span] = memory.getFinishedSpans(); + expect(span!.status.code).toBe(SpanStatusCode.ERROR); + expect(span!.ended).toBe(true); + }); +}); diff --git a/src/__tests__/url-credentials-entry.test.ts b/src/__tests__/url-credentials-entry.test.ts new file mode 100644 index 0000000..fd52033 --- /dev/null +++ b/src/__tests__/url-credentials-entry.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const SRC = new URL("../", import.meta.url); +const IMPORT = /\b(?:import|export)\s[^'"]*?from\s*["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']/g; +const NODE_GLOBALS = /\b(?:process|Buffer|require|__dirname|__filename|global)\b/; + +/** Every module `entry` reaches through static or dynamic imports, with its source. */ +function importGraph(entry: string): Map { + const seen = new Map(); + const pending = [entry]; + while (pending.length > 0) { + const file = pending.pop()!; + if (seen.has(file)) continue; + const source = readFileSync(new URL(file, SRC), "utf8"); + seen.set(file, source); + for (const match of source.matchAll(IMPORT)) { + const specifier = (match[1] ?? match[2])!; + if (!specifier.startsWith("./")) { + seen.set(`external:${specifier}`, ""); + continue; + } + pending.push(specifier.replace(/\.js$/, ".ts").slice(2)); + } + } + return seen; +} + +// WHY: the browser bundles the `./url-credentials` entry. One import of a Node built-in or of +// the OTel SDK would pull server code into a client bundle, or break the build. +describe("the url-credentials entry", () => { + const graph = importGraph("url-credentials.ts"); + + it("imports no package and no Node built-in, only its own files", () => { + expect([...graph.keys()].filter((key) => key.startsWith("external:"))).toEqual([]); + }); + + it("uses no Node global in any file it reaches", () => { + for (const [file, source] of graph) { + const code = source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); + expect(NODE_GLOBALS.test(code), file).toBe(false); + } + }); + + it("is exported as its own subpath", () => { + const manifest = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), + ) as { exports: Record }; + expect(manifest.exports["./url-credentials"]?.default).toBe("./dist/url-credentials.js"); + }); +}); diff --git a/src/__tests__/userinfo-cases.ts b/src/__tests__/userinfo-cases.ts new file mode 100644 index 0000000..f6a36f7 --- /dev/null +++ b/src/__tests__/userinfo-cases.ts @@ -0,0 +1,194 @@ +// URL userinfo cases for the scrub tests: [input, expected]. + +/** Free text through `scrubMessage`. */ +export const MESSAGE_USERINFO_CASES: readonly (readonly [string, string])[] = [ + // Userinfo ends at the last `@` of the authority. + ["connect postgres://u:pw1@pw2.ab@db:5432/x failed", "connect postgres://db:5432/x failed"], + ["postgres://user:p@ss@db:5432/x", "postgres://db:5432/x"], + ["postgres://user:jane.doe@corp.com@db/x", "postgres://db/x"], + // `?`, `#` or `/` in a password. + ["postgres://user:p?ss@db/x", "postgres://db/x"], + ["postgres://user:p#ss@db/x", "postgres://db/x"], + ["postgres://u:p/ss@db/x", "postgres://db/x"], + ["https://medium.com/@alice", "https://medium.com/@alice"], + ["https://host:8080/@handle", "https://host:8080/@handle"], + ["https://h/p?x=a@b.com", "https://h/p"], + ["https://h/pkg@1.2.3", "https://h/pkg@1.2.3"], + // Every URL in a whitespace-free token. + ['{"a":"http://x","b":"postgres://u:p@db/x"}', '{"a":"http://x","b":"postgres://db/x"}'], + ["http://h/p,postgres://u:p@db/x", "http://h/p,postgres://db/x"], + // Scheme-relative, at the token start or after a delimiter. + ["//user:pass@host/x", "//host/x"], + ['"//user:pass@host/x"', '"//host/x"'], + ["(//u:p@h/x)", "(//h/x)"], + ["url=//u:p@h/x", "url=//h/x"], + ["/a//b", "/a//b"], + ["// note", "// note"], + // A URL ends at whitespace, a quote or an angle bracket, never at `,` or `;`. + [ + '{"baseUrl":"https://api.example.com","to":"jane@x.io","msg":"sent"}', + '{"baseUrl":"https://api.example.com","to":"[redacted]","msg":"sent"}', + ], + [ + '{"url":"http://localhost:3000","owner":"bob@x.io"}', + '{"url":"http://localhost:3000","owner":"[redacted]"}', + ], + ["'https://h','a@b.io'", "'https://h','[redacted]'"], + ["jane@x.io", "[redacted]"], + ["`https://h`jane@x.io", "`https://h`[redacted]"], + ["url=https://api.x.com,user=jane@x.io", "url=https://x.io"], + ["webhook https://hooks.slack.com;by=ops@x.io", "webhook https://x.io"], + ["postgres://u:p,q;r@db/x", "postgres://db/x"], + // Colonless userinfo with a `?` or `#` before its `@` is a credential. + ["https://ghp_ab#c@github.com/x", "https://github.com/x"], + ["https://tok?c@github.com/x", "https://github.com/x"], + ["redis://p#ss@r:6379", "redis://r:6379"], + ["https://h?u=a@b", "https://b"], + // A host and port, or a bracketed IPv6 host, before a `/`, `?` or `#` is never userinfo. + ["http://[::1]:80/p?u=a@b.com", "http://[::1]:80/p"], + ["http://[::1]:80/", "http://[::1]:80/"], + ["https://h:/p?u=a@b", "https://h:/p"], + ["https://h:8080?u=a@b.com", "https://h:8080"], + // A `//` or `://` inside a password starts no URL. + ["postgres://u:p,//x@db/x", "postgres://db/x"], + ["postgres://u:ab://c@db/x", "postgres://db/x"], + ["http://h,//u:p@db/x", "http://h,//db/x"], + // A quote ends a URL only at the token's end or before a delimiter. + ["postgres://u:it's@db/x", "postgres://db/x"], + ['{"dsn":"postgres://u:p\\"x@db/x"}', '{"dsn":"postgres://db/x"}'], + ['postgresql://app:p"w@db:5432/x', "postgresql://db:5432/x"], + [ + "P1001: Can't reach database server at postgresql://app:p\"w@db:5432/x", + "P1001: Can't reach database server at postgresql://db:5432/x", + ], + ['["https://h","x@y.io"]', '["https://h","[redacted]"]'], + // A `host:port` head before `?` or `#` needs a `=` or `/` before the `@`. + ["postgres://admin:2024#Secure@db/x", "postgres://db/x"], + ["redis://:12345#abc@r:6379", "redis://r:6379"], + ["redis://:1234?ab@r:6379", "redis://r:6379"], + // A `\` is host evidence only as an escaped `\/`. + ["postgres://app:\\abc/def@db/x", "postgres://db/x"], + ["postgres://app:\\abc=def@db/x", "postgres://db/x"], + ["postgres://app:1\\a/b@db/x", "postgres://db/x"], + // A `'` before `)` or `'` does not end userinfo; it still ends a host and path. + [ + "connect ECONNREFUSED postgres://app:oi0%7DDo%40%23b!c')%22!36%2B'5@db.internal:5432/app", + "connect ECONNREFUSED postgres://db.internal:5432/app", + ], + [ + "postgres://app:%22%7D0f~gTp3U2WRWy7''Qk@db.internal:5432/app", + "postgres://db.internal:5432/app", + ], + // Accepted cost: a quoted URL beside a quoted email loses its host. + ["('https://h'),('x@y.io')", "('https://y.io')"], + // `;` starts the path-parameter and query cut. + ["https://h/x;jsessionid=S", "https://h/x"], + ["GET https://h/p;jsessionid=A 200", "GET https://h/p 200"], + ["/cb;jsessionid=A?x=1 hit", "/cb hit"], + // One-slash, prefixed and JSON-escaped schemes. + ["https:/u:p@h/x", "https:/h/x"], + ["postgres:\\/\\/u:p@db\\/x", "postgres:\\/\\/db\\/x"], + // Only a space or a line break ends userinfo; a tab or other space ends a host and path. + ["postgres://u:p\tq@db/x", "postgres://db/x"], + ["x https://u:p\u2028q@h/x", "x https://h/x"], + ["postgres://u:p\u00a0q@db/x", "postgres://db/x"], + ["a://u:p@h\tb://v:q@i", "a://h\tb://i"], + ["https://h/p?q=1\tstatus=ok", "https://h/p\tstatus=ok"], + // A line break ends the URL, so userinfo split across it stays. + ["postgres://u:p\nass@db", "postgres://u:p\nass@db"], + // Unchanged behavior. + ["postgres://user:p%40ss@db/x", "postgres://db/x"], + ["postgres://user:p:ss@db/x", "postgres://db/x"], + ["https://ghp_X@github.com/o/r", "https://github.com/o/r"], + ["redis://:pass@cache:6379", "redis://cache:6379"], + ["amqp://guest:guest@mq/v", "amqp://mq/v"], + ["mongodb+srv://u:p@cluster0/db", "mongodb+srv://cluster0/db"], + ["jdbc:postgresql://u:p@db/x", "jdbc:postgresql://db/x"], + ["1postgres://u:p@db/x", "1postgres://db/x"], + ["x:postgres://u:p@db/x", "x:postgres://db/x"], + ['"postgres://u:p@db/x"', '"postgres://db/x"'], + ["(postgres://u:p@db/x)", "(postgres://db/x)"], + ["", ""], + ['{"url":"postgres://u:p@db/x"}', '{"url":"postgres://db/x"}'], + ["url=postgres://u:p@db/x", "url=postgres://db/x"], + ["a://u:p@h b://v:q@i", "a://h b://i"], + ["a://u:p@h\nb://v:q@i", "a://h\nb://i"], + ["GET HTTPS://user:pass@h/p?q=1 failed", "GET HTTPS://h/p failed"], + ["Https://user:pass@h/p", "Https://h/p"], + ["https://jane.doe@gmail.com/p", "https://gmail.com/p"], + ["sent to a@b.com", "sent to [redacted]"], + ["ssh deploy@buildbox failed", "ssh deploy@buildbox failed"], +]; + +/** A URL value through `stripQuery`. */ +export const URL_USERINFO_CASES: readonly (readonly [string, string])[] = [ + ["postgres://u:pw1@pw2.ab@db:5432/x", "postgres://db:5432/x"], + ["postgres://user:p@ss@db:5432/x", "postgres://db:5432/x"], + ["postgres://user:p?ss@db/x", "postgres://db/x"], + ["postgres://user:p#ss@db/x", "postgres://db/x"], + ["postgres://u:p/ss@db/x", "postgres://db/x"], + ["//user:pass@host/x?q=1", "//host/x"], + ["https://medium.com/@alice", "https://medium.com/@alice"], + ["https://host:8080/@handle?q", "https://host:8080/@handle"], + ["https://user:pass@h/p?q=1#frag", "https://h/p"], + ["HTTPS://user:pass@h/p?q", "HTTPS://h/p"], + ["/a//b@c?q", "/a//b@c"], + ["https://ghp_ab#c@github.com/x", "https://github.com/x"], + ["redis://p#ss@r:6379", "redis://r:6379"], + ["http://[::1]:80/p?u=a@b.com", "http://[::1]:80/p"], + ["https://h:/p?u=a@b", "https://h:/p"], + ["postgres://u:p,//x@db/x", "postgres://db/x"], + ["postgres://u:ab://c@db/x", "postgres://db/x"], + // Leading whitespace and a scheme chain. A value with no `//` keeps its text before an `@`. + [" https://u:p@h/x", " https://h/x"], + ["\thttps://u:p@h/x", "\thttps://h/x"], + ["jdbc:postgresql://u:p@h/db", "jdbc:postgresql://h/db"], + ["mailto:jane@example.com", "mailto:jane@example.com"], + ["postgres://admin:2024#Secure@db/x", "postgres://db/x"], + ["redis://:12345#abc@r:6379", "redis://r:6379"], + ["https://h:8080?u=a@b.com", "https://h:8080"], + ["postgres://app:\\abc/def@db/x", "postgres://db/x"], + ["https://h/x;jsessionid=S", "https://h/x"], + ["postgres://u:p,q;r@db/x", "postgres://db/x"], + ["https:/u:p@h/x", "https:/h/x"], + ["1postgres://u:p@db/x", "1postgres://db/x"], + ['postgres://u:p"x@db/x', "postgres://db/x"], +]; + +/** A span name through `scrubSpanName`, with no route. */ +export const SPAN_NAME_CASES: readonly (readonly [string, string])[] = [ + ["GET /x?t=1", "GET /x"], + ["GET /x?t=1\n", "GET /x\n"], + ["fetch GET https://u:p@h/x?t", "fetch GET https://h/x"], + ["GET /apply/SECRET", "GET /apply/[token]"], + ["GET /APPLY/SECRET?x", "GET /apply/[token]"], + ["render route (app) /apply/[token]", "render route (app) /apply/[token]"], + ["GET u:p@h/x?t=1", "GET h/x"], + ["GET https://h/p;jsessionid=A", "GET https://h/p"], + // Accepted cost: a scheme-less `x@` reads as userinfo. + ["GET pkg@1.2.3", "GET 1.2.3"], +]; + +/** A URL attribute through `scrubAttributes`, with no route: [key, value, expected]. */ +export const URL_ATTRIBUTE_CASES: readonly (readonly [string, string, string])[] = [ + ["url.full", "https://ghp_ab#c@github.com/x", "https://github.com/x"], + ["http.target", "https://ghp_ab#c@github.com/x", "https://github.com/x"], + ["url.full", " https://u:p@h/x", " https://h/x"], + ["url.full", " https://h/apply/SECRET", " https://h/apply/[token]"], + ["http.target", "\t/apply/SECRET", "\t/apply/[token]"], + ["url.full", "jdbc:postgresql://u:p@h/db", "jdbc:postgresql://h/db"], + ["http.url", "u:p@h/x", "h/x"], + ["url.full", "u:p@h/x?q", "h/x"], + ["url.full", "https:/u:p@h/x", "https:/h/x"], + ["url.full", "1postgres://u:p@db/x", "1postgres://db/x"], + ["url.full", "https://h/x;jsessionid=S", "https://h/x"], + ["url.full", "https://h//apply/SECRET", "https://h/apply/[token]"], + ["http.target", "/APPLY/SECRET", "/apply/[token]"], + ["url.full", "//h/apply/SECRET", "//h/apply/[token]"], + ["http.target", "//apply/SECRET?x", "/apply/[token]"], + [ + "url.full", + "https://h/v1//Applications/by-token/SECRET", + "https://h/v1/applications/by-token/:token", + ], +]; diff --git a/src/bounded.ts b/src/bounded.ts new file mode 100644 index 0000000..6659983 --- /dev/null +++ b/src/bounded.ts @@ -0,0 +1,18 @@ +/** The label every value outside an allowlist collapses into. */ +export const OTHER_LABEL = "other"; + +/** + * A metric label from a closed set: the value itself when the allowlist holds it, otherwise + * `"other"`. Keeps raw paths, ids and error text from ever becoming a label value. + */ +export function boundedLabel( + value: unknown, + allowlist: ReadonlySet | readonly T[], +): T | typeof OTHER_LABEL { + if (typeof value !== "string") return OTHER_LABEL; + const allowed = + allowlist instanceof Set + ? allowlist.has(value as T) + : (allowlist as readonly string[]).includes(value); + return allowed ? (value as T) : OTHER_LABEL; +} diff --git a/src/bullmq.ts b/src/bullmq.ts new file mode 100644 index 0000000..af4d5e4 --- /dev/null +++ b/src/bullmq.ts @@ -0,0 +1,10 @@ +import type { Telemetry } from "bullmq"; +import { startedBullmqTelemetry } from "./sdk.js"; + +/** + * BullMQ telemetry adapter for a Queue or Worker `telemetry` option. Undefined until + * telemetry has started, which leaves BullMQ on its untraced code path. + */ +export function bullmqTelemetry(): Telemetry | undefined { + return startedBullmqTelemetry(); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..fc45a11 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,33 @@ +import { randomUUID } from "node:crypto"; + +export type TelemetryEnv = Partial>; + +export interface TelemetryConfig { + enabled: boolean; + serviceName: string; + serviceVersion: string; + environment: string; + serviceInstanceId: string; +} + +/** One id per process, so every signal from this process names the same instance. */ +const PROCESS_INSTANCE_ID = randomUUID(); + +/** Telemetry exports only when an OTLP endpoint is set; local runs, CI and tests stay silent. */ +export function resolveTelemetryConfig( + fallbackServiceName: string, + env: TelemetryEnv = process.env, +): TelemetryConfig { + const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim(); + const killed = env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true"; + return { + enabled: Boolean(endpoint) && !killed, + serviceName: env.OTEL_SERVICE_NAME?.trim() || fallbackServiceName, + // A `railway up` deploy carries no commit SHA; the deployment id still pins it. + serviceVersion: + env.RAILWAY_GIT_COMMIT_SHA?.trim() || env.RAILWAY_DEPLOYMENT_ID?.trim() || "unknown", + environment: env.RAILWAY_ENVIRONMENT_NAME?.trim() || "unknown", + // Splits runtime panels by replica; Prometheus maps it to the `instance` label. + serviceInstanceId: env.RAILWAY_REPLICA_ID?.trim() || PROCESS_INSTANCE_ID, + }; +} diff --git a/src/fatal.ts b/src/fatal.ts new file mode 100644 index 0000000..683637a --- /dev/null +++ b/src/fatal.ts @@ -0,0 +1,65 @@ +import { flushLog, log } from "./shared-log.js"; +import { reportError } from "./options.js"; + +/** Total time a fatal exit may spend flushing logs and telemetry. */ +const FATAL_FLUSH_MS = 2_000; + +interface FatalOptions { + /** Synchronous: stop taking new work (a BullMQ `worker.close()`, not awaited). */ + stopIntake?: () => void; + /** + * Reports the fatal error; called once, before the flush. Defaults to the `onError` hook given + * to `startTelemetry`. + */ + report?: (err: unknown) => void; + /** Flushes telemetry; raced against the same budget as the log flush. */ + flushTelemetry?: (timeoutMs: number) => Promise; + exit?: (code: number) => void; + target?: Pick; +} + +/** + * Logs an uncaught exception or unhandled rejection as one JSON line under `err`, stops intake, + * reports it, spends at most two seconds flushing, then exits 1. A second fatal while exiting is + * logged only. + */ +export function installFatalHandlers(options: FatalOptions = {}): void { + const { + stopIntake, + report = (err) => reportError(err), + flushTelemetry, + exit = (code) => process.exit(code), + } = options; + const target = options.target ?? process; + let exiting = false; + + const fatal = (err: unknown, message: string): void => { + try { + log.fatal({ err }, message); + } catch { + // the exit still has to happen + } + if (exiting) return; + exiting = true; + process.exitCode = 1; + try { + stopIntake?.(); + } catch { + // a failing close must not keep a crashed process alive + } + try { + report(err); + } catch { + // reporting must not keep a crashed process alive + } + const budget = new Promise((resolve) => setTimeout(resolve, FATAL_FLUSH_MS).unref()); + const flushes = Promise.allSettled([ + flushLog(FATAL_FLUSH_MS), + Promise.resolve().then(() => flushTelemetry?.(FATAL_FLUSH_MS)), + ]); + void Promise.race([flushes, budget]).then(() => exit(1)); + }; + + target.on("unhandledRejection", (reason) => fatal(reason, "unhandled rejection")); + target.on("uncaughtException", (err) => fatal(err, "uncaught exception")); +} diff --git a/src/http-route.ts b/src/http-route.ts new file mode 100644 index 0000000..dfeff0b --- /dev/null +++ b/src/http-route.ts @@ -0,0 +1,16 @@ +import { context, trace } from "@opentelemetry/api"; +import { getRPCMetadata, RPCType } from "@opentelemetry/core"; +import { logScope } from "./options.js"; + +/** + * Labels the active HTTP server span and its request metrics with a route template, and tags the + * span with `.request_id`. Call it once routing has finished; a no-op when no span or + * HTTP context is active. + */ +export function setHttpRoute(route: string, method: string, requestId?: string): void { + const rpc = getRPCMetadata(context.active()); + if (rpc?.type === RPCType.HTTP) rpc.route = route; + const span = trace.getActiveSpan(); + span?.updateName(`${method} ${route}`); + if (requestId) span?.setAttribute(`${logScope()}.request_id`, requestId); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3fc84ae --- /dev/null +++ b/src/index.ts @@ -0,0 +1,10 @@ +export { startTelemetry, shutdownTelemetry, isTelemetryStarted } from "./sdk.js"; +export type { TelemetryConfig, TelemetryEnv } from "./config.js"; +export type { ErrorHookContext, MetricViewOptions, TelemetryOptions } from "./options.js"; +export { bullmqTelemetry } from "./bullmq.js"; +export { setHttpRoute } from "./http-route.js"; +export { getMeter, type BatchObservableResult, type Meter } from "./meter.js"; +export { log, configureLogger, flushLog } from "./shared-log.js"; +export { installFatalHandlers } from "./fatal.js"; +export { boundedLabel } from "./bounded.js"; +export { currentTraceparent, withLinkedSpan, withRootSpan } from "./trace-context.js"; diff --git a/src/instrumentations.ts b/src/instrumentations.ts new file mode 100644 index 0000000..b87f0e5 --- /dev/null +++ b/src/instrumentations.ts @@ -0,0 +1,35 @@ +import type { Instrumentation } from "@opentelemetry/instrumentation"; +import { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; +import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici"; +import { IORedisInstrumentation } from "@opentelemetry/instrumentation-ioredis"; +import { PgInstrumentation } from "@opentelemetry/instrumentation-pg"; +import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node"; + +/** An ignored path covers itself and everything below it, never a sibling sharing a prefix. */ +function isIgnored(url: string | undefined, ignoredPaths: readonly string[]): boolean { + const path = (url ?? "").split("?")[0]!; + return ignoredPaths.some((ignored) => path === ignored || path.startsWith(`${ignored}/`)); +} + +/** + * Hand-picked list: the auto-instrumentations bundle adds fs, dns and net spans that are noise. + * The app's own instrumentations (Prisma, at the app's version) come last. + */ +export function buildInstrumentations( + ignoredPaths: readonly string[], + appInstrumentations: readonly Instrumentation[] = [], +): Instrumentation[] { + return [ + // Event-loop delay, heap, GC. Its uncaught-exception capture stays off: it would emit the raw, + // unscrubbed exception message and stack as a log record. + new RuntimeNodeInstrumentation({ captureUncaughtException: false }), + new HttpInstrumentation({ + ignoreIncomingRequestHook: (request) => isIgnored(request.url, ignoredPaths), + }), + new UndiciInstrumentation(), + new IORedisInstrumentation({ requireParentSpan: true }), + // SQL text only, never bound values (Prisma 7 runs every query through `pg`). + new PgInstrumentation({ enhancedDatabaseReporting: false, requireParentSpan: true }), + ...appInstrumentations, + ]; +} diff --git a/src/log-errors.ts b/src/log-errors.ts new file mode 100644 index 0000000..80834b0 --- /dev/null +++ b/src/log-errors.ts @@ -0,0 +1,183 @@ +import { scrubErrorText, scrubStack } from "./log-scrub.js"; + +const MAX_CAUSE_DEPTH = 10; +const MAX_AGGREGATE_ERRORS = 20; +/** The only properties any error keeps, besides type, name, message, stack, cause and errors. */ +const COMMON_KEYS = ["code", "errorCode", "status", "statusCode"] as const; + +type ErrorRecord = Record; + +interface Family { + /** Extra primitive properties this family keeps. */ + keys: readonly string[]; + /** A value-free message, for families whose message embeds field values. */ + message?: (err: ErrorRecord, name: string) => string; + /** The cause carries the raw driver text; its code is already in the message. */ + dropCause?: boolean; +} + +function read(target: unknown, key: string): unknown { + try { + return (target as ErrorRecord)[key]; + } catch { + return undefined; + } +} + +function asRecord(value: unknown): ErrorRecord | undefined { + return typeof value === "object" && value !== null ? (value as ErrorRecord) : undefined; +} + +function str(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +const PRISMA: Family = { + keys: [], + message: (err, name) => { + const code = str(read(err, "code")) ?? str(read(err, "errorCode")); + return code ? `${name} ${code}` : name; + }, +}; +const DRIVER_ADAPTER: Family = { + keys: [], + dropCause: true, + message: (err, name) => { + const cause = read(err, "cause"); + const code = str(read(cause, "originalCode")) ?? str(read(cause, "kind")); + return code ? `${name} ${code}` : name; + }, +}; +const ZOD: Family = { + keys: [], + message: (err, name) => { + const issues = read(err, "issues"); + return `${name} ${Array.isArray(issues) ? issues.length : 0} issue(s)`; + }, +}; +const STRIPE: Family = { keys: ["decline_code", "param", "requestId"] }; +const HAPPYCO: Family = { keys: ["outcome"] }; +const FUNNEL: Family = { keys: ["api", "errorType"] }; +const GENERIC: Family = { keys: [] }; + +function familyOf(name: string, err: ErrorRecord): Family { + if (name.startsWith("Prisma")) return PRISMA; + if (name === "DriverAdapterError") return DRIVER_ADAPTER; + if (name === "ZodError") return ZOD; + if (name.startsWith("Stripe") || str(read(err, "type"))?.startsWith("Stripe")) return STRIPE; + if (name.startsWith("HappyCo")) return HAPPYCO; + if (name.startsWith("Funnel")) return FUNNEL; + return GENERIC; +} + +function typeOf(err: ErrorRecord, name: string | undefined): string { + if (err instanceof Error) { + const ctor = read(err, "constructor"); + const ctorName = typeof ctor === "function" ? ctor.name : undefined; + if (ctorName) return ctorName; + } + return name ?? "Error"; +} + +function primitive(value: unknown): string | number | boolean | undefined { + if (typeof value === "string") return scrubErrorText(value); + return typeof value === "number" || typeof value === "boolean" ? value : undefined; +} + +/** Prisma's meta names the failing column or model; every other meta field is a value. */ +function prismaMeta(err: ErrorRecord): ErrorRecord | undefined { + const meta = asRecord(read(err, "meta")); + if (!meta) return undefined; + const target = read(meta, "target"); + const modelName = read(meta, "modelName"); + const out: ErrorRecord = {}; + if (typeof target === "string" || Array.isArray(target)) out.target = target; + if (typeof modelName === "string") out.modelName = modelName; + return out; +} + +function sanitizeValue(value: unknown, depth: number, ancestors: WeakSet): unknown { + const record = asRecord(value); + if (!record) return primitive(value); + if (ancestors.has(record)) return "[circular]"; + if (depth >= MAX_CAUSE_DEPTH) return "[truncated]"; + return sanitize(record, depth, ancestors); +} + +/** A caller-built error-like object's name is free text; scrub it like a message. */ +function nameOf(err: ErrorRecord): { raw?: string; name?: string } { + const raw = str(read(err, "name")); + return { raw, name: raw === undefined ? undefined : scrubErrorText(raw) }; +} + +/** Deny by default: an error keeps only the fields listed here; every other property is dropped. */ +function sanitize(err: ErrorRecord, depth: number, ancestors: WeakSet): ErrorRecord { + ancestors.add(err); + try { + return sanitizeFields(err, depth, ancestors); + } finally { + ancestors.delete(err); + } +} + +function sanitizeFields(err: ErrorRecord, depth: number, ancestors: WeakSet): ErrorRecord { + const { raw, name } = nameOf(err); + const family = familyOf(raw ?? "", err); + const out: ErrorRecord = { type: typeOf(err, name) }; + if (name !== undefined) out.name = name; + + const safe = family.message?.(err, name ?? "Error"); + const rawMessage = str(read(err, "message")); + if (safe !== undefined) out.message = safe; + else if (rawMessage !== undefined) out.message = scrubErrorText(rawMessage); + + const stack = str(read(err, "stack")); + if (stack !== undefined) { + out.stack = scrubStack(stack, safe === undefined ? undefined : `${name ?? "Error"}: ${safe}`); + } + + for (const key of [...COMMON_KEYS, ...family.keys]) { + const value = primitive(read(err, key)); + if (value !== undefined) out[key] = value; + } + if (family === PRISMA) { + const meta = prismaMeta(err); + if (meta) out.meta = meta; + } + + const cause = family.dropCause ? undefined : read(err, "cause"); + if (cause !== undefined) { + const sanitized = sanitizeValue(cause, depth + 1, ancestors); + if (sanitized !== undefined) out.cause = sanitized; + } + const errors = read(err, "errors"); + if (Array.isArray(errors)) { + out.errors = errors + .slice(0, MAX_AGGREGATE_ERRORS) + .map((item) => sanitizeValue(item, depth + 1, ancestors) ?? null); + } + return out; +} + +/** + * The message safe to log for an error: code-only for value-embedding families, else scrubbed. + * An empty message falls back to the error type. + */ +export function safeErrorMessage(value: unknown): string { + try { + const err = asRecord(value); + if (!err) return scrubErrorText(String(value)); + const { raw, name } = nameOf(err); + const safe = familyOf(raw ?? "", err).message?.(err, name ?? "Error"); + const message = safe ?? scrubErrorText(String(read(err, "message") ?? "")); + return message === "" ? typeOf(err, name) : message; + } catch { + return "[unserializable error]"; + } +} + +/** An allowlisted, scrubbed plain copy of an error or error-like value. May throw; see serializeError. */ +export function serializeErrorBase(value: unknown): unknown { + const err = asRecord(value); + return err ? sanitize(err, 0, new WeakSet()) : primitive(value); +} diff --git a/src/log-fields.ts b/src/log-fields.ts new file mode 100644 index 0000000..9b422e0 --- /dev/null +++ b/src/log-fields.ts @@ -0,0 +1,8 @@ +import { isSpanContextValid, trace } from "@opentelemetry/api"; + +/** Trace correlation fields for the active span; empty outside a valid span. */ +export function traceLogFields(): { trace_id?: string; span_id?: string } { + const spanContext = trace.getActiveSpan()?.spanContext(); + if (!spanContext || !isSpanContextValid(spanContext)) return {}; + return { trace_id: spanContext.traceId, span_id: spanContext.spanId }; +} diff --git a/src/log-message.ts b/src/log-message.ts new file mode 100644 index 0000000..0031906 --- /dev/null +++ b/src/log-message.ts @@ -0,0 +1,54 @@ +import { safeErrorMessage } from "./log-errors.js"; +import { plainFields, sanitizeLogValue } from "./log-redact.js"; +import { scrubMessage } from "./log-scrub.js"; + +const UNLOGGABLE = "[unloggable]"; + +/** pino derives `msg` from an Error first argument or from `err.message` of any object. */ +function errorIn(first: unknown): unknown { + if (first instanceof Error) return first; + if (typeof first !== "object" || first === null) return undefined; + const fields = first as Record; + for (const key of ["err", "error", "e"]) { + const candidate = fields[key]; + if (candidate instanceof Error) return candidate; + } + const err = fields.err; + return typeof err === "object" && err !== null && "message" in err ? err : undefined; +} + +/** + * Arguments for a pino log call with a sanitized `msg`: the raw error message never becomes `msg`, + * a `msg` field is scrubbed like a message string, and every format argument is sanitized exactly + * like the log object. Arguments that cannot be inspected become a placeholder line. + */ +export function sanitizeLogArgs(args: unknown[]): unknown[] { + try { + const out = [...args]; + // Converted once, before any msg handling, so a toJSON-hidden field never reaches a spread. + if (typeof out[0] === "object" && out[0] !== null && !(out[0] instanceof Error)) { + out[0] = plainFields(out[0]); + } + const first = out[0]; + const msgIndex = typeof first === "string" ? 0 : 1; + const fields = first as Record | null | undefined; + if (msgIndex === 1 && !(first instanceof Error) && typeof fields?.msg === "string") { + out[0] = { ...fields, msg: scrubMessage(fields.msg) }; + } + if (typeof out[msgIndex] !== "string" && msgIndex === 1) { + const err = errorIn(first); + if (err !== undefined) out.splice(1, 0, safeErrorMessage(err)); + else if (typeof fields?.msg === "string") { + const { msg, ...rest } = out[0] as Record; + out[0] = rest; + out.splice(1, 0, msg); + } + } + // The message and every format argument take the log object's own sanitizer. + for (let i = msgIndex; i < out.length; i++) out[i] = sanitizeLogValue(out[i]); + return out; + } catch { + // Nothing raw goes out: a format arg could carry PII the sanitizer never saw. + return [{ log_redaction_failed: true }, UNLOGGABLE]; + } +} diff --git a/src/log-otlp.ts b/src/log-otlp.ts new file mode 100644 index 0000000..a47289d --- /dev/null +++ b/src/log-otlp.ts @@ -0,0 +1,136 @@ +import { + isSpanContextValid, + ROOT_CONTEXT, + trace, + TraceFlags, + type Context, +} from "@opentelemetry/api"; +import { + SeverityNumber, + type LogAttributes, + type Logger, + type LogRecord, +} from "@opentelemetry/api-logs"; +import type { LogRecordProcessor, SdkLogRecord } from "@opentelemetry/sdk-logs"; +import type { LogLineSink } from "./log-sink.js"; + +const SEVERITY: Record = { + trace: SeverityNumber.TRACE, + debug: SeverityNumber.DEBUG, + info: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR, + fatal: SeverityNumber.FATAL, +}; + +/** Body of a line with no message, so a text search in Loki still has something to match. */ +export const NO_MESSAGE = "(no message)"; + +/** + * OTel's own diagnostics (exporter failures among them). Exporting one would feed a failing + * exporter its own failures, once per batch, so they stay on stdout. + */ +function isOtelDiag(fields: Record): boolean { + return fields.component === "telemetry" && fields.event === "otel_diag"; +} + +/** The line's correlation fields as a log record context; flags come from the matching span. */ +function lineContext(traceId: unknown, spanId: unknown): Context | undefined { + if (typeof traceId !== "string" || typeof spanId !== "string") return undefined; + const active = trace.getActiveSpan()?.spanContext(); + const traceFlags = + active?.traceId === traceId && active.spanId === spanId ? active.traceFlags : TraceFlags.NONE; + const spanContext = { traceId, spanId, traceFlags }; + return isSpanContextValid(spanContext) + ? trace.setSpanContext(ROOT_CONTEXT, spanContext) + : undefined; +} + +function timestampOf(time: unknown): Date | undefined { + if (typeof time !== "string") return undefined; + const date = new Date(time); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +/** + * The fields search and dashboards need, first: the SDK keeps a record's first 128 attributes and + * the logger writes `app` last. + */ +const PRIORITY_FIELDS = ["app", "env", "component", "event"]; + +function priorityFirst(fields: Record): LogAttributes { + const out: Record = {}; + for (const key of PRIORITY_FIELDS) if (key in fields) out[key] = fields[key]; + for (const [key, value] of Object.entries(fields)) if (!(key in out)) out[key] = value; + return out as LogAttributes; +} + +function bodyOf(msg: unknown, event: unknown): string { + if (typeof msg === "string" && msg !== "") return msg; + if (typeof event === "string" && event !== "") return event; + return NO_MESSAGE; +} + +/** + * A sink that turns each line the scrubbing logger wrote into an OTel log record: `msg` (else + * `event`) is the body, `level` the severity, `time` the timestamp, `trace_id`/`span_id` the record + * context, every other field an attribute. Only the serialized line is read, so a record holds + * nothing the line does not. The record is emitted after the line is written, so an SDK diagnostic + * about it (a dropped attribute) follows the line on stdout instead of preceding it. + */ +export function logLineSink(logger: Logger): LogLineSink { + return (line) => { + const parsed: unknown = JSON.parse(line); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return; + const { msg, level, time, trace_id, span_id, ...attributes } = parsed as Record< + string, + unknown + >; + if (isOtelDiag(attributes)) return; + const record: LogRecord = { + body: bodyOf(msg, attributes.event), + attributes: priorityFirst(attributes), + context: lineContext(trace_id, span_id) ?? ROOT_CONTEXT, + }; + if (typeof level === "string") { + record.severityText = level; + record.severityNumber = SEVERITY[level] ?? SeverityNumber.UNSPECIFIED; + } + const timestamp = timestampOf(time); + if (timestamp) record.timestamp = timestamp; + queueMicrotask(() => { + try { + logger.emit(record); + } catch { + // the line is already on stdout + } + }); + }; +} + +/** + * Forwards only records from `scope`, the one fed by the scrubbing logger. Any other emitter on + * the global provider (an instrumentation's raw exception record) is never exported. + */ +export class ScopedLogRecordProcessor implements LogRecordProcessor { + constructor( + private readonly inner: LogRecordProcessor, + private readonly scope: string, + ) {} + + onEmit(record: SdkLogRecord, context?: Context): void { + if (record.instrumentationScope.name === this.scope) this.inner.onEmit(record, context); + } + + enabled(options: Parameters>[0]): boolean { + return options.instrumentationScope.name === this.scope; + } + + forceFlush(): Promise { + return this.inner.forceFlush(); + } + + shutdown(): Promise { + return this.inner.shutdown(); + } +} diff --git a/src/log-redact.ts b/src/log-redact.ts new file mode 100644 index 0000000..ec9511c --- /dev/null +++ b/src/log-redact.ts @@ -0,0 +1,214 @@ +import { serializeError } from "./log-serializer.js"; +import { scrubMessage } from "./log-scrub.js"; +import { stripQuery } from "./redact.js"; +import { describePatterns } from "./scrub-descriptor.js"; + +// Matches PII and credential key names (camelCase, snake_case, kebab-case) at any depth. +// `ssn`, `dob` and `ein` are anchored so words such as "businessName", "adobe" or "protein" +// stay readable; a camelCase `...Ein` is matched case-sensitively in isSensitiveKey. +// A person's name is matched by its qualifier (`firstName`, `contactName`, `payeeName`). A bare +// `name` is not: queue, job, category and target names are logged under it. +// Any key ending in `address` is: in a provider payload it is a mailing address. That includes +// `ipAddress`, which only the access-audit rows store and no log call site emits. +// Structured address parts (`street1`, `line1`) and `mobile` are matched too. +const SENSITIVE_KEY_PATTERN = + /(^ssn|ssn$|ssn.?(enc|last)|_ssn|social.?security|^dob|dob$|dob.?enc|_dob|date.?of.?birth|birth.?date|account.?number|routing.?number|tax.?id|taxpayer|(^|_)ein(_?number)?$|e.?mail|phone|password|passwd|secret|token|api.?key|authorization|cookie|holder.?name|iban|passport|drivers?.?license|birthday|bank.?account|aba.?number|clerk.?auth|signature$|enc$|enc.?snapshot$|(first|last|middle|full|legal|preferred|given|family).?name|surname|(occupant|caller|contact|payee|bill.?to|tenant|resident|applicant|guarantor|performed.?by|assignee|recipient|signer).?name|(street|mailing|home|billing|residential|postal).?address|^address(.?line)?.?\d*$|address.?line|address$|forwarding.?address|^street\d*$|^line[12]$|mobile|name.?on.?account|postal.?code|^zip|zip.?code)/i; +const CAMEL_EIN = /[a-z](Ein|EIN)(Number)?$/; +// A record id (`emailId`, `bankAccountId`, `resendEmailId`) names a row, not the value it points +// at. +const RECORD_ID = /(Id|ID|Ids|IDs|_id|_ids)$/; +// ...except where the id is itself the identifier of a person. +const PERSON_ID = /(tax|taxpayer|national|government|passport|license|licence|ssn).?(id|ids)$/i; + +/** Pinned by scrub-contract.test.ts: the agent's layer-2 log scrub repeats these patterns. */ +export const LOG_REDACT_PATTERNS = describePatterns({ + SENSITIVE_KEY_PATTERN, + CAMEL_EIN, + PERSON_ID, + RECORD_ID, +}); + +/** App-supplied key names, lower-cased; redacted on top of the patterns above. */ +const extraKeys = new Set(); + +/** Adds key names (matched whole, any letter case) to the redacted set; a default stays. */ +export function addSensitiveKeys(keys: Iterable): void { + for (const key of keys) { + const name = key.trim().toLowerCase(); + if (name) extraKeys.add(name); + } +} + +/** Test seam: forgets the app-supplied keys. */ +export function resetSensitiveKeys(): void { + extraKeys.clear(); +} + +export function isSensitiveKey(key: string): boolean { + if (extraKeys.size > 0 && extraKeys.has(key.toLowerCase())) return true; + if (PERSON_ID.test(key)) return true; + if (RECORD_ID.test(key)) return false; + return SENSITIVE_KEY_PATTERN.test(key) || CAMEL_EIN.test(key); +} + +const MAX_DEPTH = 8; +const MAX_BREADTH = 1_000; +/** Total values visited per log line, so a deep and wide object cannot stall the process. */ +const MAX_NODES = 5_000; +const REDACTED = "[redacted]"; +const TRUNCATED = "[truncated]"; +const ERROR_KEYS = new Set(["err", "error", "e"]); + +interface WalkState { + nodes: number; + ancestors: WeakSet; +} + +function isOpaque(value: object): boolean { + return ( + value instanceof Map || + value instanceof Set || + value instanceof RegExp || + value instanceof ArrayBuffer || + ArrayBuffer.isView(value) + ); +} + +/** + * The JSON-facing form of an object: URL, Buffer and toJSON values converted; `done` stops the + * walk. + */ +export function convert(value: object): { done: boolean; value: unknown } { + if (value instanceof URL) return { done: true, value: scrubMessage(stripQuery(value.href)) }; + if (Buffer.isBuffer(value)) return { done: true, value: { bytes: value.length } }; + if (value instanceof Date) return { done: true, value }; + if (isOpaque(value)) return { done: true, value }; + const toJSON = (value as { toJSON?: unknown }).toJSON; + if (typeof toJSON === "function") return { done: false, value: toJSON.call(value) }; + return { done: false, value }; +} + +/** + * A log call's first argument or child bindings as JSON would see them, so a spread keeps what a + * toJSON hides hidden. Byte views become `{ bytes }`; a value that is not a plain record (an + * array, a date, a map's entries, what a toJSON returns) lands under `value`. + */ +export function plainFields(fields: object, nested = false): Record { + if (ArrayBuffer.isView(fields)) return { bytes: fields.byteLength }; + if (Array.isArray(fields)) return { value: fields }; + if (fields instanceof Date) { + return { value: Number.isNaN(fields.getTime()) ? null : fields.toISOString() }; + } + if (fields instanceof Map) return { value: Object.fromEntries(fields) }; + const { value } = convert(fields); + if (value === fields) return fields as Record; + // What a toJSON returns gets the same treatment once, so a returned date or map keeps its value. + if (typeof value === "object" && value !== null) { + return nested ? (value as Record) : plainFields(value, true); + } + return { value }; +} + +type Visit = (item: unknown, depth: number, state: WalkState) => unknown; + +/** Maps an array within the breadth cap, stopping at the first item past the node budget. */ +function walkItems(items: unknown[], depth: number, state: WalkState, visit: Visit): unknown[] { + const out: unknown[] = []; + for (const item of items) { + if (out.length >= MAX_BREADTH || state.nodes >= MAX_NODES) { + out.push(TRUNCATED); + break; + } + out.push(visit(item, depth + 1, state)); + } + return out; +} + +/** A value under err/error/e at any depth: serialized deny-by-default; an array is mapped. */ +function walkErrorValue(value: unknown, depth: number, state: WalkState): unknown { + if (++state.nodes > MAX_NODES) return TRUNCATED; + if (value === null || value === undefined) return value; + if (!Array.isArray(value)) return serializeError(value); + if (depth > MAX_DEPTH) return TRUNCATED; + return walkItems(value, depth, state, walkErrorValue); +} + +/** `base`, or `base#n` when that name is taken; the chosen name is then taken. */ +function freeKey(base: string, taken: Set): string { + let candidate = base; + for (let n = 1; taken.has(candidate); n++) candidate = `${base}#${n}`; + taken.add(candidate); + return candidate; +} + +/** + * An object's entries within the breadth cap, each with the key it is logged under. A key that the + * message scrub would change becomes `[redacted]`; every clean key is reserved first, so a caller key that + * looks like a marker is never overwritten and never overwrites. + */ +function keyedEntries(target: object): { + entries: [string, string, unknown][]; + taken: Set; +} { + const raw = Object.entries(target).slice(0, MAX_BREADTH + 1); + const clean = raw.map(([k]) => scrubMessage(k) === k); + const taken = new Set(raw.filter((_, i) => clean[i]).map(([k]) => k)); + const entries = raw.map(([k, v], i): [string, string, unknown] => [ + k, + clean[i] ? k : freeKey(REDACTED, taken), + v, + ]); + return { entries, taken }; +} + +function walk(value: unknown, depth: number, state: WalkState): unknown { + if (++state.nodes > MAX_NODES) return TRUNCATED; + if (typeof value === "string") return scrubMessage(value); + if (typeof value !== "object" || value === null) return value; + if (value instanceof Error) return serializeError(value); + const converted = convert(value); + if (converted.done) return converted.value; + const target = converted.value; + if (typeof target === "string") return scrubMessage(target); + if (typeof target !== "object" || target === null) return target; + if (state.ancestors.has(target)) return "[circular]"; + if (depth > MAX_DEPTH) return TRUNCATED; + state.ancestors.add(target); + try { + if (Array.isArray(target)) return walkItems(target, depth, state, walk); + const out: Record = {}; + const { entries, taken } = keyedEntries(target); + let count = 0; + for (const [k, key, child] of entries) { + if (++count > MAX_BREADTH || state.nodes >= MAX_NODES) { + out[freeKey(TRUNCATED, taken)] = true; + break; + } + if (isSensitiveKey(k)) out[key] = REDACTED; + else if (ERROR_KEYS.has(k)) out[key] = walkErrorValue(child, depth + 1, state); + else out[key] = walk(child, depth + 1, state); + } + return out; + } finally { + state.ancestors.delete(target); + } +} + +/** + * A sanitized copy of a log value; the caller's value is never mutated. Fails closed. The log + * object, child bindings and every format argument take this one path: PII keys are redacted at + * any depth, every Error and every non-nullish value under err/error/e is serialized deny-by- + * default, and every string gets the message scrub (`scrubMessage`). + */ +export function sanitizeLogValue(value: unknown): unknown { + try { + return walk(value, 0, { nodes: 0, ancestors: new WeakSet() }); + } catch { + return { log_redaction_failed: true }; + } +} + +/** The log object or bindings, through sanitizeLogValue. */ +export function redactLogObject(obj: Record): Record { + return sanitizeLogValue(obj) as Record; +} diff --git a/src/log-safely.ts b/src/log-safely.ts new file mode 100644 index 0000000..da1b161 --- /dev/null +++ b/src/log-safely.ts @@ -0,0 +1,31 @@ +import type { DiagLogger } from "@opentelemetry/api"; +import { log as sharedLog } from "./shared-log.js"; + +export const log = sharedLog.child({ component: "telemetry" }); + +type Level = "trace" | "debug" | "info" | "warn" | "error"; + +/** Telemetry never breaks the app, so not even a failing logger may throw out of it. */ +export function logSafely(level: Level, fields: Record, message: string): void { + try { + log[level](fields, message); + } catch { + // nothing left to report to + } +} + +/** OTel's own diagnostics, through the redacting logger; only an Error argument is kept. */ +function diagMethod(level: Level, event: string) { + return (message: string, ...args: unknown[]): void => { + const err = args.find((arg) => arg instanceof Error); + logSafely(level, { event, ...(err !== undefined && { err }) }, message); + }; +} + +export const diagLogger: DiagLogger = { + error: diagMethod("error", "otel_diag"), + warn: diagMethod("warn", "otel_diag"), + info: diagMethod("info", "otel_diag"), + debug: diagMethod("debug", "otel_diag"), + verbose: diagMethod("trace", "otel_diag"), +}; diff --git a/src/log-scrub.ts b/src/log-scrub.ts new file mode 100644 index 0000000..70987ec --- /dev/null +++ b/src/log-scrub.ts @@ -0,0 +1,91 @@ +import { describePatterns } from "./scrub-descriptor.js"; +import { + HOST_PORT, + QUERY_START, + QUOTE_END, + scrubUrls, + USERINFO_QUOTE, + URL_END, + URL_START, + URL_TOKEN, + USERINFO_END, +} from "./url-credentials.js"; + +// Every pattern here can start a match only at a bounded set of positions (an `@`, the start of a +// run, a newline), so each scrub is linear in the input length. A pattern that may start anywhere +// in a long run rescans the run from every position and blocks the event loop for seconds. URL +// userinfo and query removal live in url-credentials.ts. +const SSN_SHAPE = /\b\d{3}-\d{2}-\d{4}\b/g; +// An email's domain, matched at its `@`. The TLD must be alphabetic, so a pnpm store path +// (`.pnpm/hono+node-server@1.19.5/...`) is not an email. +const EMAIL_DOMAIN = /@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/g; +const EMAIL_LOCAL_CHAR = /[A-Za-z0-9._%+-]/; +// Eight or more digits standing alone: an account or card number. A UUID group or a prefixed +// provider id (`pi_12345678`) touches a letter, `_` or `-`, so it is not matched. +const LONG_DIGIT_RUN = /(? last && EMAIL_LOCAL_CHAR.test(text[start - 1]!)) start--; + if (start === m.index) { + EMAIL_DOMAIN.lastIndex = m.index + 1; + continue; + } + out += text.slice(last, start) + REDACTED; + last = m.index + m[0].length; + } + return out + text.slice(last); +} + +/** + * Replaces SSN and email shapes in free text, strips the userinfo, query and fragment from any URL + * it quotes and the query and fragment from an absolute path. Phone and account shapes are left + * alone (id false positives). + */ +export function scrubMessage(text: string): string { + return replaceEmails(scrubUrls(text).replace(PATH_IN_TEXT, "$1$2").replace(SSN_SHAPE, REDACTED)); +} + +/** Error text comes from libraries and providers, so standalone long digit runs go too. */ +export function scrubErrorText(text: string): string { + return scrubMessage(text).replace(LONG_DIGIT_RUN, REDACTED); +} + +/** Scrubs the header line(s) of a stack and keeps every `at` frame byte for byte. */ +export function scrubStack(stack: string, header?: string): string { + const match = FRAME_START.exec(stack); + const at = match ? match.index + match[0].indexOf("\n") : -1; + const frames = at === -1 ? "" : stack.slice(at); + const head = header ?? (at === -1 ? stack : stack.slice(0, at)); + return `${scrubErrorText(head)}${frames}`; +} diff --git a/src/log-serializer.ts b/src/log-serializer.ts new file mode 100644 index 0000000..2847091 --- /dev/null +++ b/src/log-serializer.ts @@ -0,0 +1,18 @@ +import { serializeErrorBase } from "./log-errors.js"; +import { scrubErrorText } from "./log-scrub.js"; + +/** The `err` serializer: an allowlisted, scrubbed copy of the error chain. Never throws. */ +export function serializeError(value: unknown): unknown { + try { + return serializeErrorBase(value); + } catch { + let type = "Error"; + try { + const name = (value as { name?: unknown } | null)?.name; + if (typeof name === "string") type = scrubErrorText(name); + } catch { + // keep the default type + } + return { type, message: "[unserializable error]" }; + } +} diff --git a/src/log-sink.ts b/src/log-sink.ts new file mode 100644 index 0000000..6e2dfe9 --- /dev/null +++ b/src/log-sink.ts @@ -0,0 +1,32 @@ +/** Receives each serialized line the logger writes; installed only once the SDK has booted. */ +export type LogLineSink = (line: string) => void; + +let sink: LogLineSink | undefined; +let forwarding = false; + +/** Installs (or, with undefined, removes) the sink every logger line is copied to. */ +export function setLogLineSink(next: LogLineSink | undefined): void { + sink = next; +} + +export function hasLogLineSink(): boolean { + return sink !== undefined; +} + +/** + * Copies a written line to the sink and returns it unchanged. A sink that throws, or a line logged + * while the sink is running, is dropped: exporting must never break or recurse into a log call. + */ +export function forwardLogLine(line: string): string { + const current = sink; + if (current === undefined || forwarding) return line; + forwarding = true; + try { + current(line); + } catch { + // the line still reaches stdout + } finally { + forwarding = false; + } + return line; +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..b7772e3 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,111 @@ +import { pino, stdTimeFunctions, type DestinationStream, type Logger } from "pino"; +import { sanitizeLogArgs } from "./log-message.js"; +import { plainFields, redactLogObject } from "./log-redact.js"; +import { traceLogFields } from "./log-fields.js"; +import { forwardLogLine } from "./log-sink.js"; + +// `silent` is not offered: it would hide fatal lines too. +const VALID_LEVELS = new Set(["trace", "debug", "info", "warn", "error", "fatal"]); +/** + * Fields the logger owns. A caller's field of the same name is renamed `field.`, so only the + * active span sets `trace_id`/`span_id` (a Stripe payout carries its own `trace_id`). + */ +const RESERVED_FIELDS = new Set(["level", "time", "msg", "app", "env", "trace_id", "span_id"]); + +/** + * LOG_LEVEL (any case) when it names a level; anything else falls back to "info" rather than + * throwing. + */ +export function resolveLogLevel(raw = process.env.LOG_LEVEL): { level: string; invalid?: string } { + const requested = raw?.trim(); + if (!requested) return { level: "info" }; + const level = requested.toLowerCase(); + return VALID_LEVELS.has(level) ? { level } : { level: "info", invalid: requested }; +} + +function renameReserved(fields: Record): Record { + if (!Object.keys(fields).some((key) => RESERVED_FIELDS.has(key))) return fields; + const out: Record = {}; + for (const [key, value] of Object.entries(fields)) { + out[RESERVED_FIELDS.has(key) ? `field.${key}` : key] = value; + } + return out; +} + +/** Child bindings in their JSON shape, renamed; a binding that cannot be read never throws. */ +function safeBindings(bindings: object): Record { + try { + return renameReserved(plainFields(bindings)); + } catch { + return { log_redaction_failed: true }; + } +} + +/** A new object: the caller's fields (reserved names renamed) under the logger's own fields. */ +function mergeMixin(fields: Record, mixin: object): Record { + try { + return { ...renameReserved(plainFields(fields)), ...mixin }; + } catch { + return { log_redaction_failed: true, ...mixin }; + } +} + +/** Emits the invalid LOG_LEVEL warning once; held back until the process has named itself. */ +const pendingLevelWarnings = new WeakMap void>(); + +export function announceLogLevel(logger: Logger): void { + const emit = pendingLevelWarnings.get(logger); + pendingLevelWarnings.delete(logger); + emit?.(); +} + +/** + * Builds a redacting pino logger that names its process in `app`. `app` is read on every line, so + * a logger or child created before the process names itself still reports the configured name. + */ +export function createLogger(app: () => string, destination?: DestinationStream): Logger { + const { level, invalid } = resolveLogLevel(); + let logger: Logger; + const announce = () => announceLogLevel(logger); + logger = pino( + { + level, + base: { env: process.env.RAILWAY_ENVIRONMENT_NAME?.trim() || "unknown" }, + // The log formatter sanitizes the whole object, err/error/e included, on the same path as + // format args. pino's default err serializer would rewrite that result, so it is replaced. + serializers: { err: (value: unknown) => value }, + formatters: { + level: (label) => ({ level: label }), + bindings: redactLogObject, + log: redactLogObject, + }, + hooks: { + logMethod(args, method) { + announce(); + return method.apply(this, sanitizeLogArgs(args) as Parameters); + }, + // The finished, scrubbed line: written unchanged, and copied to the OTLP sink once the SDK + // has installed one. + streamWrite: forwardLogLine, + }, + timestamp: stdTimeFunctions.isoTime, + mixin: () => ({ app: app(), ...traceLogFields() }), + mixinMergeStrategy: (fields, mixin) => mergeMixin(fields as Record, mixin), + }, + destination, + ); + // pino applies formatters.bindings to the base bindings only, so child bindings are handled here. + const originalChild = logger.child; + logger.child = function (this: Logger, bindings, options) { + return originalChild.call(this, redactLogObject(safeBindings(bindings)), options); + } as Logger["child"]; + if (invalid !== undefined) { + pendingLevelWarnings.set(logger, () => + logger.warn( + { event: "invalid_log_level", requested: invalid }, + "invalid LOG_LEVEL, using info", + ), + ); + } + return logger; +} diff --git a/src/meter.ts b/src/meter.ts new file mode 100644 index 0000000..744360b --- /dev/null +++ b/src/meter.ts @@ -0,0 +1,11 @@ +import { metrics, type Meter } from "@opentelemetry/api"; + +/** + * A meter on the global provider. Obtain it after `startTelemetry`: a meter taken earlier is a + * no-op and never records, even once telemetry starts. + */ +export function getMeter(name: string): Meter { + return metrics.getMeter(name); +} + +export type { BatchObservableResult, Meter } from "@opentelemetry/api"; diff --git a/src/metric-views.ts b/src/metric-views.ts new file mode 100644 index 0000000..a0e309f --- /dev/null +++ b/src/metric-views.ts @@ -0,0 +1,16 @@ +import { createAllowListAttributesProcessor, type ViewOptions } from "@opentelemetry/sdk-metrics"; +import type { MetricViewOptions } from "./options.js"; + +/** + * One view that drops every attribute outside `allowedAttributes` from the app's own instruments + * (`*`), so an id or a message never becomes a label. No options, no views. + */ +export function metricViews(options?: MetricViewOptions): ViewOptions[] { + if (!options) return []; + return [ + { + instrumentName: `${options.instrumentPrefix}*`, + attributesProcessors: [createAllowListAttributesProcessor([...options.allowedAttributes])], + }, + ]; +} diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000..2bc085f --- /dev/null +++ b/src/options.ts @@ -0,0 +1,65 @@ +import type { Instrumentation } from "@opentelemetry/instrumentation"; +import type { TelemetryEnv } from "./config.js"; + +/** An allowlist view: instruments named `*` export only these attributes. */ +export interface MetricViewOptions { + instrumentPrefix: string; + allowedAttributes: readonly string[]; +} + +/** Context passed to `onError`: the service name, plus whatever the caller adds. */ +export type ErrorHookContext = Record; + +export interface TelemetryOptions { + serviceName: string; + /** Only records from this logger scope are exported; defaults to `serviceName`. */ + logScope?: string; + /** Without it, every metric attribute is kept. */ + metricViews?: MetricViewOptions; + /** Appended to the package's own list, so Prisma and the like match the app's versions. */ + instrumentations?: Instrumentation[]; + /** Log field names redacted on top of the default PII key list; never removes a default. */ + redactKeys?: readonly string[]; + /** Receives every fatal error `installFatalHandlers` sees, when it is given no `report`. */ + onError?: (err: unknown, context: ErrorHookContext) => void; + /** Read instead of `process.env`. */ + env?: TelemetryEnv; + /** Incoming request paths that open no server span; each covers itself and everything below. */ + ignoredPaths?: readonly string[]; + /** False for a host that runs bundled CommonJS (Next.js): no ESM loader hook is registered. */ + esmLoaderHook?: boolean; + /** False keeps log lines on stdout only; a host whose own code does not use the shared logger. */ + logs?: boolean; +} + +let scope: string | undefined; +let service: string | undefined; +let errorHook: TelemetryOptions["onError"]; + +/** Records the options that outlive `startTelemetry`: the log scope and the error hook. */ +export function applyOptions(options: TelemetryOptions): void { + scope = options.logScope?.trim() || options.serviceName; + service = options.serviceName; + errorHook = options.onError; +} + +/** The configured log scope; before `startTelemetry`, the service name from the environment. */ +export function logScope(): string { + return scope ?? (process.env.OTEL_SERVICE_NAME?.trim() || "unknown"); +} + +/** Calls the app's error hook, if any, with `service` set; never throws. */ +export function reportError(err: unknown, context: ErrorHookContext = {}): void { + try { + errorHook?.(err, { service: service ?? "unknown", ...context }); + } catch { + // reporting must never break the caller + } +} + +/** Test seam: forgets the recorded options. */ +export function resetOptions(): void { + scope = undefined; + service = undefined; + errorHook = undefined; +} diff --git a/src/profiling.ts b/src/profiling.ts new file mode 100644 index 0000000..9ae6671 --- /dev/null +++ b/src/profiling.ts @@ -0,0 +1,61 @@ +import type { PyroscopeConfig } from "@pyroscope/nodejs"; +import type { TelemetryConfig, TelemetryEnv } from "./config.js"; +import { logSafely } from "./log-safely.js"; + +/** The slice of the profiler used here; the CJS build surfaces it on `default` under ESM. */ +interface Pyroscope { + init(config?: PyroscopeConfig): void; + start(): void; + stop(): Promise; +} + +let profiler: Pyroscope | undefined; + +/** + * Starts Pyroscope CPU and heap profiling. Gated on `PYROSCOPE_SERVER_ADDRESS` alone, so it + * is independent of the OTLP endpoint, and `OTEL_SDK_DISABLED=true` still switches it off. + * Tags are `env` and `version` only; request-scoped labels would carry identifiers. Never + * rejects: a missing native binary logs `profiling_start_failed` and returns false. + */ +export async function startProfiling( + config: TelemetryConfig, + env: TelemetryEnv = process.env, +): Promise { + try { + if (profiler) return true; + const serverAddress = env.PYROSCOPE_SERVER_ADDRESS?.trim(); + if (!serverAddress) return false; + if (env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true") return false; + const module = await import("@pyroscope/nodejs"); + const pyroscope: Pyroscope = + (module as unknown as { default?: Pyroscope }).default ?? (module as Pyroscope); + pyroscope.init({ + serverAddress, + appName: config.serviceName, + tags: { env: config.environment, version: config.serviceVersion }, + // Pins the 0.6.4 defaults: 60 s flush, 100 Hz wall sampling, one heap sample per 512 KiB. + flushIntervalMs: 60_000, + wall: { samplingDurationMs: 60_000, samplingIntervalMicros: 10_000 }, + heap: { samplingIntervalBytes: 512 * 1024, stackDepth: 64 }, + }); + pyroscope.start(); + profiler = pyroscope; + return true; + } catch (error) { + profiler = undefined; + logSafely("warn", { err: error, event: "profiling_start_failed" }, "profiling_start_failed"); + return false; + } +} + +/** Stops the profiler; never rejects. */ +export async function stopProfiling(): Promise { + const running = profiler; + profiler = undefined; + if (!running) return; + try { + await running.stop(); + } catch { + // shutdown proceeds regardless + } +} diff --git a/src/redact.ts b/src/redact.ts new file mode 100644 index 0000000..fddaaf6 --- /dev/null +++ b/src/redact.ts @@ -0,0 +1,139 @@ +import type { Attributes } from "@opentelemetry/api"; +import { scrubMessage } from "./log-scrub.js"; +import { describeKeySets, describePatterns } from "./scrub-descriptor.js"; +import { stripUserinfo, URL_AUTHORITY } from "./url-credentials.js"; +import { routeTemplateOf, templateUrlPath, TOKEN_PATH_TEMPLATES } from "./url-path.js"; + +const URL_KEYS = new Set(["url.full", "url.original", "http.url", "http.target", "url.path"]); +const DROPPED_KEYS = new Set([ + "url.query", + "http.request.body", + "http.response.body", + "client.address", + "network.peer.address", + "bullmq.job.result", + "bullmq.job.progress", +]); +/** Kept so a failure is visible, but its content is the thrown message. */ +const REDACTED_KEYS = new Set(["exception.message", "bullmq.job.failed.reason"]); +const DROPPED_PREFIXES = [ + "http.request.header.", + "http.response.header.", + "messaging.message.body", + "db.query.parameter.", + "db.operation.parameter.", + "enduser.", + "user.", +]; +const STATEMENT_KEYS = ["db.statement", "db.query.text"]; +// A span with either key set to this is a Redis call, whose statement quotes the values it sent. +const REDIS_SYSTEM_KEYS = ["db.system.name", "db.system"]; +const REDIS_SYSTEM = "redis"; +const URL_QUERY_START = /[?#;]/; +const FRAME_LINE = /^\s+at /; + +/** `schemeless` reads a value with no scheme and no leading `/` as starting at its authority. */ +export function stripQuery(url: string, schemeless = false): string { + // Userinfo first: a password can hold a `?` or `#`. + const withoutUserinfo = stripUserinfo(url, schemeless); + const cut = withoutUserinfo.search(URL_QUERY_START); + return cut === -1 ? withoutUserinfo : withoutUserinfo.slice(0, cut); +} + +// `METHOD /path`, `METHOD scheme://host/path` or `METHOD user@host/path`. +const NAMED_REQUEST = /^([A-Z]+) ((?:\/|[a-z][a-z0-9+.-]*:\/\/|[^\s/?#@]*@)\S*)$/i; +const NAME_KEYS = new Set(["next.span_name"]); +const WORD = /\S+/g; + +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats these. */ +export const REDACT_PATTERNS = describePatterns({ + URL_AUTHORITY, + URL_QUERY_START, + NAMED_REQUEST, + FRAME_LINE, +}); +export const REDACT_KEY_SETS = describeKeySets({ + URL_KEYS, + DROPPED_KEYS, + REDACTED_KEYS, + DROPPED_PREFIXES, + STATEMENT_KEYS, + NAME_KEYS, + REDIS_SYSTEM_KEYS, +}); +export const REDACT_LITERALS = Object.freeze({ REDIS_SYSTEM }); + +/** + * A span name of the form `METHOD /path`, `METHOD scheme://host/path` or `METHOD user@host/path` + * gets the same treatment as a URL attribute: no query, fragment or userinfo, and the path + * templated. Next.js names an unrouted request's span after its raw URL. Any other name gets the + * message scrub, and each of its words its token paths templated. + */ +export function scrubSpanName( + name: string, + route: string | undefined, + tokenPaths: readonly (readonly [RegExp, string])[] = TOKEN_PATH_TEMPLATES, +): string { + const match = NAMED_REQUEST.exec(name); + if (!match) { + return scrubMessage(name).replace(WORD, (word) => templateUrlPath(word, undefined, tokenPaths)); + } + return `${match[1]} ${templateUrlPath(stripQuery(match[2]!, true), route, tokenPaths)}`; +} + +function isRedis(attributes: Attributes): boolean { + return REDIS_SYSTEM_KEYS.some((key) => attributes[key] === REDIS_SYSTEM); +} + +function scrubExceptionStacktrace(stacktrace: string, exceptionType?: string): string { + const lines = stacktrace.split("\n"); + const type = exceptionType || "Error"; + let frameStartIndex = lines.findIndex((line) => FRAME_LINE.test(line)); + if (frameStartIndex === -1) { + return `${type}: [redacted]`; + } + const result = [`${type}: [redacted]`, ...lines.slice(frameStartIndex)]; + return result.join("\n"); +} + +/** + * Scrubs one attribute set. Every URL attribute loses its query, fragment and userinfo, and its + * path becomes the span's `http.route` template when it has one, so a path parameter never + * leaves. Without a route (client spans, unmatched requests) `url.path` is dropped and known + * token-bearing paths are templated in the remaining URL attributes. + */ +export function scrubAttributes( + attributes: Attributes, + tokenPaths: readonly (readonly [RegExp, string])[] = TOKEN_PATH_TEMPLATES, +): Attributes { + const redis = isRedis(attributes); + const route = routeTemplateOf(attributes["http.route"]); + const scrubbed: Attributes = {}; + for (const [key, value] of Object.entries(attributes)) { + if (DROPPED_KEYS.has(key) || DROPPED_PREFIXES.some((prefix) => key.startsWith(prefix))) + continue; + if (redis && STATEMENT_KEYS.includes(key)) continue; + if (key === "url.path" && !route) continue; + + if (REDACTED_KEYS.has(key)) { + scrubbed[key] = "[redacted]"; + } else if (key === "exception.stacktrace") { + if (typeof value === "string") { + const exceptionType = attributes["exception.type"]; + scrubbed[key] = scrubExceptionStacktrace(value, exceptionType as string | undefined); + } else { + scrubbed[key] = "[redacted]"; + } + } else { + scrubbed[key] = + typeof value !== "string" + ? value + : URL_KEYS.has(key) + ? templateUrlPath(stripQuery(value, true), route, tokenPaths) + : NAME_KEYS.has(key) + ? scrubSpanName(value, route, tokenPaths) + : value; + } + } + return scrubbed; +} diff --git a/src/redacting-exporter.ts b/src/redacting-exporter.ts new file mode 100644 index 0000000..7035d66 --- /dev/null +++ b/src/redacting-exporter.ts @@ -0,0 +1,61 @@ +import type { ExportResult } from "@opentelemetry/core"; +import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base"; +import { scrubAttributes, scrubSpanName } from "./redact.js"; +import { routeTemplateOf, TOKEN_PATH_TEMPLATES } from "./url-path.js"; + +type TokenPaths = readonly (readonly [RegExp, string])[]; + +// The span status field that carries the thrown message; it is kept, redacted, so a failure shows. +const REDACTED_STATUS_KEY = "message"; + +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats it. */ +export const EXPORTER_LITERALS = Object.freeze({ REDACTED_STATUS_KEY }); + +function scrubSpan(span: ReadableSpan, tokenPaths: TokenPaths): ReadableSpan { + const attributes = scrubAttributes(span.attributes, tokenPaths); + const name = scrubSpanName(span.name, routeTemplateOf(span.attributes["http.route"]), tokenPaths); + const events = span.events.map((event) => + event.attributes + ? { ...event, attributes: scrubAttributes(event.attributes, tokenPaths) } + : event, + ); + const links = span.links.map((link) => + link.attributes ? { ...link, attributes: scrubAttributes(link.attributes, tokenPaths) } : link, + ); + const status = + span.status && span.status[REDACTED_STATUS_KEY] + ? { ...span.status, [REDACTED_STATUS_KEY]: "[redacted]" } + : span.status; + + return new Proxy(span, { + get(target, property) { + if (property === "name") return name; + if (property === "attributes") return attributes; + if (property === "events") return events; + if (property === "links") return links; + if (property === "status") return status; + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + +export class RedactingSpanExporter implements SpanExporter { + /** `tokenPaths` are the path patterns templated on spans without a route template. */ + constructor( + private readonly inner: SpanExporter, + private readonly tokenPaths: TokenPaths = TOKEN_PATH_TEMPLATES, + ) {} + export(spans: ReadableSpan[], done: (result: ExportResult) => void): void { + this.inner.export( + spans.map((span) => scrubSpan(span, this.tokenPaths)), + done, + ); + } + shutdown(): Promise { + return this.inner.shutdown(); + } + forceFlush(): Promise { + return this.inner.forceFlush?.() ?? Promise.resolve(); + } +} diff --git a/src/scrub-contract.json b/src/scrub-contract.json new file mode 100644 index 0000000..b424515 --- /dev/null +++ b/src/scrub-contract.json @@ -0,0 +1,128 @@ +{ + "SENSITIVE_KEY_PATTERN": { + "source": "(^ssn|ssn$|ssn.?(enc|last)|_ssn|social.?security|^dob|dob$|dob.?enc|_dob|date.?of.?birth|birth.?date|account.?number|routing.?number|tax.?id|taxpayer|(^|_)ein(_?number)?$|e.?mail|phone|password|passwd|secret|token|api.?key|authorization|cookie|holder.?name|iban|passport|drivers?.?license|birthday|bank.?account|aba.?number|clerk.?auth|signature$|enc$|enc.?snapshot$|(first|last|middle|full|legal|preferred|given|family).?name|surname|(occupant|caller|contact|payee|bill.?to|tenant|resident|applicant|guarantor|performed.?by|assignee|recipient|signer).?name|(street|mailing|home|billing|residential|postal).?address|^address(.?line)?.?\\d*$|address.?line|address$|forwarding.?address|^street\\d*$|^line[12]$|mobile|name.?on.?account|postal.?code|^zip|zip.?code)", + "flags": "i" + }, + "CAMEL_EIN": { + "source": "[a-z](Ein|EIN)(Number)?$", + "flags": "" + }, + "PERSON_ID": { + "source": "(tax|taxpayer|national|government|passport|license|licence|ssn).?(id|ids)$", + "flags": "i" + }, + "RECORD_ID": { + "source": "(Id|ID|Ids|IDs|_id|_ids)$", + "flags": "" + }, + "SSN_SHAPE": { + "source": "\\b\\d{3}-\\d{2}-\\d{4}\\b", + "flags": "g" + }, + "EMAIL_DOMAIN": { + "source": "@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*\\.[A-Za-z]{2,}\\b", + "flags": "g" + }, + "EMAIL_LOCAL_CHAR": { + "source": "[A-Za-z0-9._%+-]", + "flags": "" + }, + "LONG_DIGIT_RUN": { + "source": "(?]", + "flags": "" + }, + "URL_END": { + "source": "[\\s`<>]", + "flags": "" + }, + "QUOTE_END": { + "source": "[\"'](?=$|[\\s,;:}\\])>\"'&])", + "flags": "y" + }, + "USERINFO_QUOTE": { + "source": "'[)']", + "flags": "y" + }, + "HOST_PORT": { + "source": "^(?:\\[[^\\]]*\\]|[^:[\\]]+)(?::\\d*)?$", + "flags": "" + }, + "QUERY_START": { + "source": "[?#;]", + "flags": "" + }, + "PATH_IN_TEXT": { + "source": "(^|\\s)(\\/[^\\s?#;]*)[?#;]\\S*", + "flags": "g" + }, + "URL_AUTHORITY": { + "source": "^\\s*(?:(?:[0-9+.-]*[a-z][a-z0-9+.-]*:)+(?:\\/\\/?|\\\\\\/\\\\\\/)|\\/\\/)", + "flags": "i" + }, + "URL_QUERY_START": { + "source": "[?#;]", + "flags": "" + }, + "NAMED_REQUEST": { + "source": "^([A-Z]+) ((?:\\/|[a-z][a-z0-9+.-]*:\\/\\/|[^\\s/?#@]*@)\\S*)$", + "flags": "i" + }, + "FRAME_LINE": { + "source": "^\\s+at ", + "flags": "" + }, + "ORIGIN": { + "source": "^((?:[a-z][a-z0-9+.-]*:)*\\/\\/[^/?#]*)(.*)$", + "flags": "is" + }, + "TOKEN_PATH_TEMPLATES": [ + { + "source": "^\\/+v1\\/+applications\\/+by-token\\/+[^/?#]+", + "flags": "i", + "replacement": "/v1/applications/by-token/:token" + }, + { + "source": "^\\/+apply\\/+[^/?#]+", + "flags": "i", + "replacement": "/apply/[token]" + } + ], + "URL_KEYS": ["http.target", "http.url", "url.full", "url.original", "url.path"], + "DROPPED_KEYS": [ + "bullmq.job.progress", + "bullmq.job.result", + "client.address", + "http.request.body", + "http.response.body", + "network.peer.address", + "url.query" + ], + "REDACTED_KEYS": ["bullmq.job.failed.reason", "exception.message"], + "DROPPED_PREFIXES": [ + "db.operation.parameter.", + "db.query.parameter.", + "enduser.", + "http.request.header.", + "http.response.header.", + "messaging.message.body", + "user." + ], + "STATEMENT_KEYS": ["db.query.text", "db.statement"], + "NAME_KEYS": ["next.span_name"], + "REDIS_SYSTEM_KEYS": ["db.system", "db.system.name"], + "REDIS_SYSTEM": "redis", + "ROUTE_TEMPLATE_PREFIX": "/", + "REDACTED_STATUS_KEY": "message" +} diff --git a/src/scrub-descriptor.ts b/src/scrub-descriptor.ts new file mode 100644 index 0000000..e0ba2cb --- /dev/null +++ b/src/scrub-descriptor.ts @@ -0,0 +1,24 @@ +/** A regex's source and flags, for the scrub contract test; never the stateful RegExp itself. */ +export interface PatternDescriptor { + readonly source: string; + readonly flags: string; +} + +export function describePatterns( + patterns: Record, +): Readonly> { + const out = {} as Record; + for (const name of Object.keys(patterns) as K[]) { + const { source, flags } = patterns[name]; + out[name] = Object.freeze({ source, flags }); + } + return Object.freeze(out); +} + +export function describeKeySets( + sets: Record>, +): Readonly> { + const out = {} as Record; + for (const name of Object.keys(sets) as K[]) out[name] = Object.freeze([...sets[name]].sort()); + return Object.freeze(out); +} diff --git a/src/sdk-boot.ts b/src/sdk-boot.ts new file mode 100644 index 0000000..28db98c --- /dev/null +++ b/src/sdk-boot.ts @@ -0,0 +1,147 @@ +import { register } from "node:module"; +import { createAddHookMessageChannel } from "import-in-the-middle"; +import { NodeSDK } from "@opentelemetry/sdk-node"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto"; +import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; +import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto"; +import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs"; +import { logs } from "@opentelemetry/api-logs"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { + ATTR_SERVICE_INSTANCE_ID, + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, +} from "@opentelemetry/semantic-conventions"; +import { BullMQOtel } from "bullmq-otel"; +import type { Telemetry } from "bullmq"; +import type { Instrumentation } from "@opentelemetry/instrumentation"; +import type { TelemetryConfig } from "./config.js"; +import { buildInstrumentations } from "./instrumentations.js"; +import { metricViews } from "./metric-views.js"; +import type { MetricViewOptions } from "./options.js"; +import { RedactingSpanExporter } from "./redacting-exporter.js"; +import { logSafely } from "./log-safely.js"; +import { setLogLineSink } from "./log-sink.js"; +import { logLineSink, ScopedLogRecordProcessor } from "./log-otlp.js"; + +/** Longest boot waits for the loader thread to acknowledge the hook registration. */ +const HOOK_ACK_TIMEOUT_MS = 5_000; + +/** + * Log records per OTLP request. Alloy refuses a whole request over 32 MiB + * (`agent/config/base.alloy` in codelittinc/carbon-observability), and the logger does not cap a + * line, so a batch is bounded by count: 64 records stay under the limit unless they average over + * 512 KiB each. The SDK default of 512 would fail at 64 KiB each. + */ +const LOG_EXPORT_BATCH_SIZE = 64; + +export interface BootedSdk { + shutdown(): Promise; + bullmq: Telemetry; +} + +export interface BootOptions { + logScope: string; + ignoredPaths?: readonly string[]; + esmLoaderHook?: boolean; + exportLogs?: boolean; + metricViews?: MetricViewOptions; + instrumentations?: readonly Instrumentation[]; +} + +/** + * Registers the ESM loader hook (unless `esmLoaderHook` is false) and starts the Node SDK. Loaded + * only once telemetry is enabled, so a process without an endpoint never pays for the SDK and its + * instrumentations. With `exportLogs`, every line the shared logger writes is also sent over OTLP + * under `logScope`. + */ +export async function bootSdk(config: TelemetryConfig, options: BootOptions): Promise { + const { logScope, esmLoaderHook = true, exportLogs = true } = options; + // A message channel limits import-in-the-middle to modules the instrumentations register, + // instead of wrapping every ESM module the app loads. + const hook = esmLoaderHook ? createAddHookMessageChannel() : undefined; + if (hook) register("import-in-the-middle/hook.mjs", import.meta.url, hook.registerOptions); + const sdk = new NodeSDK({ + resource: resourceFromAttributes({ + [ATTR_SERVICE_NAME]: config.serviceName, + [ATTR_SERVICE_VERSION]: config.serviceVersion, + [ATTR_SERVICE_INSTANCE_ID]: config.serviceInstanceId, + "deployment.environment.name": config.environment, + }), + spanProcessors: [new BatchSpanProcessor(new RedactingSpanExporter(new OTLPTraceExporter()))], + metricReaders: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + exportIntervalMillis: 60_000, + }), + ], + views: metricViews(options.metricViews), + // Never omitted: an omitted list makes NodeSDK build its own log exporter from env. Records + // come only from the logger's scrubbed output (see log-otlp.ts), never from an + // instrumentation that sees a log call before it is scrubbed. + logRecordProcessors: exportLogs + ? [ + new ScopedLogRecordProcessor( + new BatchLogRecordProcessor({ + exporter: new OTLPLogExporter(), + maxExportBatchSize: LOG_EXPORT_BATCH_SIZE, + }), + logScope, + ), + ] + : [], + instrumentations: buildInstrumentations(options.ignoredPaths ?? [], options.instrumentations), + }); + sdk.start(); + const booted = { + shutdown: () => { + setLogLineSink(undefined); + // After the records the sink has already queued for emission. + return Promise.resolve().then(() => sdk.shutdown()); + }, + bullmq: new BullMQOtel({ tracerName: logScope }), + }; + if (exportLogs) setLogLineSink(logLineSink(logs.getLogger(logScope))); + if (hook) await acknowledgedWithin(hook.waitForAllMessagesAcknowledged, HOOK_ACK_TIMEOUT_MS); + return booted; +} + +/** + * Abandons a loader acknowledgement that never arrives, so boot cannot hang. The wait keeps a + * referenced interval alive until the ack lands; on timeout it is unreferenced, so an abandoned + * ack never holds the process open. + */ +async function acknowledgedWithin(wait: () => Promise, timeoutMs: number): Promise { + const { result: ack, intervals } = captureIntervals(wait); + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), timeoutMs); + timer.unref(); + }); + try { + if ((await Promise.race([ack, timeout])) === "timeout") { + for (const interval of intervals) interval.unref(); + logSafely("warn", { event: "telemetry_hook_ack_timeout" }, "telemetry_hook_ack_timeout"); + } + } finally { + clearTimeout(timer); + } +} + +/** Runs `fn` and returns the intervals it created synchronously. */ +function captureIntervals(fn: () => T): { result: T; intervals: NodeJS.Timeout[] } { + const intervals: NodeJS.Timeout[] = []; + const original = globalThis.setInterval; + globalThis.setInterval = ((...args: Parameters) => { + const interval = original(...args); + intervals.push(interval); + return interval; + }) as typeof setInterval; + try { + return { result: fn(), intervals }; + } finally { + globalThis.setInterval = original; + } +} diff --git a/src/sdk.ts b/src/sdk.ts new file mode 100644 index 0000000..16ad178 --- /dev/null +++ b/src/sdk.ts @@ -0,0 +1,109 @@ +import { diag, DiagLogLevel } from "@opentelemetry/api"; +import type { Telemetry } from "bullmq"; +import { resolveTelemetryConfig, type TelemetryConfig } from "./config.js"; +import type { BootedSdk } from "./sdk-boot.js"; +import { diagLogger, logSafely } from "./log-safely.js"; +import { addSensitiveKeys } from "./log-redact.js"; +import { applyOptions, logScope, type TelemetryOptions } from "./options.js"; +import { startProfiling, stopProfiling } from "./profiling.js"; + +let sdk: BootedSdk | undefined; +let started: TelemetryConfig | undefined; + +/** + * Boots the OpenTelemetry Node SDK. Never rejects: a failed start is logged and the config + * is returned. Telemetry starts once per process; a restart after `shutdownTelemetry` is not + * supported (shutdown resets state only so tests can run in sequence). The ESM loader hook is + * registered here, so this module must run under `node --import` before the app loads `http`. + * The SDK itself is imported only once telemetry is enabled. A host that runs bundled CommonJS + * (Next.js) passes `esmLoaderHook: false`. The log scope, `redactKeys` and `onError` apply even + * when export is off, since they shape stdout and error reporting too. + */ +export async function startTelemetry(options: TelemetryOptions): Promise { + if (started) return started; + applyOptions(options); + if (options.redactKeys) addSensitiveKeys(options.redactKeys); + const env = options.env ?? process.env; + const config = resolveTelemetryConfig(options.serviceName, env); + if (!config.enabled) { + await startProfiling(config, env); + return config; + } + started = config; + installDiagLogger(env.OTEL_LOG_LEVEL); + try { + const { bootSdk } = await import("./sdk-boot.js"); + sdk = await bootSdk(config, { + logScope: logScope(), + ignoredPaths: options.ignoredPaths ?? [], + esmLoaderHook: options.esmLoaderHook !== false, + exportLogs: options.logs !== false, + metricViews: options.metricViews, + instrumentations: options.instrumentations, + }); + logSafely( + "info", + { + event: "telemetry_started", + serviceName: config.serviceName, + endpointHost: hostOf(env.OTEL_EXPORTER_OTLP_ENDPOINT), + }, + "telemetry_started", + ); + } catch (error) { + sdk = undefined; + logSafely("warn", { err: error, event: "telemetry_start_failed" }, "telemetry_start_failed"); + } + await startProfiling(config, env); + return config; +} + +/** True once `startTelemetry` has booted the SDK in this process. */ +export function isTelemetryStarted(): boolean { + return sdk !== undefined; +} + +/** The BullMQ adapter built by `startTelemetry`; undefined until telemetry has started. */ +export function startedBullmqTelemetry(): Telemetry | undefined { + return sdk?.bullmq; +} + +/** + * Flushes pending spans, metrics, log records and profiles, bounded so a dead collector cannot + * stall a shutdown. + */ +export async function shutdownTelemetry(timeoutMs = 5_000): Promise { + started = undefined; + const running = sdk; + sdk = undefined; + const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs).unref()); + await Promise.race([ + Promise.all([ + stopProfiling(), + Promise.resolve() + .then(() => running?.shutdown()) + .catch(() => undefined), + ]).then(() => undefined), + timeout, + ]); +} + +/** SDK warnings (a rejected export, a bad option) go through the redacting logger. When + * `OTEL_LOG_LEVEL` is set, NodeSDK installs its own console logger at that level for debugging, + * so this default stays out of its way. */ +function installDiagLogger(level: string | undefined): void { + if (level?.trim()) return; + diag.setLogger(diagLogger, { + logLevel: DiagLogLevel.WARN, + suppressOverrideMessage: true, + }); +} + +/** The endpoint's host only: a URL can carry credentials in its userinfo or query. */ +function hostOf(endpoint: string | undefined): string { + try { + return new URL(endpoint?.trim() ?? "").host; + } catch { + return "invalid"; + } +} diff --git a/src/shared-log.ts b/src/shared-log.ts new file mode 100644 index 0000000..025071a --- /dev/null +++ b/src/shared-log.ts @@ -0,0 +1,51 @@ +import type { Logger } from "pino"; +import { announceLogLevel, createLogger } from "./logger.js"; + +let configuredService: string | undefined; + +/** The configured service name, else `OTEL_SERVICE_NAME`, else "unknown". */ +export function currentService(): string { + return configuredService ?? (process.env.OTEL_SERVICE_NAME?.trim() || "unknown"); +} + +/** + * The one logger every process shares. Apps import it directly; modules and packages log through a + * child that binds `component` (`log.child({ component: "db" })`), so their lines carry the + * process's name in `app`. + */ +export const log: Logger = createLogger(currentService); + +/** + * Names the process on every log line (the `app` field), including lines from loggers created + * before this call. Each entry point calls it once. The first name wins: a later call with a + * different name is ignored and warns, so no imported module can rename the process. + */ +export function configureLogger({ service }: { service: string }): void { + if (configuredService === undefined) { + configuredService = service; + announceLogLevel(log); + return; + } + if (service !== configuredService) { + log.warn( + { event: "logger_reconfigure_ignored", requested: service }, + "logger already configured, keeping the first service name", + ); + } +} + +/** Resolves once buffered log lines are written, or after `timeoutMs`; never rejects. */ +export function flushLog(timeoutMs = 1_000): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + try { + log.flush(() => { + clearTimeout(timer); + resolve(); + }); + } catch { + clearTimeout(timer); + resolve(); + } + }); +} diff --git a/src/testing.ts b/src/testing.ts new file mode 100644 index 0000000..8c4f7dc --- /dev/null +++ b/src/testing.ts @@ -0,0 +1,14 @@ +import type { Logger } from "pino"; +import { createLogger } from "./logger.js"; +import { currentService } from "./shared-log.js"; + +/** A logger that captures parsed records in memory, for tests. */ +export function createTestLogger(): { logger: Logger; records: Record[] } { + const records: Record[] = []; + const logger = createLogger(currentService, { + write(line: string) { + records.push(JSON.parse(line) as Record); + }, + }); + return { logger, records }; +} diff --git a/src/trace-context.ts b/src/trace-context.ts new file mode 100644 index 0000000..e2bc8a5 --- /dev/null +++ b/src/trace-context.ts @@ -0,0 +1,87 @@ +import { + context, + defaultTextMapGetter, + defaultTextMapSetter, + isSpanContextValid, + ROOT_CONTEXT, + SpanStatusCode, + trace, + type Link, +} from "@opentelemetry/api"; +import { unsuppressTracing, W3CTraceContextPropagator } from "@opentelemetry/core"; + +// The W3C propagator is used directly, not the global one, so a stored value is always a +// `traceparent` header whatever OTEL_PROPAGATORS says. +const w3c = new W3CTraceContextPropagator(); +const TRACER_NAME = "@codelittinc/otel-node"; + +/** The W3C `traceparent` of the active span, or undefined when no span is active. */ +export function currentTraceparent(): string | undefined { + const carrier: Record = {}; + w3c.inject(context.active(), carrier, defaultTextMapSetter); + return carrier.traceparent; +} + +/** A span link to a stored `traceparent`. A missing or malformed value gives no link. */ +export function linkFromTraceparent(value: string | null | undefined): Link | undefined { + if (!value) return undefined; + try { + const extracted = w3c.extract(ROOT_CONTEXT, { traceparent: value }, defaultTextMapGetter); + const spanContext = trace.getSpanContext(extracted); + return spanContext && isSpanContextValid(spanContext) ? { context: spanContext } : undefined; + } catch { + return undefined; + } +} + +/** + * Run `fn` inside a new active span that links to the trace in `traceparent`. A link, not a + * parent: the work runs later and apart from the request that stored the value. + */ +export function withLinkedSpan( + name: string, + traceparent: string | null | undefined, + fn: () => Promise, +): Promise { + const link = linkFromTraceparent(traceparent); + return trace + .getTracer(TRACER_NAME) + .startActiveSpan(name, { links: link ? [link] : [] }, async (span) => { + try { + return await fn(); + } catch (err) { + span.recordException(err instanceof Error ? err : String(err)); + span.setStatus({ code: SpanStatusCode.ERROR }); + throw err; + } finally { + span.end(); + } + }); +} + +/** + * Run `fn` inside a new active root span, recorded even where the HTTP instrumentation suppressed + * tracing for an ignored path. The span ends with ERROR when `fn` throws or `failed` holds for its + * result, else OK. + */ +export function withRootSpan( + name: string, + fn: () => Promise, + failed: (result: T) => boolean = () => false, +): Promise { + return trace + .getTracer(TRACER_NAME) + .startActiveSpan(name, { root: true }, unsuppressTracing(context.active()), async (span) => { + try { + const result = await fn(); + span.setStatus({ code: failed(result) ? SpanStatusCode.ERROR : SpanStatusCode.OK }); + return result; + } catch (err) { + span.recordException(err instanceof Error ? err : String(err)); + span.setStatus({ code: SpanStatusCode.ERROR }); + throw err; + } finally { + span.end(); + } + }); +} diff --git a/src/url-credentials.ts b/src/url-credentials.ts new file mode 100644 index 0000000..80f28aa --- /dev/null +++ b/src/url-credentials.ts @@ -0,0 +1,163 @@ +// URL userinfo and query removal, shared by the message scrub and the URL value scrub. It imports +// nothing, so a browser bundle can load it from the `./url-credentials` entry. +// +// Every scan moves forward through the text once, so a scrub is linear in the input length. + +// Free text splits into URL tokens at a space or a line break only, so a tab or another space +// inside a password does not end its URL. +export const URL_TOKEN = /[^ \n\r]+/g; +// Where a URL's authority starts: after a scheme's `://` or JSON-escaped `:\/\/` (matched from +// the start of its run of scheme characters); after a scheme's `:/`, or a `//`, at the start of a +// token or after a delimiter. +export const URL_START = + /(?]/; +// A host and path also end at any whitespace. +export const URL_END = /[\s`<>]/; +// A quote ends a URL only at the token's end or before whitespace or a JSON or list delimiter. +export const QUOTE_END = /["'](?=$|[\s,;:}\])>"'&])/y; +// A `'` before `)` or `'`, which an encoded password keeps raw, does not end userinfo. +export const USERINFO_QUOTE = /'[)']/y; +// Text before an authority's first `/`, `?`, `#` or `\` that reads as a host and an optional port +// (a bracketed IPv6 host, or a non-empty host with no `:`, then `:` and digits or nothing). +export const HOST_PORT = /^(?:\[[^\]]*\]|[^:[\]]+)(?::\d*)?$/; +// The leading `scheme:` chain and one or two `/` (or `\/\/`), or a bare `//`, of a URL value. +export const URL_AUTHORITY = /^\s*(?:(?:[0-9+.-]*[a-z][a-z0-9+.-]*:)+(?:\/\/?|\\\/\\\/)|\/\/)/i; +export const QUERY_START = /[?#;]/; + +type Finder = (from: number) => number; + +/** The first index at or after `from` whose character passes `test`, or the text's length. */ +function finder(text: string, test: (char: string, index: number) => boolean): Finder { + let lastFrom = -1; + let found = -1; + return (from) => { + // Calls whose `from` never decreases reuse the last result, which keeps a scan linear. + if (from < lastFrom || found < from) { + found = from; + while (found < text.length && !test(text[found]!, found)) found++; + } + lastFrom = from; + return found; + }; +} + +const isDelimiter = (char: string) => char === "/" || char === "?" || char === "#" || char === "\\"; + +interface Scanner { + at: Finder; + delimiter: Finder; + slash: Finder; + queryValue: Finder; + delimiterAfterAt: Finder; +} + +function scanner(text: string): Scanner { + return { + at: finder(text, (char) => char === "@"), + delimiter: finder(text, isDelimiter), + slash: finder(text, (char) => char === "/"), + queryValue: finder(text, (char) => char === "=" || char === "/"), + delimiterAfterAt: finder(text, isDelimiter), + }; +} + +/** + * Where the authority at `from` continues past its userinfo, which runs to the last `@` before + * the first `/`, `?`, `#` or `\` after its first `@`, within `end`. Text before a delimiter that + * comes ahead of the first `@` is a host, not userinfo: + * - with a `:` or a leading `[`, when HOST_PORT matches and the head is bracketed, the delimiter + * is `/` or an escaped `\/`, or a `?` or `#` delimiter has a `=` or `/` after it before the `@`; + * - with neither, when a `/` comes before the `@`. + * A `//` or `://` inside userinfo starts no URL. Returns `from` when there is no userinfo. + */ +function afterUserinfo(text: string, from: number, end: number, scan: Scanner): number { + const at = scan.at(from); + if (at >= end) return from; + const delimiter = scan.delimiter(from); + if (delimiter < at) { + const head = text.slice(from, delimiter); + if (head.includes(":") || head.startsWith("[")) { + const sign = text[delimiter]; + const hostEvidence = + head.startsWith("[") || + sign === "/" || + (sign === "\\" ? text[delimiter + 1] === "/" : scan.queryValue(delimiter) < at); + if (hostEvidence && HOST_PORT.test(head)) return from; + } else if (scan.slash(delimiter) < at) { + return from; + } + } + const close = Math.min(scan.delimiterAfterAt(at + 1), end); + return text.lastIndexOf("@", close - 1) + 1; +} + +/** + * In a URL token, each URL loses its userinfo, and a URL with a `?`, `#` or `;` after its + * authority loses everything from there to the next whitespace. A host and path run to URL_END, + * QUOTE_END or the next URL's start, whichever comes first. + */ +export function scrubUrlToken(token: string): string { + URL_START.lastIndex = 0; + let start = URL_START.exec(token); + if (!start) return token; + const scan = scanner(token); + const quoteEnds = (char: string, index: number) => { + if (char !== '"' && char !== "'") return false; + QUOTE_END.lastIndex = index; + return QUOTE_END.test(token); + }; + const keptQuote = (index: number) => { + USERINFO_QUOTE.lastIndex = index; + return USERINFO_QUOTE.test(token); + }; + const userinfoEnd = finder( + token, + (char, i) => USERINFO_END.test(char) || (quoteEnds(char, i) && !keptQuote(i)), + ); + const urlEnd = finder(token, (char, i) => URL_END.test(char) || quoteEnds(char, i)); + const query = finder(token, (char) => QUERY_START.test(char)); + const space = finder(token, (char) => /\s/.test(char)); + let out = token.slice(0, start.index); + while (start) { + const authority = start.index + start[0].length; + const host = afterUserinfo(token, authority, userinfoEnd(authority), scan); + const end = urlEnd(host); + URL_START.lastIndex = host; + let next = URL_START.exec(token); + const cut = query(host); + out += token.slice(start.index, authority); + let resume = host; + if (cut < (next ? Math.min(next.index, end) : end)) { + out += token.slice(host, cut); + resume = space(cut); + if (next && next.index < resume) { + URL_START.lastIndex = resume; + next = URL_START.exec(token); + } + } + out += token.slice(resume, next ? next.index : token.length); + start = next; + } + return out; +} + +/** Every URL in free text, scrubbed as `scrubUrlToken` does. */ +export function scrubUrls(text: string): string { + return text.replace(URL_TOKEN, scrubUrlToken); +} + +/** + * A URL value without its userinfo. The authority starts after URL_AUTHORITY. With `schemeless`, + * a value without one that is not a path starts its authority at its first non-space character. + * The value is one URL, so its userinfo may run to the value's end. + */ +export function stripUserinfo(value: string, schemeless = false): string { + const lead = URL_AUTHORITY.exec(value); + const authority = lead ? lead[0].length : value.length - value.trimStart().length; + if (!lead && (!schemeless || value.startsWith("/", authority))) return value; + const host = afterUserinfo(value, authority, value.length, scanner(value)); + return value.slice(0, authority) + value.slice(host); +} diff --git a/src/url-path.ts b/src/url-path.ts new file mode 100644 index 0000000..1b8b3d5 --- /dev/null +++ b/src/url-path.ts @@ -0,0 +1,65 @@ +import { describePatterns } from "./scrub-descriptor.js"; + +/** + * Paths whose segments are bearer secrets, with the template each becomes. Anchored at the start + * of the path. An applicant intake token opens and submits an application for whoever holds it: + * the web page (`/apply/[token]`) and the api routes it calls. Any letter case and any run of `/` + * match, as a server routes them alike. + */ +export const TOKEN_PATH_TEMPLATES: readonly (readonly [RegExp, string])[] = [ + [/^\/+v1\/+applications\/+by-token\/+[^/?#]+/i, "/v1/applications/by-token/:token"], + [/^\/+apply\/+[^/?#]+/i, "/apply/[token]"], +]; + +// A `scheme:` chain and `//`, or a bare `//`, then the host. +const ORIGIN = /^((?:[a-z][a-z0-9+.-]*:)*\/\/[^/?#]*)(.*)$/is; + +// An `http.route` is a template only when it is a path. +const ROUTE_TEMPLATE_PREFIX = "/"; + +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats these. */ +export const URL_PATH_PATTERNS = describePatterns({ ORIGIN }); +export const URL_PATH_LITERALS = Object.freeze({ ROUTE_TEMPLATE_PREFIX }); +export const TOKEN_PATH_CONTRACT = Object.freeze( + TOKEN_PATH_TEMPLATES.map(([pattern, replacement]) => + Object.freeze({ source: pattern.source, flags: pattern.flags, replacement }), + ), +); + +/** Splits `scheme://host/path` or `//host/path` into its origin and the rest; a path has none. */ +function splitOrigin(value: string): [string, string] { + const match = ORIGIN.exec(value); + return match ? [match[1]!, match[2]!] : ["", value]; +} + +function templatePath(path: string, tokenPaths: readonly (readonly [RegExp, string])[]): string { + return tokenPaths.reduce((out, [pattern, template]) => out.replace(pattern, template), path); +} + +/** + * The URL with its path replaced by the route template when there is one, else with any token + * segment templated. The origin is kept; the query and fragment are the caller's to strip. A + * leading `//` may open a path rather than a host, so it is never kept beside a route, and a + * token path right after it is templated as a path. Leading whitespace is kept as it is. + */ +export function templateUrlPath( + value: string, + route: string | undefined, + tokenPaths: readonly (readonly [RegExp, string])[] = TOKEN_PATH_TEMPLATES, +): string { + const url = value.trimStart(); + const lead = value.slice(0, value.length - url.length); + const [origin, rest] = splitOrigin(url); + if (origin.startsWith("/")) { + if (route) return `${lead}${route}`; + const asPath = templatePath(url, tokenPaths); + if (asPath !== url) return `${lead}${asPath}`; + } + if (route) return `${lead}${origin}${route}`; + return `${lead}${origin}${templatePath(rest, tokenPaths)}`; +} + +/** A usable route template: a path, and not the shared label for unmatched requests. */ +export function routeTemplateOf(route: unknown): string | undefined { + return typeof route === "string" && route.startsWith(ROUTE_TEMPLATE_PREFIX) ? route : undefined; +} From 315ff0d4bfbf4d4dc80dfeca600e94677a266bce Mon Sep 17 00:00:00 2001 From: Victor Carvalho Date: Mon, 12 Oct 2026 22:27:46 -0300 Subject: [PATCH 03/12] feat: captureConsole and the register entry captureConsole sends console.warn and console.error lines through the logger's scrub to OTLP logs under the configured scope, and still writes them to the console. register starts telemetry under node --import from OTEL_SERVICE_NAME alone. Tests pin the defaults: no SDK module without an endpoint, a dead collector costs a request at most 50 ms, no metricViews keeps every attribute, and the scope filter keeps the default scope's lines and drops every other scope. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/console-capture.test.ts | 97 ++++++++++++++++++++++ src/__tests__/metric-views.test.ts | 36 +++++--- src/__tests__/no-endpoint.test.ts | 60 +++++++++++++ src/__tests__/otlp-export-helpers.ts | 49 +++++++++++ src/__tests__/unreachable-endpoint.test.ts | 72 ++++++++++++++++ src/console-capture.ts | 62 ++++++++++++++ src/index.ts | 1 + src/register.ts | 7 ++ 8 files changed, 373 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/console-capture.test.ts create mode 100644 src/__tests__/no-endpoint.test.ts create mode 100644 src/__tests__/otlp-export-helpers.ts create mode 100644 src/__tests__/unreachable-endpoint.test.ts create mode 100644 src/console-capture.ts create mode 100644 src/register.ts diff --git a/src/__tests__/console-capture.test.ts b/src/__tests__/console-capture.test.ts new file mode 100644 index 0000000..79c2f2e --- /dev/null +++ b/src/__tests__/console-capture.test.ts @@ -0,0 +1,97 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { logs } from "@opentelemetry/api-logs"; +import { captureConsole, releaseConsole } from "../console-capture.js"; +import { log } from "../shared-log.js"; +import { bootWithMemoryLogs, exported, flushLogs, stopTelemetry } from "./otlp-export-helpers.js"; + +vi.mock("@opentelemetry/exporter-logs-otlp-proto", async () => + (await import("./otlp-export-helpers.js")).memoryLogExporterModule(), +); + +const written = { warn: vi.fn(), error: vi.fn() }; +const original = { warn: console.warn, error: console.error }; + +beforeAll(async () => { + console.warn = written.warn; + console.error = written.error; + // No logScope: the scope defaults to the service name. + await bootWithMemoryLogs({ serviceName: "acp-api" }); + captureConsole(); + // The boot's own telemetry_started line. + await flushLogs(); +}); + +afterAll(async () => { + releaseConsole(); + console.warn = original.warn; + console.error = original.error; + await stopTelemetry(); +}); + +beforeEach(() => { + exported.length = 0; + written.warn.mockClear(); + written.error.mockClear(); +}); + +function bodies(): unknown[] { + return exported.map((record) => record.body); +} + +describe("captureConsole", () => { + // WHY: an app that logs with console must reach Loki without a rewrite, and stdout keeps the + // line it always had. + it("still writes the console line, unchanged", async () => { + console.error("payout failed for", "acct"); + expect(written.error).toHaveBeenCalledWith("payout failed for", "acct"); + await flushLogs(); + expect(bodies()).toEqual(["payout failed for acct"]); + }); + + it("scrubs an SSN-shaped value before export", async () => { + console.warn("screening returned 123-45-6789 for applicant"); + const records = await flushLogs(); + expect(records).toHaveLength(1); + expect(JSON.stringify(records[0]!.body)).not.toContain("123-45-6789"); + expect(records[0]!.body).toBe("screening returned [redacted] for applicant"); + expect(records[0]!.severityText).toBe("warn"); + }); + + it("redacts a PII key in an object it is given", async () => { + console.error("lookup failed", { email: "jane@example.com", phone: "555-0100", queue: "q" }); + const [record] = await flushLogs(); + expect(record!.body).not.toMatch(/jane|555-0100/); + expect(record!.body).toContain("queue: 'q'"); + }); + + it("exports an Error under err, scrubbed", async () => { + console.error(new Error("card 4111111111111111 declined")); + const [record] = await flushLogs(); + expect(JSON.stringify(record)).not.toContain("4111111111111111"); + expect(record!.attributes.err).toMatchObject({ type: "Error" }); + }); + + // WHY: with no logScope set, the scope filter must still keep the app's own lines. + it("exports a console line and a log line under the default scope", async () => { + console.error("from console"); + log.info("from the logger"); + const records = await flushLogs(); + expect(bodies()).toEqual(["from console", "from the logger"]); + expect(records.map((r) => r.instrumentationScope.name)).toEqual(["acp-api", "acp-api"]); + }); + + it("exports no record from a scope other than the log scope", async () => { + logs.getLogger("@opentelemetry/instrumentation-runtime-node").emit({ body: "raw jane@x.com" }); + log.info("kept"); + expect(bodies()).toEqual([]); + await flushLogs(); + expect(bodies()).toEqual(["kept"]); + }); + + it("leaves console.log and console.info alone", async () => { + vi.spyOn(console, "info").mockImplementation(() => undefined); + console.info("not captured"); + await flushLogs(); + expect(bodies()).toEqual([]); + }); +}); diff --git a/src/__tests__/metric-views.test.ts b/src/__tests__/metric-views.test.ts index 6c9b2f0..f47d2ca 100644 --- a/src/__tests__/metric-views.test.ts +++ b/src/__tests__/metric-views.test.ts @@ -16,7 +16,7 @@ const APP_VIEWS: MetricViewOptions = { async function exportedAttributes( instrument: string, attributes: Record, - options: MetricViewOptions | undefined = APP_VIEWS, + options: MetricViewOptions | undefined, ) { const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 }); @@ -35,20 +35,34 @@ async function exportedAttributes( // instrument would explode cardinality and put personal data in Prometheus, which keeps it. describe("metricViews", () => { it("exports only allowlisted labels from the app's prefixed instruments", async () => { - const attributes = await exportedAttributes("app.worker.job.duration", { - queue: "late-fees", - outcome: "failed", - tenantId: "tenant-123", - "error.message": "duplicate email jane@example.com", - }); + const attributes = await exportedAttributes( + "app.worker.job.duration", + { + queue: "late-fees", + outcome: "failed", + tenantId: "tenant-123", + "error.message": "duplicate email jane@example.com", + }, + APP_VIEWS, + ); expect(attributes).toEqual({ queue: "late-fees", outcome: "failed" }); }); it("leaves instruments outside the prefix to their own instrumentation", async () => { - const attributes = await exportedAttributes("http.server.request.duration", { - "http.route": "/v1/tenants", - "server.address": "api", - }); + const attributes = await exportedAttributes( + "http.server.request.duration", + { "http.route": "/v1/tenants", "server.address": "api" }, + APP_VIEWS, + ); expect(attributes).toEqual({ "http.route": "/v1/tenants", "server.address": "api" }); }); + + it("keeps every attribute when the app sets no metricViews", async () => { + const attributes = await exportedAttributes( + "app.worker.job.duration", + { queue: "late-fees", tenantId: "tenant-123" }, + undefined, + ); + expect(attributes).toEqual({ queue: "late-fees", tenantId: "tenant-123" }); + }); }); diff --git a/src/__tests__/no-endpoint.test.ts b/src/__tests__/no-endpoint.test.ts new file mode 100644 index 0000000..f86bcbd --- /dev/null +++ b/src/__tests__/no-endpoint.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const loaded = vi.hoisted(() => ({ modules: [] as string[] })); +const SDK_MODULES = vi.hoisted(() => [ + "@opentelemetry/sdk-node", + "@opentelemetry/sdk-metrics", + "@opentelemetry/sdk-trace-base", + "@opentelemetry/exporter-trace-otlp-proto", + "@opentelemetry/exporter-metrics-otlp-proto", + "@opentelemetry/exporter-logs-otlp-proto", + "@opentelemetry/instrumentation-http", + "@opentelemetry/instrumentation-undici", + "@opentelemetry/instrumentation-ioredis", + "@opentelemetry/instrumentation-pg", + "@opentelemetry/instrumentation-runtime-node", + "bullmq-otel", + "import-in-the-middle", +]); + +for (const name of SDK_MODULES) { + vi.doMock(name, () => { + loaded.modules.push(name); + return {}; + }); +} + +afterEach(async () => { + const { shutdownTelemetry } = await import("../sdk.js"); + await shutdownTelemetry(100); + vi.unstubAllEnvs(); +}); + +// WHY: a process with no endpoint (local runs, CI, an app before its stack is switched on) must +// not pay for loading the SDK, its exporters and instrumentations, whatever options it passes. +describe("with no endpoint", () => { + it("startTelemetry loads no SDK module, with every option set", async () => { + const telemetry = await import("../index.js"); + const config = await telemetry.startTelemetry({ + serviceName: "acp-api", + env: {}, + logScope: "acp", + metricViews: { instrumentPrefix: "acp.", allowedAttributes: ["queue"] }, + redactKeys: ["unitLabel"], + onError: () => undefined, + }); + telemetry.captureConsole(); + expect(config.enabled).toBe(false); + expect(telemetry.isTelemetryStarted()).toBe(false); + expect(loaded.modules).toEqual([]); + }); + + it("the register entry loads no SDK module", async () => { + vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", ""); + vi.stubEnv("OTEL_SERVICE_NAME", "acp-worker"); + await import("../register.js"); + const { isTelemetryStarted } = await import("../sdk.js"); + expect(isTelemetryStarted()).toBe(false); + expect(loaded.modules).toEqual([]); + }); +}); diff --git a/src/__tests__/otlp-export-helpers.ts b/src/__tests__/otlp-export-helpers.ts new file mode 100644 index 0000000..9b920e5 --- /dev/null +++ b/src/__tests__/otlp-export-helpers.ts @@ -0,0 +1,49 @@ +import { vi } from "vitest"; +import { logs } from "@opentelemetry/api-logs"; +import type { ReadableLogRecord } from "@opentelemetry/sdk-logs"; +import { shutdownTelemetry, startTelemetry } from "../sdk.js"; +import type { TelemetryOptions } from "../options.js"; + +/** Records the real log pipeline handed to its (mocked) OTLP exporter. */ +export const exported: ReadableLogRecord[] = []; + +/** An OTLP log exporter that keeps records in `exported`; install with `vi.mock`. */ +export function memoryLogExporterModule() { + return { + OTLPLogExporter: class { + export(records: ReadableLogRecord[], done: (result: { code: number }) => void): void { + exported.push(...records); + done({ code: 0 }); + } + shutdown(): Promise { + return Promise.resolve(); + } + forceFlush(): Promise { + return Promise.resolve(); + } + }, + }; +} + +/** Boots the real SDK against an unreachable collector, with logs going to `exported`. */ +export async function bootWithMemoryLogs(options: Omit): Promise { + vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:9"); + await startTelemetry({ + ...options, + esmLoaderHook: false, + env: { OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:9", OTEL_LOG_LEVEL: "NONE" }, + }); +} + +/** Exports everything emitted so far: the sink emits on a microtask, the batch on flush. */ +export async function flushLogs(): Promise { + await Promise.resolve(); + await (logs.getLoggerProvider() as { forceFlush?: () => Promise }).forceFlush?.(); + return exported; +} + +export async function stopTelemetry(): Promise { + await shutdownTelemetry(1_000); + logs.disable(); + vi.unstubAllEnvs(); +} diff --git a/src/__tests__/unreachable-endpoint.test.ts b/src/__tests__/unreachable-endpoint.test.ts new file mode 100644 index 0000000..d8695c1 --- /dev/null +++ b/src/__tests__/unreachable-endpoint.test.ts @@ -0,0 +1,72 @@ +import { createServer, request, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import { trace } from "@opentelemetry/api"; +import { log } from "../shared-log.js"; +import { isTelemetryStarted, shutdownTelemetry, startTelemetry } from "../sdk.js"; + +const UNREACHABLE = "http://127.0.0.1:9"; + +function serve(): Promise { + const server = createServer((_req, res) => { + trace.getTracer("probe").startActiveSpan("handler", (span) => { + log.info({ event: "request_handled" }, "handled"); + span.end(); + res.end("ok"); + }); + }); + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server))); +} + +function timedRequest(server: Server): Promise { + const { port } = server.address() as AddressInfo; + const started = performance.now(); + return new Promise((resolve, reject) => { + request({ host: "127.0.0.1", port, agent: false }, (res) => { + res.resume(); + res.on("end", () => resolve(performance.now() - started)); + }) + .on("error", reject) + .end(); + }); +} + +async function slowest(server: Server, count: number): Promise { + let worst = 0; + for (let i = 0; i < count; i++) worst = Math.max(worst, await timedRequest(server)); + return worst; +} + +afterAll(async () => { + await shutdownTelemetry(1_000); + vi.unstubAllEnvs(); +}); + +// WHY: a dead collector must cost a request nothing it waits on: every export is batched and +// asynchronous, so the request path never blocks on a connection that will not open. +describe("an unreachable endpoint", () => { + it("delays a request by at most 50 ms", async () => { + const server = await serve(); + try { + await timedRequest(server); + const baseline = await slowest(server, 5); + + vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", UNREACHABLE); + await startTelemetry({ + serviceName: "acp-api", + esmLoaderHook: false, + env: { OTEL_EXPORTER_OTLP_ENDPOINT: UNREACHABLE, OTEL_LOG_LEVEL: "NONE" }, + }); + expect(isTelemetryStarted()).toBe(true); + // Fill a log batch (64) and a span batch (512) so exports to the dead collector are in flight. + for (let i = 0; i < 600; i++) trace.getTracer("probe").startSpan("filler").end(); + for (let i = 0; i < 70; i++) log.info({ event: "filler" }, "filler"); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const withTelemetry = await slowest(server, 20); + expect(withTelemetry - baseline).toBeLessThanOrEqual(50); + } finally { + server.close(); + } + }, 15_000); +}); diff --git a/src/console-capture.ts b/src/console-capture.ts new file mode 100644 index 0000000..b5b8b98 --- /dev/null +++ b/src/console-capture.ts @@ -0,0 +1,62 @@ +import { formatWithOptions } from "node:util"; +import type { Logger } from "pino"; +import { createLogger } from "./logger.js"; +import { sanitizeLogValue } from "./log-redact.js"; +import { hasLogLineSink } from "./log-sink.js"; +import { currentService } from "./shared-log.js"; + +const LEVELS = ["warn", "error"] as const; +type Level = (typeof LEVELS)[number]; +type ConsoleMethod = (...args: unknown[]) => void; + +let originals: Partial> | undefined; + +/** + * Sends every `console.warn` and `console.error` line to OTLP logs, through the logger's scrub and + * under the configured log scope, and still writes it to the console unchanged. Only the scrubbed + * copy is exported; stdout keeps the app's own line. Calling it again changes nothing. + */ +export function captureConsole(): void { + if (originals) return; + originals = {}; + // Writes nothing itself: the line reaches only the OTLP sink. + const logger = createLogger(currentService, { write: () => undefined }).child({ + component: "console", + }); + for (const level of LEVELS) { + const original = console[level] as ConsoleMethod; + originals[level] = original; + console[level] = (...args: unknown[]): void => { + original.apply(console, args); + exportLine(logger, level, args); + }; + } +} + +/** Puts back the console methods `captureConsole` replaced. */ +export function releaseConsole(): void { + if (!originals) return; + for (const level of LEVELS) { + const original = originals[level]; + if (original) console[level] = original; + } + originals = undefined; +} + +/** + * The first Error goes under `err`; every other argument is sanitized like a log field (PII keys + * redacted, strings scrubbed) before it is formatted into the message. + */ +function exportLine(logger: Logger, level: Level, args: unknown[]): void { + if (!hasLogLineSink()) return; + try { + const err = args.find((arg) => arg instanceof Error); + const rest = args.filter((arg) => arg !== err).map(sanitizeLogValue); + const message = rest.length > 0 ? formatWithOptions({ colors: false }, ...rest) : undefined; + if (err === undefined) logger[level](message ?? ""); + else if (message === undefined) logger[level]({ err }); + else logger[level]({ err }, message); + } catch { + // the console line is already written + } +} diff --git a/src/index.ts b/src/index.ts index 3fc84ae..9ee81ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export { bullmqTelemetry } from "./bullmq.js"; export { setHttpRoute } from "./http-route.js"; export { getMeter, type BatchObservableResult, type Meter } from "./meter.js"; export { log, configureLogger, flushLog } from "./shared-log.js"; +export { captureConsole } from "./console-capture.js"; export { installFatalHandlers } from "./fatal.js"; export { boundedLabel } from "./bounded.js"; export { currentTraceparent, withLinkedSpan, withRootSpan } from "./trace-context.js"; diff --git a/src/register.ts b/src/register.ts new file mode 100644 index 0000000..3048de0 --- /dev/null +++ b/src/register.ts @@ -0,0 +1,7 @@ +// `node --import @codelittinc/otel-node/register`: starts telemetry before the app loads, for an +// app with no options to pass. `OTEL_SERVICE_NAME` names the service and is its log scope. +import { startTelemetry } from "./sdk.js"; + +const serviceName = process.env.OTEL_SERVICE_NAME?.trim() || "unknown"; + +await startTelemetry({ serviceName, logScope: serviceName }); From d173332b4125e035a25bcf29c719bd98e395bf2d Mon Sep 17 00:00:00 2001 From: Victor Carvalho Date: Mon, 12 Oct 2026 22:28:04 -0300 Subject: [PATCH 04/12] build: commit dist/ for install by commit Apps install the repo as a git dependency pinned to a commit, so the built output ships in the commit. The build drops source maps, whose sources are not in the installed package. Co-Authored-By: Claude Opus 5.5 --- dist/bounded.d.ts | 7 ++ dist/bounded.js | 14 +++ dist/bullmq.d.ts | 6 + dist/bullmq.js | 8 ++ dist/config.d.ts | 10 ++ dist/config.js | 17 +++ dist/console-capture.d.ts | 8 ++ dist/console-capture.js | 62 ++++++++++ dist/fatal.d.ts | 20 ++++ dist/fatal.js | 46 ++++++++ dist/http-route.d.ts | 6 + dist/http-route.js | 17 +++ dist/index.d.ts | 11 ++ dist/index.js | 9 ++ dist/instrumentations.d.ts | 6 + dist/instrumentations.js | 29 +++++ dist/log-errors.d.ts | 7 ++ dist/log-errors.js | 177 ++++++++++++++++++++++++++++ dist/log-fields.d.ts | 5 + dist/log-fields.js | 8 ++ dist/log-message.d.ts | 6 + dist/log-message.js | 57 +++++++++ dist/log-otlp.d.ts | 27 +++++ dist/log-otlp.js | 120 +++++++++++++++++++ dist/log-redact.d.ts | 30 +++++ dist/log-redact.js | 217 +++++++++++++++++++++++++++++++++++ dist/log-safely.d.ts | 7 ++ dist/log-safely.js | 25 ++++ dist/log-scrub.d.ts | 12 ++ dist/log-scrub.js | 76 ++++++++++++ dist/log-serializer.d.ts | 2 + dist/log-serializer.js | 20 ++++ dist/log-sink.d.ts | 10 ++ dist/log-sink.js | 29 +++++ dist/logger.d.ts | 15 +++ dist/logger.js | 99 ++++++++++++++++ dist/meter.d.ts | 7 ++ dist/meter.js | 8 ++ dist/metric-views.d.ts | 7 ++ dist/metric-views.js | 15 +++ dist/options.d.ts | 38 ++++++ dist/options.js | 28 +++++ dist/profiling.d.ts | 10 ++ dist/profiling.js | 51 ++++++++ dist/redact.d.ts | 23 ++++ dist/redact.js | 127 ++++++++++++++++++++ dist/redacting-exporter.d.ts | 17 +++ dist/redacting-exporter.js | 51 ++++++++ dist/register.d.ts | 1 + dist/register.js | 5 + dist/scrub-descriptor.d.ts | 7 ++ dist/scrub-descriptor.js | 14 +++ dist/sdk-boot.d.ts | 23 ++++ dist/sdk-boot.js | 123 ++++++++++++++++++++ dist/sdk.d.ts | 22 ++++ dist/sdk.js | 101 ++++++++++++++++ dist/shared-log.d.ts | 19 +++ dist/shared-log.js | 43 +++++++ dist/testing.d.ts | 6 + dist/testing.js | 12 ++ dist/trace-context.d.ts | 16 +++ dist/trace-context.js | 71 ++++++++++++ dist/url-credentials.d.ts | 23 ++++ dist/url-credentials.js | 147 ++++++++++++++++++++++++ dist/url-path.d.ts | 26 +++++ dist/url-path.js | 52 +++++++++ tsconfig.build.json | 4 + 67 files changed, 2322 insertions(+) create mode 100644 dist/bounded.d.ts create mode 100644 dist/bounded.js create mode 100644 dist/bullmq.d.ts create mode 100644 dist/bullmq.js create mode 100644 dist/config.d.ts create mode 100644 dist/config.js create mode 100644 dist/console-capture.d.ts create mode 100644 dist/console-capture.js create mode 100644 dist/fatal.d.ts create mode 100644 dist/fatal.js create mode 100644 dist/http-route.d.ts create mode 100644 dist/http-route.js create mode 100644 dist/index.d.ts create mode 100644 dist/index.js create mode 100644 dist/instrumentations.d.ts create mode 100644 dist/instrumentations.js create mode 100644 dist/log-errors.d.ts create mode 100644 dist/log-errors.js create mode 100644 dist/log-fields.d.ts create mode 100644 dist/log-fields.js create mode 100644 dist/log-message.d.ts create mode 100644 dist/log-message.js create mode 100644 dist/log-otlp.d.ts create mode 100644 dist/log-otlp.js create mode 100644 dist/log-redact.d.ts create mode 100644 dist/log-redact.js create mode 100644 dist/log-safely.d.ts create mode 100644 dist/log-safely.js create mode 100644 dist/log-scrub.d.ts create mode 100644 dist/log-scrub.js create mode 100644 dist/log-serializer.d.ts create mode 100644 dist/log-serializer.js create mode 100644 dist/log-sink.d.ts create mode 100644 dist/log-sink.js create mode 100644 dist/logger.d.ts create mode 100644 dist/logger.js create mode 100644 dist/meter.d.ts create mode 100644 dist/meter.js create mode 100644 dist/metric-views.d.ts create mode 100644 dist/metric-views.js create mode 100644 dist/options.d.ts create mode 100644 dist/options.js create mode 100644 dist/profiling.d.ts create mode 100644 dist/profiling.js create mode 100644 dist/redact.d.ts create mode 100644 dist/redact.js create mode 100644 dist/redacting-exporter.d.ts create mode 100644 dist/redacting-exporter.js create mode 100644 dist/register.d.ts create mode 100644 dist/register.js create mode 100644 dist/scrub-descriptor.d.ts create mode 100644 dist/scrub-descriptor.js create mode 100644 dist/sdk-boot.d.ts create mode 100644 dist/sdk-boot.js create mode 100644 dist/sdk.d.ts create mode 100644 dist/sdk.js create mode 100644 dist/shared-log.d.ts create mode 100644 dist/shared-log.js create mode 100644 dist/testing.d.ts create mode 100644 dist/testing.js create mode 100644 dist/trace-context.d.ts create mode 100644 dist/trace-context.js create mode 100644 dist/url-credentials.d.ts create mode 100644 dist/url-credentials.js create mode 100644 dist/url-path.d.ts create mode 100644 dist/url-path.js diff --git a/dist/bounded.d.ts b/dist/bounded.d.ts new file mode 100644 index 0000000..419ba88 --- /dev/null +++ b/dist/bounded.d.ts @@ -0,0 +1,7 @@ +/** The label every value outside an allowlist collapses into. */ +export declare const OTHER_LABEL = "other"; +/** + * A metric label from a closed set: the value itself when the allowlist holds it, otherwise + * `"other"`. Keeps raw paths, ids and error text from ever becoming a label value. + */ +export declare function boundedLabel(value: unknown, allowlist: ReadonlySet | readonly T[]): T | typeof OTHER_LABEL; diff --git a/dist/bounded.js b/dist/bounded.js new file mode 100644 index 0000000..347a14e --- /dev/null +++ b/dist/bounded.js @@ -0,0 +1,14 @@ +/** The label every value outside an allowlist collapses into. */ +export const OTHER_LABEL = "other"; +/** + * A metric label from a closed set: the value itself when the allowlist holds it, otherwise + * `"other"`. Keeps raw paths, ids and error text from ever becoming a label value. + */ +export function boundedLabel(value, allowlist) { + if (typeof value !== "string") + return OTHER_LABEL; + const allowed = allowlist instanceof Set + ? allowlist.has(value) + : allowlist.includes(value); + return allowed ? value : OTHER_LABEL; +} diff --git a/dist/bullmq.d.ts b/dist/bullmq.d.ts new file mode 100644 index 0000000..a62fd9e --- /dev/null +++ b/dist/bullmq.d.ts @@ -0,0 +1,6 @@ +import type { Telemetry } from "bullmq"; +/** + * BullMQ telemetry adapter for a Queue or Worker `telemetry` option. Undefined until + * telemetry has started, which leaves BullMQ on its untraced code path. + */ +export declare function bullmqTelemetry(): Telemetry | undefined; diff --git a/dist/bullmq.js b/dist/bullmq.js new file mode 100644 index 0000000..3891deb --- /dev/null +++ b/dist/bullmq.js @@ -0,0 +1,8 @@ +import { startedBullmqTelemetry } from "./sdk.js"; +/** + * BullMQ telemetry adapter for a Queue or Worker `telemetry` option. Undefined until + * telemetry has started, which leaves BullMQ on its untraced code path. + */ +export function bullmqTelemetry() { + return startedBullmqTelemetry(); +} diff --git a/dist/config.d.ts b/dist/config.d.ts new file mode 100644 index 0000000..0712635 --- /dev/null +++ b/dist/config.d.ts @@ -0,0 +1,10 @@ +export type TelemetryEnv = Partial>; +export interface TelemetryConfig { + enabled: boolean; + serviceName: string; + serviceVersion: string; + environment: string; + serviceInstanceId: string; +} +/** Telemetry exports only when an OTLP endpoint is set; local runs, CI and tests stay silent. */ +export declare function resolveTelemetryConfig(fallbackServiceName: string, env?: TelemetryEnv): TelemetryConfig; diff --git a/dist/config.js b/dist/config.js new file mode 100644 index 0000000..730f1b3 --- /dev/null +++ b/dist/config.js @@ -0,0 +1,17 @@ +import { randomUUID } from "node:crypto"; +/** One id per process, so every signal from this process names the same instance. */ +const PROCESS_INSTANCE_ID = randomUUID(); +/** Telemetry exports only when an OTLP endpoint is set; local runs, CI and tests stay silent. */ +export function resolveTelemetryConfig(fallbackServiceName, env = process.env) { + const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim(); + const killed = env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true"; + return { + enabled: Boolean(endpoint) && !killed, + serviceName: env.OTEL_SERVICE_NAME?.trim() || fallbackServiceName, + // A `railway up` deploy carries no commit SHA; the deployment id still pins it. + serviceVersion: env.RAILWAY_GIT_COMMIT_SHA?.trim() || env.RAILWAY_DEPLOYMENT_ID?.trim() || "unknown", + environment: env.RAILWAY_ENVIRONMENT_NAME?.trim() || "unknown", + // Splits runtime panels by replica; Prometheus maps it to the `instance` label. + serviceInstanceId: env.RAILWAY_REPLICA_ID?.trim() || PROCESS_INSTANCE_ID, + }; +} diff --git a/dist/console-capture.d.ts b/dist/console-capture.d.ts new file mode 100644 index 0000000..3d57e7c --- /dev/null +++ b/dist/console-capture.d.ts @@ -0,0 +1,8 @@ +/** + * Sends every `console.warn` and `console.error` line to OTLP logs, through the logger's scrub and + * under the configured log scope, and still writes it to the console unchanged. Only the scrubbed + * copy is exported; stdout keeps the app's own line. Calling it again changes nothing. + */ +export declare function captureConsole(): void; +/** Puts back the console methods `captureConsole` replaced. */ +export declare function releaseConsole(): void; diff --git a/dist/console-capture.js b/dist/console-capture.js new file mode 100644 index 0000000..573b19e --- /dev/null +++ b/dist/console-capture.js @@ -0,0 +1,62 @@ +import { formatWithOptions } from "node:util"; +import { createLogger } from "./logger.js"; +import { sanitizeLogValue } from "./log-redact.js"; +import { hasLogLineSink } from "./log-sink.js"; +import { currentService } from "./shared-log.js"; +const LEVELS = ["warn", "error"]; +let originals; +/** + * Sends every `console.warn` and `console.error` line to OTLP logs, through the logger's scrub and + * under the configured log scope, and still writes it to the console unchanged. Only the scrubbed + * copy is exported; stdout keeps the app's own line. Calling it again changes nothing. + */ +export function captureConsole() { + if (originals) + return; + originals = {}; + // Writes nothing itself: the line reaches only the OTLP sink. + const logger = createLogger(currentService, { write: () => undefined }).child({ + component: "console", + }); + for (const level of LEVELS) { + const original = console[level]; + originals[level] = original; + console[level] = (...args) => { + original.apply(console, args); + exportLine(logger, level, args); + }; + } +} +/** Puts back the console methods `captureConsole` replaced. */ +export function releaseConsole() { + if (!originals) + return; + for (const level of LEVELS) { + const original = originals[level]; + if (original) + console[level] = original; + } + originals = undefined; +} +/** + * The first Error goes under `err`; every other argument is sanitized like a log field (PII keys + * redacted, strings scrubbed) before it is formatted into the message. + */ +function exportLine(logger, level, args) { + if (!hasLogLineSink()) + return; + try { + const err = args.find((arg) => arg instanceof Error); + const rest = args.filter((arg) => arg !== err).map(sanitizeLogValue); + const message = rest.length > 0 ? formatWithOptions({ colors: false }, ...rest) : undefined; + if (err === undefined) + logger[level](message ?? ""); + else if (message === undefined) + logger[level]({ err }); + else + logger[level]({ err }, message); + } + catch { + // the console line is already written + } +} diff --git a/dist/fatal.d.ts b/dist/fatal.d.ts new file mode 100644 index 0000000..6d5c982 --- /dev/null +++ b/dist/fatal.d.ts @@ -0,0 +1,20 @@ +interface FatalOptions { + /** Synchronous: stop taking new work (a BullMQ `worker.close()`, not awaited). */ + stopIntake?: () => void; + /** + * Reports the fatal error; called once, before the flush. Defaults to the `onError` hook given + * to `startTelemetry`. + */ + report?: (err: unknown) => void; + /** Flushes telemetry; raced against the same budget as the log flush. */ + flushTelemetry?: (timeoutMs: number) => Promise; + exit?: (code: number) => void; + target?: Pick; +} +/** + * Logs an uncaught exception or unhandled rejection as one JSON line under `err`, stops intake, + * reports it, spends at most two seconds flushing, then exits 1. A second fatal while exiting is + * logged only. + */ +export declare function installFatalHandlers(options?: FatalOptions): void; +export {}; diff --git a/dist/fatal.js b/dist/fatal.js new file mode 100644 index 0000000..0285267 --- /dev/null +++ b/dist/fatal.js @@ -0,0 +1,46 @@ +import { flushLog, log } from "./shared-log.js"; +import { reportError } from "./options.js"; +/** Total time a fatal exit may spend flushing logs and telemetry. */ +const FATAL_FLUSH_MS = 2_000; +/** + * Logs an uncaught exception or unhandled rejection as one JSON line under `err`, stops intake, + * reports it, spends at most two seconds flushing, then exits 1. A second fatal while exiting is + * logged only. + */ +export function installFatalHandlers(options = {}) { + const { stopIntake, report = (err) => reportError(err), flushTelemetry, exit = (code) => process.exit(code), } = options; + const target = options.target ?? process; + let exiting = false; + const fatal = (err, message) => { + try { + log.fatal({ err }, message); + } + catch { + // the exit still has to happen + } + if (exiting) + return; + exiting = true; + process.exitCode = 1; + try { + stopIntake?.(); + } + catch { + // a failing close must not keep a crashed process alive + } + try { + report(err); + } + catch { + // reporting must not keep a crashed process alive + } + const budget = new Promise((resolve) => setTimeout(resolve, FATAL_FLUSH_MS).unref()); + const flushes = Promise.allSettled([ + flushLog(FATAL_FLUSH_MS), + Promise.resolve().then(() => flushTelemetry?.(FATAL_FLUSH_MS)), + ]); + void Promise.race([flushes, budget]).then(() => exit(1)); + }; + target.on("unhandledRejection", (reason) => fatal(reason, "unhandled rejection")); + target.on("uncaughtException", (err) => fatal(err, "uncaught exception")); +} diff --git a/dist/http-route.d.ts b/dist/http-route.d.ts new file mode 100644 index 0000000..a07afe6 --- /dev/null +++ b/dist/http-route.d.ts @@ -0,0 +1,6 @@ +/** + * Labels the active HTTP server span and its request metrics with a route template, and tags the + * span with `.request_id`. Call it once routing has finished; a no-op when no span or + * HTTP context is active. + */ +export declare function setHttpRoute(route: string, method: string, requestId?: string): void; diff --git a/dist/http-route.js b/dist/http-route.js new file mode 100644 index 0000000..a07eb5b --- /dev/null +++ b/dist/http-route.js @@ -0,0 +1,17 @@ +import { context, trace } from "@opentelemetry/api"; +import { getRPCMetadata, RPCType } from "@opentelemetry/core"; +import { logScope } from "./options.js"; +/** + * Labels the active HTTP server span and its request metrics with a route template, and tags the + * span with `.request_id`. Call it once routing has finished; a no-op when no span or + * HTTP context is active. + */ +export function setHttpRoute(route, method, requestId) { + const rpc = getRPCMetadata(context.active()); + if (rpc?.type === RPCType.HTTP) + rpc.route = route; + const span = trace.getActiveSpan(); + span?.updateName(`${method} ${route}`); + if (requestId) + span?.setAttribute(`${logScope()}.request_id`, requestId); +} diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..9ee81ba --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,11 @@ +export { startTelemetry, shutdownTelemetry, isTelemetryStarted } from "./sdk.js"; +export type { TelemetryConfig, TelemetryEnv } from "./config.js"; +export type { ErrorHookContext, MetricViewOptions, TelemetryOptions } from "./options.js"; +export { bullmqTelemetry } from "./bullmq.js"; +export { setHttpRoute } from "./http-route.js"; +export { getMeter, type BatchObservableResult, type Meter } from "./meter.js"; +export { log, configureLogger, flushLog } from "./shared-log.js"; +export { captureConsole } from "./console-capture.js"; +export { installFatalHandlers } from "./fatal.js"; +export { boundedLabel } from "./bounded.js"; +export { currentTraceparent, withLinkedSpan, withRootSpan } from "./trace-context.js"; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..5a504cf --- /dev/null +++ b/dist/index.js @@ -0,0 +1,9 @@ +export { startTelemetry, shutdownTelemetry, isTelemetryStarted } from "./sdk.js"; +export { bullmqTelemetry } from "./bullmq.js"; +export { setHttpRoute } from "./http-route.js"; +export { getMeter } from "./meter.js"; +export { log, configureLogger, flushLog } from "./shared-log.js"; +export { captureConsole } from "./console-capture.js"; +export { installFatalHandlers } from "./fatal.js"; +export { boundedLabel } from "./bounded.js"; +export { currentTraceparent, withLinkedSpan, withRootSpan } from "./trace-context.js"; diff --git a/dist/instrumentations.d.ts b/dist/instrumentations.d.ts new file mode 100644 index 0000000..5b3122a --- /dev/null +++ b/dist/instrumentations.d.ts @@ -0,0 +1,6 @@ +import type { Instrumentation } from "@opentelemetry/instrumentation"; +/** + * Hand-picked list: the auto-instrumentations bundle adds fs, dns and net spans that are noise. + * The app's own instrumentations (Prisma, at the app's version) come last. + */ +export declare function buildInstrumentations(ignoredPaths: readonly string[], appInstrumentations?: readonly Instrumentation[]): Instrumentation[]; diff --git a/dist/instrumentations.js b/dist/instrumentations.js new file mode 100644 index 0000000..a643727 --- /dev/null +++ b/dist/instrumentations.js @@ -0,0 +1,29 @@ +import { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; +import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici"; +import { IORedisInstrumentation } from "@opentelemetry/instrumentation-ioredis"; +import { PgInstrumentation } from "@opentelemetry/instrumentation-pg"; +import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node"; +/** An ignored path covers itself and everything below it, never a sibling sharing a prefix. */ +function isIgnored(url, ignoredPaths) { + const path = (url ?? "").split("?")[0]; + return ignoredPaths.some((ignored) => path === ignored || path.startsWith(`${ignored}/`)); +} +/** + * Hand-picked list: the auto-instrumentations bundle adds fs, dns and net spans that are noise. + * The app's own instrumentations (Prisma, at the app's version) come last. + */ +export function buildInstrumentations(ignoredPaths, appInstrumentations = []) { + return [ + // Event-loop delay, heap, GC. Its uncaught-exception capture stays off: it would emit the raw, + // unscrubbed exception message and stack as a log record. + new RuntimeNodeInstrumentation({ captureUncaughtException: false }), + new HttpInstrumentation({ + ignoreIncomingRequestHook: (request) => isIgnored(request.url, ignoredPaths), + }), + new UndiciInstrumentation(), + new IORedisInstrumentation({ requireParentSpan: true }), + // SQL text only, never bound values (Prisma 7 runs every query through `pg`). + new PgInstrumentation({ enhancedDatabaseReporting: false, requireParentSpan: true }), + ...appInstrumentations, + ]; +} diff --git a/dist/log-errors.d.ts b/dist/log-errors.d.ts new file mode 100644 index 0000000..f7feea0 --- /dev/null +++ b/dist/log-errors.d.ts @@ -0,0 +1,7 @@ +/** + * The message safe to log for an error: code-only for value-embedding families, else scrubbed. + * An empty message falls back to the error type. + */ +export declare function safeErrorMessage(value: unknown): string; +/** An allowlisted, scrubbed plain copy of an error or error-like value. May throw; see serializeError. */ +export declare function serializeErrorBase(value: unknown): unknown; diff --git a/dist/log-errors.js b/dist/log-errors.js new file mode 100644 index 0000000..93743c7 --- /dev/null +++ b/dist/log-errors.js @@ -0,0 +1,177 @@ +import { scrubErrorText, scrubStack } from "./log-scrub.js"; +const MAX_CAUSE_DEPTH = 10; +const MAX_AGGREGATE_ERRORS = 20; +/** The only properties any error keeps, besides type, name, message, stack, cause and errors. */ +const COMMON_KEYS = ["code", "errorCode", "status", "statusCode"]; +function read(target, key) { + try { + return target[key]; + } + catch { + return undefined; + } +} +function asRecord(value) { + return typeof value === "object" && value !== null ? value : undefined; +} +function str(value) { + return typeof value === "string" ? value : undefined; +} +const PRISMA = { + keys: [], + message: (err, name) => { + const code = str(read(err, "code")) ?? str(read(err, "errorCode")); + return code ? `${name} ${code}` : name; + }, +}; +const DRIVER_ADAPTER = { + keys: [], + dropCause: true, + message: (err, name) => { + const cause = read(err, "cause"); + const code = str(read(cause, "originalCode")) ?? str(read(cause, "kind")); + return code ? `${name} ${code}` : name; + }, +}; +const ZOD = { + keys: [], + message: (err, name) => { + const issues = read(err, "issues"); + return `${name} ${Array.isArray(issues) ? issues.length : 0} issue(s)`; + }, +}; +const STRIPE = { keys: ["decline_code", "param", "requestId"] }; +const HAPPYCO = { keys: ["outcome"] }; +const FUNNEL = { keys: ["api", "errorType"] }; +const GENERIC = { keys: [] }; +function familyOf(name, err) { + if (name.startsWith("Prisma")) + return PRISMA; + if (name === "DriverAdapterError") + return DRIVER_ADAPTER; + if (name === "ZodError") + return ZOD; + if (name.startsWith("Stripe") || str(read(err, "type"))?.startsWith("Stripe")) + return STRIPE; + if (name.startsWith("HappyCo")) + return HAPPYCO; + if (name.startsWith("Funnel")) + return FUNNEL; + return GENERIC; +} +function typeOf(err, name) { + if (err instanceof Error) { + const ctor = read(err, "constructor"); + const ctorName = typeof ctor === "function" ? ctor.name : undefined; + if (ctorName) + return ctorName; + } + return name ?? "Error"; +} +function primitive(value) { + if (typeof value === "string") + return scrubErrorText(value); + return typeof value === "number" || typeof value === "boolean" ? value : undefined; +} +/** Prisma's meta names the failing column or model; every other meta field is a value. */ +function prismaMeta(err) { + const meta = asRecord(read(err, "meta")); + if (!meta) + return undefined; + const target = read(meta, "target"); + const modelName = read(meta, "modelName"); + const out = {}; + if (typeof target === "string" || Array.isArray(target)) + out.target = target; + if (typeof modelName === "string") + out.modelName = modelName; + return out; +} +function sanitizeValue(value, depth, ancestors) { + const record = asRecord(value); + if (!record) + return primitive(value); + if (ancestors.has(record)) + return "[circular]"; + if (depth >= MAX_CAUSE_DEPTH) + return "[truncated]"; + return sanitize(record, depth, ancestors); +} +/** A caller-built error-like object's name is free text; scrub it like a message. */ +function nameOf(err) { + const raw = str(read(err, "name")); + return { raw, name: raw === undefined ? undefined : scrubErrorText(raw) }; +} +/** Deny by default: an error keeps only the fields listed here; every other property is dropped. */ +function sanitize(err, depth, ancestors) { + ancestors.add(err); + try { + return sanitizeFields(err, depth, ancestors); + } + finally { + ancestors.delete(err); + } +} +function sanitizeFields(err, depth, ancestors) { + const { raw, name } = nameOf(err); + const family = familyOf(raw ?? "", err); + const out = { type: typeOf(err, name) }; + if (name !== undefined) + out.name = name; + const safe = family.message?.(err, name ?? "Error"); + const rawMessage = str(read(err, "message")); + if (safe !== undefined) + out.message = safe; + else if (rawMessage !== undefined) + out.message = scrubErrorText(rawMessage); + const stack = str(read(err, "stack")); + if (stack !== undefined) { + out.stack = scrubStack(stack, safe === undefined ? undefined : `${name ?? "Error"}: ${safe}`); + } + for (const key of [...COMMON_KEYS, ...family.keys]) { + const value = primitive(read(err, key)); + if (value !== undefined) + out[key] = value; + } + if (family === PRISMA) { + const meta = prismaMeta(err); + if (meta) + out.meta = meta; + } + const cause = family.dropCause ? undefined : read(err, "cause"); + if (cause !== undefined) { + const sanitized = sanitizeValue(cause, depth + 1, ancestors); + if (sanitized !== undefined) + out.cause = sanitized; + } + const errors = read(err, "errors"); + if (Array.isArray(errors)) { + out.errors = errors + .slice(0, MAX_AGGREGATE_ERRORS) + .map((item) => sanitizeValue(item, depth + 1, ancestors) ?? null); + } + return out; +} +/** + * The message safe to log for an error: code-only for value-embedding families, else scrubbed. + * An empty message falls back to the error type. + */ +export function safeErrorMessage(value) { + try { + const err = asRecord(value); + if (!err) + return scrubErrorText(String(value)); + const { raw, name } = nameOf(err); + const safe = familyOf(raw ?? "", err).message?.(err, name ?? "Error"); + const message = safe ?? scrubErrorText(String(read(err, "message") ?? "")); + return message === "" ? typeOf(err, name) : message; + } + catch { + return "[unserializable error]"; + } +} +/** An allowlisted, scrubbed plain copy of an error or error-like value. May throw; see serializeError. */ +export function serializeErrorBase(value) { + const err = asRecord(value); + return err ? sanitize(err, 0, new WeakSet()) : primitive(value); +} diff --git a/dist/log-fields.d.ts b/dist/log-fields.d.ts new file mode 100644 index 0000000..ecbe90f --- /dev/null +++ b/dist/log-fields.d.ts @@ -0,0 +1,5 @@ +/** Trace correlation fields for the active span; empty outside a valid span. */ +export declare function traceLogFields(): { + trace_id?: string; + span_id?: string; +}; diff --git a/dist/log-fields.js b/dist/log-fields.js new file mode 100644 index 0000000..de421d2 --- /dev/null +++ b/dist/log-fields.js @@ -0,0 +1,8 @@ +import { isSpanContextValid, trace } from "@opentelemetry/api"; +/** Trace correlation fields for the active span; empty outside a valid span. */ +export function traceLogFields() { + const spanContext = trace.getActiveSpan()?.spanContext(); + if (!spanContext || !isSpanContextValid(spanContext)) + return {}; + return { trace_id: spanContext.traceId, span_id: spanContext.spanId }; +} diff --git a/dist/log-message.d.ts b/dist/log-message.d.ts new file mode 100644 index 0000000..fbeb9b9 --- /dev/null +++ b/dist/log-message.d.ts @@ -0,0 +1,6 @@ +/** + * Arguments for a pino log call with a sanitized `msg`: the raw error message never becomes `msg`, + * a `msg` field is scrubbed like a message string, and every format argument is sanitized exactly + * like the log object. Arguments that cannot be inspected become a placeholder line. + */ +export declare function sanitizeLogArgs(args: unknown[]): unknown[]; diff --git a/dist/log-message.js b/dist/log-message.js new file mode 100644 index 0000000..dca30bc --- /dev/null +++ b/dist/log-message.js @@ -0,0 +1,57 @@ +import { safeErrorMessage } from "./log-errors.js"; +import { plainFields, sanitizeLogValue } from "./log-redact.js"; +import { scrubMessage } from "./log-scrub.js"; +const UNLOGGABLE = "[unloggable]"; +/** pino derives `msg` from an Error first argument or from `err.message` of any object. */ +function errorIn(first) { + if (first instanceof Error) + return first; + if (typeof first !== "object" || first === null) + return undefined; + const fields = first; + for (const key of ["err", "error", "e"]) { + const candidate = fields[key]; + if (candidate instanceof Error) + return candidate; + } + const err = fields.err; + return typeof err === "object" && err !== null && "message" in err ? err : undefined; +} +/** + * Arguments for a pino log call with a sanitized `msg`: the raw error message never becomes `msg`, + * a `msg` field is scrubbed like a message string, and every format argument is sanitized exactly + * like the log object. Arguments that cannot be inspected become a placeholder line. + */ +export function sanitizeLogArgs(args) { + try { + const out = [...args]; + // Converted once, before any msg handling, so a toJSON-hidden field never reaches a spread. + if (typeof out[0] === "object" && out[0] !== null && !(out[0] instanceof Error)) { + out[0] = plainFields(out[0]); + } + const first = out[0]; + const msgIndex = typeof first === "string" ? 0 : 1; + const fields = first; + if (msgIndex === 1 && !(first instanceof Error) && typeof fields?.msg === "string") { + out[0] = { ...fields, msg: scrubMessage(fields.msg) }; + } + if (typeof out[msgIndex] !== "string" && msgIndex === 1) { + const err = errorIn(first); + if (err !== undefined) + out.splice(1, 0, safeErrorMessage(err)); + else if (typeof fields?.msg === "string") { + const { msg, ...rest } = out[0]; + out[0] = rest; + out.splice(1, 0, msg); + } + } + // The message and every format argument take the log object's own sanitizer. + for (let i = msgIndex; i < out.length; i++) + out[i] = sanitizeLogValue(out[i]); + return out; + } + catch { + // Nothing raw goes out: a format arg could carry PII the sanitizer never saw. + return [{ log_redaction_failed: true }, UNLOGGABLE]; + } +} diff --git a/dist/log-otlp.d.ts b/dist/log-otlp.d.ts new file mode 100644 index 0000000..f097c01 --- /dev/null +++ b/dist/log-otlp.d.ts @@ -0,0 +1,27 @@ +import { type Context } from "@opentelemetry/api"; +import { type Logger } from "@opentelemetry/api-logs"; +import type { LogRecordProcessor, SdkLogRecord } from "@opentelemetry/sdk-logs"; +import type { LogLineSink } from "./log-sink.js"; +/** Body of a line with no message, so a text search in Loki still has something to match. */ +export declare const NO_MESSAGE = "(no message)"; +/** + * A sink that turns each line the scrubbing logger wrote into an OTel log record: `msg` (else + * `event`) is the body, `level` the severity, `time` the timestamp, `trace_id`/`span_id` the record + * context, every other field an attribute. Only the serialized line is read, so a record holds + * nothing the line does not. The record is emitted after the line is written, so an SDK diagnostic + * about it (a dropped attribute) follows the line on stdout instead of preceding it. + */ +export declare function logLineSink(logger: Logger): LogLineSink; +/** + * Forwards only records from `scope`, the one fed by the scrubbing logger. Any other emitter on + * the global provider (an instrumentation's raw exception record) is never exported. + */ +export declare class ScopedLogRecordProcessor implements LogRecordProcessor { + private readonly inner; + private readonly scope; + constructor(inner: LogRecordProcessor, scope: string); + onEmit(record: SdkLogRecord, context?: Context): void; + enabled(options: Parameters>[0]): boolean; + forceFlush(): Promise; + shutdown(): Promise; +} diff --git a/dist/log-otlp.js b/dist/log-otlp.js new file mode 100644 index 0000000..6a4fd0f --- /dev/null +++ b/dist/log-otlp.js @@ -0,0 +1,120 @@ +import { isSpanContextValid, ROOT_CONTEXT, trace, TraceFlags, } from "@opentelemetry/api"; +import { SeverityNumber, } from "@opentelemetry/api-logs"; +const SEVERITY = { + trace: SeverityNumber.TRACE, + debug: SeverityNumber.DEBUG, + info: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR, + fatal: SeverityNumber.FATAL, +}; +/** Body of a line with no message, so a text search in Loki still has something to match. */ +export const NO_MESSAGE = "(no message)"; +/** + * OTel's own diagnostics (exporter failures among them). Exporting one would feed a failing + * exporter its own failures, once per batch, so they stay on stdout. + */ +function isOtelDiag(fields) { + return fields.component === "telemetry" && fields.event === "otel_diag"; +} +/** The line's correlation fields as a log record context; flags come from the matching span. */ +function lineContext(traceId, spanId) { + if (typeof traceId !== "string" || typeof spanId !== "string") + return undefined; + const active = trace.getActiveSpan()?.spanContext(); + const traceFlags = active?.traceId === traceId && active.spanId === spanId ? active.traceFlags : TraceFlags.NONE; + const spanContext = { traceId, spanId, traceFlags }; + return isSpanContextValid(spanContext) + ? trace.setSpanContext(ROOT_CONTEXT, spanContext) + : undefined; +} +function timestampOf(time) { + if (typeof time !== "string") + return undefined; + const date = new Date(time); + return Number.isNaN(date.getTime()) ? undefined : date; +} +/** + * The fields search and dashboards need, first: the SDK keeps a record's first 128 attributes and + * the logger writes `app` last. + */ +const PRIORITY_FIELDS = ["app", "env", "component", "event"]; +function priorityFirst(fields) { + const out = {}; + for (const key of PRIORITY_FIELDS) + if (key in fields) + out[key] = fields[key]; + for (const [key, value] of Object.entries(fields)) + if (!(key in out)) + out[key] = value; + return out; +} +function bodyOf(msg, event) { + if (typeof msg === "string" && msg !== "") + return msg; + if (typeof event === "string" && event !== "") + return event; + return NO_MESSAGE; +} +/** + * A sink that turns each line the scrubbing logger wrote into an OTel log record: `msg` (else + * `event`) is the body, `level` the severity, `time` the timestamp, `trace_id`/`span_id` the record + * context, every other field an attribute. Only the serialized line is read, so a record holds + * nothing the line does not. The record is emitted after the line is written, so an SDK diagnostic + * about it (a dropped attribute) follows the line on stdout instead of preceding it. + */ +export function logLineSink(logger) { + return (line) => { + const parsed = JSON.parse(line); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + return; + const { msg, level, time, trace_id, span_id, ...attributes } = parsed; + if (isOtelDiag(attributes)) + return; + const record = { + body: bodyOf(msg, attributes.event), + attributes: priorityFirst(attributes), + context: lineContext(trace_id, span_id) ?? ROOT_CONTEXT, + }; + if (typeof level === "string") { + record.severityText = level; + record.severityNumber = SEVERITY[level] ?? SeverityNumber.UNSPECIFIED; + } + const timestamp = timestampOf(time); + if (timestamp) + record.timestamp = timestamp; + queueMicrotask(() => { + try { + logger.emit(record); + } + catch { + // the line is already on stdout + } + }); + }; +} +/** + * Forwards only records from `scope`, the one fed by the scrubbing logger. Any other emitter on + * the global provider (an instrumentation's raw exception record) is never exported. + */ +export class ScopedLogRecordProcessor { + inner; + scope; + constructor(inner, scope) { + this.inner = inner; + this.scope = scope; + } + onEmit(record, context) { + if (record.instrumentationScope.name === this.scope) + this.inner.onEmit(record, context); + } + enabled(options) { + return options.instrumentationScope.name === this.scope; + } + forceFlush() { + return this.inner.forceFlush(); + } + shutdown() { + return this.inner.shutdown(); + } +} diff --git a/dist/log-redact.d.ts b/dist/log-redact.d.ts new file mode 100644 index 0000000..5510b11 --- /dev/null +++ b/dist/log-redact.d.ts @@ -0,0 +1,30 @@ +/** Pinned by scrub-contract.test.ts: the agent's layer-2 log scrub repeats these patterns. */ +export declare const LOG_REDACT_PATTERNS: Readonly>; +/** Adds key names (matched whole, any letter case) to the redacted set; a default stays. */ +export declare function addSensitiveKeys(keys: Iterable): void; +/** Test seam: forgets the app-supplied keys. */ +export declare function resetSensitiveKeys(): void; +export declare function isSensitiveKey(key: string): boolean; +/** + * The JSON-facing form of an object: URL, Buffer and toJSON values converted; `done` stops the + * walk. + */ +export declare function convert(value: object): { + done: boolean; + value: unknown; +}; +/** + * A log call's first argument or child bindings as JSON would see them, so a spread keeps what a + * toJSON hides hidden. Byte views become `{ bytes }`; a value that is not a plain record (an + * array, a date, a map's entries, what a toJSON returns) lands under `value`. + */ +export declare function plainFields(fields: object, nested?: boolean): Record; +/** + * A sanitized copy of a log value; the caller's value is never mutated. Fails closed. The log + * object, child bindings and every format argument take this one path: PII keys are redacted at + * any depth, every Error and every non-nullish value under err/error/e is serialized deny-by- + * default, and every string gets the message scrub (`scrubMessage`). + */ +export declare function sanitizeLogValue(value: unknown): unknown; +/** The log object or bindings, through sanitizeLogValue. */ +export declare function redactLogObject(obj: Record): Record; diff --git a/dist/log-redact.js b/dist/log-redact.js new file mode 100644 index 0000000..555a3c2 --- /dev/null +++ b/dist/log-redact.js @@ -0,0 +1,217 @@ +import { serializeError } from "./log-serializer.js"; +import { scrubMessage } from "./log-scrub.js"; +import { stripQuery } from "./redact.js"; +import { describePatterns } from "./scrub-descriptor.js"; +// Matches PII and credential key names (camelCase, snake_case, kebab-case) at any depth. +// `ssn`, `dob` and `ein` are anchored so words such as "businessName", "adobe" or "protein" +// stay readable; a camelCase `...Ein` is matched case-sensitively in isSensitiveKey. +// A person's name is matched by its qualifier (`firstName`, `contactName`, `payeeName`). A bare +// `name` is not: queue, job, category and target names are logged under it. +// Any key ending in `address` is: in a provider payload it is a mailing address. That includes +// `ipAddress`, which only the access-audit rows store and no log call site emits. +// Structured address parts (`street1`, `line1`) and `mobile` are matched too. +const SENSITIVE_KEY_PATTERN = /(^ssn|ssn$|ssn.?(enc|last)|_ssn|social.?security|^dob|dob$|dob.?enc|_dob|date.?of.?birth|birth.?date|account.?number|routing.?number|tax.?id|taxpayer|(^|_)ein(_?number)?$|e.?mail|phone|password|passwd|secret|token|api.?key|authorization|cookie|holder.?name|iban|passport|drivers?.?license|birthday|bank.?account|aba.?number|clerk.?auth|signature$|enc$|enc.?snapshot$|(first|last|middle|full|legal|preferred|given|family).?name|surname|(occupant|caller|contact|payee|bill.?to|tenant|resident|applicant|guarantor|performed.?by|assignee|recipient|signer).?name|(street|mailing|home|billing|residential|postal).?address|^address(.?line)?.?\d*$|address.?line|address$|forwarding.?address|^street\d*$|^line[12]$|mobile|name.?on.?account|postal.?code|^zip|zip.?code)/i; +const CAMEL_EIN = /[a-z](Ein|EIN)(Number)?$/; +// A record id (`emailId`, `bankAccountId`, `resendEmailId`) names a row, not the value it points +// at. +const RECORD_ID = /(Id|ID|Ids|IDs|_id|_ids)$/; +// ...except where the id is itself the identifier of a person. +const PERSON_ID = /(tax|taxpayer|national|government|passport|license|licence|ssn).?(id|ids)$/i; +/** Pinned by scrub-contract.test.ts: the agent's layer-2 log scrub repeats these patterns. */ +export const LOG_REDACT_PATTERNS = describePatterns({ + SENSITIVE_KEY_PATTERN, + CAMEL_EIN, + PERSON_ID, + RECORD_ID, +}); +/** App-supplied key names, lower-cased; redacted on top of the patterns above. */ +const extraKeys = new Set(); +/** Adds key names (matched whole, any letter case) to the redacted set; a default stays. */ +export function addSensitiveKeys(keys) { + for (const key of keys) { + const name = key.trim().toLowerCase(); + if (name) + extraKeys.add(name); + } +} +/** Test seam: forgets the app-supplied keys. */ +export function resetSensitiveKeys() { + extraKeys.clear(); +} +export function isSensitiveKey(key) { + if (extraKeys.size > 0 && extraKeys.has(key.toLowerCase())) + return true; + if (PERSON_ID.test(key)) + return true; + if (RECORD_ID.test(key)) + return false; + return SENSITIVE_KEY_PATTERN.test(key) || CAMEL_EIN.test(key); +} +const MAX_DEPTH = 8; +const MAX_BREADTH = 1_000; +/** Total values visited per log line, so a deep and wide object cannot stall the process. */ +const MAX_NODES = 5_000; +const REDACTED = "[redacted]"; +const TRUNCATED = "[truncated]"; +const ERROR_KEYS = new Set(["err", "error", "e"]); +function isOpaque(value) { + return (value instanceof Map || + value instanceof Set || + value instanceof RegExp || + value instanceof ArrayBuffer || + ArrayBuffer.isView(value)); +} +/** + * The JSON-facing form of an object: URL, Buffer and toJSON values converted; `done` stops the + * walk. + */ +export function convert(value) { + if (value instanceof URL) + return { done: true, value: scrubMessage(stripQuery(value.href)) }; + if (Buffer.isBuffer(value)) + return { done: true, value: { bytes: value.length } }; + if (value instanceof Date) + return { done: true, value }; + if (isOpaque(value)) + return { done: true, value }; + const toJSON = value.toJSON; + if (typeof toJSON === "function") + return { done: false, value: toJSON.call(value) }; + return { done: false, value }; +} +/** + * A log call's first argument or child bindings as JSON would see them, so a spread keeps what a + * toJSON hides hidden. Byte views become `{ bytes }`; a value that is not a plain record (an + * array, a date, a map's entries, what a toJSON returns) lands under `value`. + */ +export function plainFields(fields, nested = false) { + if (ArrayBuffer.isView(fields)) + return { bytes: fields.byteLength }; + if (Array.isArray(fields)) + return { value: fields }; + if (fields instanceof Date) { + return { value: Number.isNaN(fields.getTime()) ? null : fields.toISOString() }; + } + if (fields instanceof Map) + return { value: Object.fromEntries(fields) }; + const { value } = convert(fields); + if (value === fields) + return fields; + // What a toJSON returns gets the same treatment once, so a returned date or map keeps its value. + if (typeof value === "object" && value !== null) { + return nested ? value : plainFields(value, true); + } + return { value }; +} +/** Maps an array within the breadth cap, stopping at the first item past the node budget. */ +function walkItems(items, depth, state, visit) { + const out = []; + for (const item of items) { + if (out.length >= MAX_BREADTH || state.nodes >= MAX_NODES) { + out.push(TRUNCATED); + break; + } + out.push(visit(item, depth + 1, state)); + } + return out; +} +/** A value under err/error/e at any depth: serialized deny-by-default; an array is mapped. */ +function walkErrorValue(value, depth, state) { + if (++state.nodes > MAX_NODES) + return TRUNCATED; + if (value === null || value === undefined) + return value; + if (!Array.isArray(value)) + return serializeError(value); + if (depth > MAX_DEPTH) + return TRUNCATED; + return walkItems(value, depth, state, walkErrorValue); +} +/** `base`, or `base#n` when that name is taken; the chosen name is then taken. */ +function freeKey(base, taken) { + let candidate = base; + for (let n = 1; taken.has(candidate); n++) + candidate = `${base}#${n}`; + taken.add(candidate); + return candidate; +} +/** + * An object's entries within the breadth cap, each with the key it is logged under. A key that the + * message scrub would change becomes `[redacted]`; every clean key is reserved first, so a caller key that + * looks like a marker is never overwritten and never overwrites. + */ +function keyedEntries(target) { + const raw = Object.entries(target).slice(0, MAX_BREADTH + 1); + const clean = raw.map(([k]) => scrubMessage(k) === k); + const taken = new Set(raw.filter((_, i) => clean[i]).map(([k]) => k)); + const entries = raw.map(([k, v], i) => [ + k, + clean[i] ? k : freeKey(REDACTED, taken), + v, + ]); + return { entries, taken }; +} +function walk(value, depth, state) { + if (++state.nodes > MAX_NODES) + return TRUNCATED; + if (typeof value === "string") + return scrubMessage(value); + if (typeof value !== "object" || value === null) + return value; + if (value instanceof Error) + return serializeError(value); + const converted = convert(value); + if (converted.done) + return converted.value; + const target = converted.value; + if (typeof target === "string") + return scrubMessage(target); + if (typeof target !== "object" || target === null) + return target; + if (state.ancestors.has(target)) + return "[circular]"; + if (depth > MAX_DEPTH) + return TRUNCATED; + state.ancestors.add(target); + try { + if (Array.isArray(target)) + return walkItems(target, depth, state, walk); + const out = {}; + const { entries, taken } = keyedEntries(target); + let count = 0; + for (const [k, key, child] of entries) { + if (++count > MAX_BREADTH || state.nodes >= MAX_NODES) { + out[freeKey(TRUNCATED, taken)] = true; + break; + } + if (isSensitiveKey(k)) + out[key] = REDACTED; + else if (ERROR_KEYS.has(k)) + out[key] = walkErrorValue(child, depth + 1, state); + else + out[key] = walk(child, depth + 1, state); + } + return out; + } + finally { + state.ancestors.delete(target); + } +} +/** + * A sanitized copy of a log value; the caller's value is never mutated. Fails closed. The log + * object, child bindings and every format argument take this one path: PII keys are redacted at + * any depth, every Error and every non-nullish value under err/error/e is serialized deny-by- + * default, and every string gets the message scrub (`scrubMessage`). + */ +export function sanitizeLogValue(value) { + try { + return walk(value, 0, { nodes: 0, ancestors: new WeakSet() }); + } + catch { + return { log_redaction_failed: true }; + } +} +/** The log object or bindings, through sanitizeLogValue. */ +export function redactLogObject(obj) { + return sanitizeLogValue(obj); +} diff --git a/dist/log-safely.d.ts b/dist/log-safely.d.ts new file mode 100644 index 0000000..874d964 --- /dev/null +++ b/dist/log-safely.d.ts @@ -0,0 +1,7 @@ +import type { DiagLogger } from "@opentelemetry/api"; +export declare const log: import("pino").Logger; +type Level = "trace" | "debug" | "info" | "warn" | "error"; +/** Telemetry never breaks the app, so not even a failing logger may throw out of it. */ +export declare function logSafely(level: Level, fields: Record, message: string): void; +export declare const diagLogger: DiagLogger; +export {}; diff --git a/dist/log-safely.js b/dist/log-safely.js new file mode 100644 index 0000000..b7201a4 --- /dev/null +++ b/dist/log-safely.js @@ -0,0 +1,25 @@ +import { log as sharedLog } from "./shared-log.js"; +export const log = sharedLog.child({ component: "telemetry" }); +/** Telemetry never breaks the app, so not even a failing logger may throw out of it. */ +export function logSafely(level, fields, message) { + try { + log[level](fields, message); + } + catch { + // nothing left to report to + } +} +/** OTel's own diagnostics, through the redacting logger; only an Error argument is kept. */ +function diagMethod(level, event) { + return (message, ...args) => { + const err = args.find((arg) => arg instanceof Error); + logSafely(level, { event, ...(err !== undefined && { err }) }, message); + }; +} +export const diagLogger = { + error: diagMethod("error", "otel_diag"), + warn: diagMethod("warn", "otel_diag"), + info: diagMethod("info", "otel_diag"), + debug: diagMethod("debug", "otel_diag"), + verbose: diagMethod("trace", "otel_diag"), +}; diff --git a/dist/log-scrub.d.ts b/dist/log-scrub.d.ts new file mode 100644 index 0000000..abc7d87 --- /dev/null +++ b/dist/log-scrub.d.ts @@ -0,0 +1,12 @@ +/** Pinned by scrub-contract.test.ts: the agent's layer-2 scrub repeats these patterns. */ +export declare const LOG_SCRUB_PATTERNS: Readonly>; +/** + * Replaces SSN and email shapes in free text, strips the userinfo, query and fragment from any URL + * it quotes and the query and fragment from an absolute path. Phone and account shapes are left + * alone (id false positives). + */ +export declare function scrubMessage(text: string): string; +/** Error text comes from libraries and providers, so standalone long digit runs go too. */ +export declare function scrubErrorText(text: string): string; +/** Scrubs the header line(s) of a stack and keeps every `at` frame byte for byte. */ +export declare function scrubStack(stack: string, header?: string): string; diff --git a/dist/log-scrub.js b/dist/log-scrub.js new file mode 100644 index 0000000..ddffbbc --- /dev/null +++ b/dist/log-scrub.js @@ -0,0 +1,76 @@ +import { describePatterns } from "./scrub-descriptor.js"; +import { HOST_PORT, QUERY_START, QUOTE_END, scrubUrls, USERINFO_QUOTE, URL_END, URL_START, URL_TOKEN, USERINFO_END, } from "./url-credentials.js"; +// Every pattern here can start a match only at a bounded set of positions (an `@`, the start of a +// run, a newline), so each scrub is linear in the input length. A pattern that may start anywhere +// in a long run rescans the run from every position and blocks the event loop for seconds. URL +// userinfo and query removal live in url-credentials.ts. +const SSN_SHAPE = /\b\d{3}-\d{2}-\d{4}\b/g; +// An email's domain, matched at its `@`. The TLD must be alphabetic, so a pnpm store path +// (`.pnpm/hono+node-server@1.19.5/...`) is not an email. +const EMAIL_DOMAIN = /@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/g; +const EMAIL_LOCAL_CHAR = /[A-Za-z0-9._%+-]/; +// Eight or more digits standing alone: an account or card number. A UUID group or a prefixed +// provider id (`pi_12345678`) touches a letter, `_` or `-`, so it is not matched. +const LONG_DIGIT_RUN = /(? last && EMAIL_LOCAL_CHAR.test(text[start - 1])) + start--; + if (start === m.index) { + EMAIL_DOMAIN.lastIndex = m.index + 1; + continue; + } + out += text.slice(last, start) + REDACTED; + last = m.index + m[0].length; + } + return out + text.slice(last); +} +/** + * Replaces SSN and email shapes in free text, strips the userinfo, query and fragment from any URL + * it quotes and the query and fragment from an absolute path. Phone and account shapes are left + * alone (id false positives). + */ +export function scrubMessage(text) { + return replaceEmails(scrubUrls(text).replace(PATH_IN_TEXT, "$1$2").replace(SSN_SHAPE, REDACTED)); +} +/** Error text comes from libraries and providers, so standalone long digit runs go too. */ +export function scrubErrorText(text) { + return scrubMessage(text).replace(LONG_DIGIT_RUN, REDACTED); +} +/** Scrubs the header line(s) of a stack and keeps every `at` frame byte for byte. */ +export function scrubStack(stack, header) { + const match = FRAME_START.exec(stack); + const at = match ? match.index + match[0].indexOf("\n") : -1; + const frames = at === -1 ? "" : stack.slice(at); + const head = header ?? (at === -1 ? stack : stack.slice(0, at)); + return `${scrubErrorText(head)}${frames}`; +} diff --git a/dist/log-serializer.d.ts b/dist/log-serializer.d.ts new file mode 100644 index 0000000..15a1183 --- /dev/null +++ b/dist/log-serializer.d.ts @@ -0,0 +1,2 @@ +/** The `err` serializer: an allowlisted, scrubbed copy of the error chain. Never throws. */ +export declare function serializeError(value: unknown): unknown; diff --git a/dist/log-serializer.js b/dist/log-serializer.js new file mode 100644 index 0000000..24b0c73 --- /dev/null +++ b/dist/log-serializer.js @@ -0,0 +1,20 @@ +import { serializeErrorBase } from "./log-errors.js"; +import { scrubErrorText } from "./log-scrub.js"; +/** The `err` serializer: an allowlisted, scrubbed copy of the error chain. Never throws. */ +export function serializeError(value) { + try { + return serializeErrorBase(value); + } + catch { + let type = "Error"; + try { + const name = value?.name; + if (typeof name === "string") + type = scrubErrorText(name); + } + catch { + // keep the default type + } + return { type, message: "[unserializable error]" }; + } +} diff --git a/dist/log-sink.d.ts b/dist/log-sink.d.ts new file mode 100644 index 0000000..70bab96 --- /dev/null +++ b/dist/log-sink.d.ts @@ -0,0 +1,10 @@ +/** Receives each serialized line the logger writes; installed only once the SDK has booted. */ +export type LogLineSink = (line: string) => void; +/** Installs (or, with undefined, removes) the sink every logger line is copied to. */ +export declare function setLogLineSink(next: LogLineSink | undefined): void; +export declare function hasLogLineSink(): boolean; +/** + * Copies a written line to the sink and returns it unchanged. A sink that throws, or a line logged + * while the sink is running, is dropped: exporting must never break or recurse into a log call. + */ +export declare function forwardLogLine(line: string): string; diff --git a/dist/log-sink.js b/dist/log-sink.js new file mode 100644 index 0000000..5dd4489 --- /dev/null +++ b/dist/log-sink.js @@ -0,0 +1,29 @@ +let sink; +let forwarding = false; +/** Installs (or, with undefined, removes) the sink every logger line is copied to. */ +export function setLogLineSink(next) { + sink = next; +} +export function hasLogLineSink() { + return sink !== undefined; +} +/** + * Copies a written line to the sink and returns it unchanged. A sink that throws, or a line logged + * while the sink is running, is dropped: exporting must never break or recurse into a log call. + */ +export function forwardLogLine(line) { + const current = sink; + if (current === undefined || forwarding) + return line; + forwarding = true; + try { + current(line); + } + catch { + // the line still reaches stdout + } + finally { + forwarding = false; + } + return line; +} diff --git a/dist/logger.d.ts b/dist/logger.d.ts new file mode 100644 index 0000000..65614ef --- /dev/null +++ b/dist/logger.d.ts @@ -0,0 +1,15 @@ +import { type DestinationStream, type Logger } from "pino"; +/** + * LOG_LEVEL (any case) when it names a level; anything else falls back to "info" rather than + * throwing. + */ +export declare function resolveLogLevel(raw?: string | undefined): { + level: string; + invalid?: string; +}; +export declare function announceLogLevel(logger: Logger): void; +/** + * Builds a redacting pino logger that names its process in `app`. `app` is read on every line, so + * a logger or child created before the process names itself still reports the configured name. + */ +export declare function createLogger(app: () => string, destination?: DestinationStream): Logger; diff --git a/dist/logger.js b/dist/logger.js new file mode 100644 index 0000000..d0df2b3 --- /dev/null +++ b/dist/logger.js @@ -0,0 +1,99 @@ +import { pino, stdTimeFunctions } from "pino"; +import { sanitizeLogArgs } from "./log-message.js"; +import { plainFields, redactLogObject } from "./log-redact.js"; +import { traceLogFields } from "./log-fields.js"; +import { forwardLogLine } from "./log-sink.js"; +// `silent` is not offered: it would hide fatal lines too. +const VALID_LEVELS = new Set(["trace", "debug", "info", "warn", "error", "fatal"]); +/** + * Fields the logger owns. A caller's field of the same name is renamed `field.`, so only the + * active span sets `trace_id`/`span_id` (a Stripe payout carries its own `trace_id`). + */ +const RESERVED_FIELDS = new Set(["level", "time", "msg", "app", "env", "trace_id", "span_id"]); +/** + * LOG_LEVEL (any case) when it names a level; anything else falls back to "info" rather than + * throwing. + */ +export function resolveLogLevel(raw = process.env.LOG_LEVEL) { + const requested = raw?.trim(); + if (!requested) + return { level: "info" }; + const level = requested.toLowerCase(); + return VALID_LEVELS.has(level) ? { level } : { level: "info", invalid: requested }; +} +function renameReserved(fields) { + if (!Object.keys(fields).some((key) => RESERVED_FIELDS.has(key))) + return fields; + const out = {}; + for (const [key, value] of Object.entries(fields)) { + out[RESERVED_FIELDS.has(key) ? `field.${key}` : key] = value; + } + return out; +} +/** Child bindings in their JSON shape, renamed; a binding that cannot be read never throws. */ +function safeBindings(bindings) { + try { + return renameReserved(plainFields(bindings)); + } + catch { + return { log_redaction_failed: true }; + } +} +/** A new object: the caller's fields (reserved names renamed) under the logger's own fields. */ +function mergeMixin(fields, mixin) { + try { + return { ...renameReserved(plainFields(fields)), ...mixin }; + } + catch { + return { log_redaction_failed: true, ...mixin }; + } +} +/** Emits the invalid LOG_LEVEL warning once; held back until the process has named itself. */ +const pendingLevelWarnings = new WeakMap(); +export function announceLogLevel(logger) { + const emit = pendingLevelWarnings.get(logger); + pendingLevelWarnings.delete(logger); + emit?.(); +} +/** + * Builds a redacting pino logger that names its process in `app`. `app` is read on every line, so + * a logger or child created before the process names itself still reports the configured name. + */ +export function createLogger(app, destination) { + const { level, invalid } = resolveLogLevel(); + let logger; + const announce = () => announceLogLevel(logger); + logger = pino({ + level, + base: { env: process.env.RAILWAY_ENVIRONMENT_NAME?.trim() || "unknown" }, + // The log formatter sanitizes the whole object, err/error/e included, on the same path as + // format args. pino's default err serializer would rewrite that result, so it is replaced. + serializers: { err: (value) => value }, + formatters: { + level: (label) => ({ level: label }), + bindings: redactLogObject, + log: redactLogObject, + }, + hooks: { + logMethod(args, method) { + announce(); + return method.apply(this, sanitizeLogArgs(args)); + }, + // The finished, scrubbed line: written unchanged, and copied to the OTLP sink once the SDK + // has installed one. + streamWrite: forwardLogLine, + }, + timestamp: stdTimeFunctions.isoTime, + mixin: () => ({ app: app(), ...traceLogFields() }), + mixinMergeStrategy: (fields, mixin) => mergeMixin(fields, mixin), + }, destination); + // pino applies formatters.bindings to the base bindings only, so child bindings are handled here. + const originalChild = logger.child; + logger.child = function (bindings, options) { + return originalChild.call(this, redactLogObject(safeBindings(bindings)), options); + }; + if (invalid !== undefined) { + pendingLevelWarnings.set(logger, () => logger.warn({ event: "invalid_log_level", requested: invalid }, "invalid LOG_LEVEL, using info")); + } + return logger; +} diff --git a/dist/meter.d.ts b/dist/meter.d.ts new file mode 100644 index 0000000..4f28159 --- /dev/null +++ b/dist/meter.d.ts @@ -0,0 +1,7 @@ +import { type Meter } from "@opentelemetry/api"; +/** + * A meter on the global provider. Obtain it after `startTelemetry`: a meter taken earlier is a + * no-op and never records, even once telemetry starts. + */ +export declare function getMeter(name: string): Meter; +export type { BatchObservableResult, Meter } from "@opentelemetry/api"; diff --git a/dist/meter.js b/dist/meter.js new file mode 100644 index 0000000..5b59ec8 --- /dev/null +++ b/dist/meter.js @@ -0,0 +1,8 @@ +import { metrics } from "@opentelemetry/api"; +/** + * A meter on the global provider. Obtain it after `startTelemetry`: a meter taken earlier is a + * no-op and never records, even once telemetry starts. + */ +export function getMeter(name) { + return metrics.getMeter(name); +} diff --git a/dist/metric-views.d.ts b/dist/metric-views.d.ts new file mode 100644 index 0000000..4875236 --- /dev/null +++ b/dist/metric-views.d.ts @@ -0,0 +1,7 @@ +import { type ViewOptions } from "@opentelemetry/sdk-metrics"; +import type { MetricViewOptions } from "./options.js"; +/** + * One view that drops every attribute outside `allowedAttributes` from the app's own instruments + * (`*`), so an id or a message never becomes a label. No options, no views. + */ +export declare function metricViews(options?: MetricViewOptions): ViewOptions[]; diff --git a/dist/metric-views.js b/dist/metric-views.js new file mode 100644 index 0000000..5cbf499 --- /dev/null +++ b/dist/metric-views.js @@ -0,0 +1,15 @@ +import { createAllowListAttributesProcessor } from "@opentelemetry/sdk-metrics"; +/** + * One view that drops every attribute outside `allowedAttributes` from the app's own instruments + * (`*`), so an id or a message never becomes a label. No options, no views. + */ +export function metricViews(options) { + if (!options) + return []; + return [ + { + instrumentName: `${options.instrumentPrefix}*`, + attributesProcessors: [createAllowListAttributesProcessor([...options.allowedAttributes])], + }, + ]; +} diff --git a/dist/options.d.ts b/dist/options.d.ts new file mode 100644 index 0000000..ccc20e0 --- /dev/null +++ b/dist/options.d.ts @@ -0,0 +1,38 @@ +import type { Instrumentation } from "@opentelemetry/instrumentation"; +import type { TelemetryEnv } from "./config.js"; +/** An allowlist view: instruments named `*` export only these attributes. */ +export interface MetricViewOptions { + instrumentPrefix: string; + allowedAttributes: readonly string[]; +} +/** Context passed to `onError`: the service name, plus whatever the caller adds. */ +export type ErrorHookContext = Record; +export interface TelemetryOptions { + serviceName: string; + /** Only records from this logger scope are exported; defaults to `serviceName`. */ + logScope?: string; + /** Without it, every metric attribute is kept. */ + metricViews?: MetricViewOptions; + /** Appended to the package's own list, so Prisma and the like match the app's versions. */ + instrumentations?: Instrumentation[]; + /** Log field names redacted on top of the default PII key list; never removes a default. */ + redactKeys?: readonly string[]; + /** Receives every fatal error `installFatalHandlers` sees, when it is given no `report`. */ + onError?: (err: unknown, context: ErrorHookContext) => void; + /** Read instead of `process.env`. */ + env?: TelemetryEnv; + /** Incoming request paths that open no server span; each covers itself and everything below. */ + ignoredPaths?: readonly string[]; + /** False for a host that runs bundled CommonJS (Next.js): no ESM loader hook is registered. */ + esmLoaderHook?: boolean; + /** False keeps log lines on stdout only; a host whose own code does not use the shared logger. */ + logs?: boolean; +} +/** Records the options that outlive `startTelemetry`: the log scope and the error hook. */ +export declare function applyOptions(options: TelemetryOptions): void; +/** The configured log scope; before `startTelemetry`, the service name from the environment. */ +export declare function logScope(): string; +/** Calls the app's error hook, if any, with `service` set; never throws. */ +export declare function reportError(err: unknown, context?: ErrorHookContext): void; +/** Test seam: forgets the recorded options. */ +export declare function resetOptions(): void; diff --git a/dist/options.js b/dist/options.js new file mode 100644 index 0000000..b7051aa --- /dev/null +++ b/dist/options.js @@ -0,0 +1,28 @@ +let scope; +let service; +let errorHook; +/** Records the options that outlive `startTelemetry`: the log scope and the error hook. */ +export function applyOptions(options) { + scope = options.logScope?.trim() || options.serviceName; + service = options.serviceName; + errorHook = options.onError; +} +/** The configured log scope; before `startTelemetry`, the service name from the environment. */ +export function logScope() { + return scope ?? (process.env.OTEL_SERVICE_NAME?.trim() || "unknown"); +} +/** Calls the app's error hook, if any, with `service` set; never throws. */ +export function reportError(err, context = {}) { + try { + errorHook?.(err, { service: service ?? "unknown", ...context }); + } + catch { + // reporting must never break the caller + } +} +/** Test seam: forgets the recorded options. */ +export function resetOptions() { + scope = undefined; + service = undefined; + errorHook = undefined; +} diff --git a/dist/profiling.d.ts b/dist/profiling.d.ts new file mode 100644 index 0000000..b60baec --- /dev/null +++ b/dist/profiling.d.ts @@ -0,0 +1,10 @@ +import type { TelemetryConfig, TelemetryEnv } from "./config.js"; +/** + * Starts Pyroscope CPU and heap profiling. Gated on `PYROSCOPE_SERVER_ADDRESS` alone, so it + * is independent of the OTLP endpoint, and `OTEL_SDK_DISABLED=true` still switches it off. + * Tags are `env` and `version` only; request-scoped labels would carry identifiers. Never + * rejects: a missing native binary logs `profiling_start_failed` and returns false. + */ +export declare function startProfiling(config: TelemetryConfig, env?: TelemetryEnv): Promise; +/** Stops the profiler; never rejects. */ +export declare function stopProfiling(): Promise; diff --git a/dist/profiling.js b/dist/profiling.js new file mode 100644 index 0000000..a4a5ea3 --- /dev/null +++ b/dist/profiling.js @@ -0,0 +1,51 @@ +import { logSafely } from "./log-safely.js"; +let profiler; +/** + * Starts Pyroscope CPU and heap profiling. Gated on `PYROSCOPE_SERVER_ADDRESS` alone, so it + * is independent of the OTLP endpoint, and `OTEL_SDK_DISABLED=true` still switches it off. + * Tags are `env` and `version` only; request-scoped labels would carry identifiers. Never + * rejects: a missing native binary logs `profiling_start_failed` and returns false. + */ +export async function startProfiling(config, env = process.env) { + try { + if (profiler) + return true; + const serverAddress = env.PYROSCOPE_SERVER_ADDRESS?.trim(); + if (!serverAddress) + return false; + if (env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true") + return false; + const module = await import("@pyroscope/nodejs"); + const pyroscope = module.default ?? module; + pyroscope.init({ + serverAddress, + appName: config.serviceName, + tags: { env: config.environment, version: config.serviceVersion }, + // Pins the 0.6.4 defaults: 60 s flush, 100 Hz wall sampling, one heap sample per 512 KiB. + flushIntervalMs: 60_000, + wall: { samplingDurationMs: 60_000, samplingIntervalMicros: 10_000 }, + heap: { samplingIntervalBytes: 512 * 1024, stackDepth: 64 }, + }); + pyroscope.start(); + profiler = pyroscope; + return true; + } + catch (error) { + profiler = undefined; + logSafely("warn", { err: error, event: "profiling_start_failed" }, "profiling_start_failed"); + return false; + } +} +/** Stops the profiler; never rejects. */ +export async function stopProfiling() { + const running = profiler; + profiler = undefined; + if (!running) + return; + try { + await running.stop(); + } + catch { + // shutdown proceeds regardless + } +} diff --git a/dist/redact.d.ts b/dist/redact.d.ts new file mode 100644 index 0000000..40f041a --- /dev/null +++ b/dist/redact.d.ts @@ -0,0 +1,23 @@ +import type { Attributes } from "@opentelemetry/api"; +/** `schemeless` reads a value with no scheme and no leading `/` as starting at its authority. */ +export declare function stripQuery(url: string, schemeless?: boolean): string; +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats these. */ +export declare const REDACT_PATTERNS: Readonly>; +export declare const REDACT_KEY_SETS: Readonly>; +export declare const REDACT_LITERALS: Readonly<{ + REDIS_SYSTEM: "redis"; +}>; +/** + * A span name of the form `METHOD /path`, `METHOD scheme://host/path` or `METHOD user@host/path` + * gets the same treatment as a URL attribute: no query, fragment or userinfo, and the path + * templated. Next.js names an unrouted request's span after its raw URL. Any other name gets the + * message scrub, and each of its words its token paths templated. + */ +export declare function scrubSpanName(name: string, route: string | undefined, tokenPaths?: readonly (readonly [RegExp, string])[]): string; +/** + * Scrubs one attribute set. Every URL attribute loses its query, fragment and userinfo, and its + * path becomes the span's `http.route` template when it has one, so a path parameter never + * leaves. Without a route (client spans, unmatched requests) `url.path` is dropped and known + * token-bearing paths are templated in the remaining URL attributes. + */ +export declare function scrubAttributes(attributes: Attributes, tokenPaths?: readonly (readonly [RegExp, string])[]): Attributes; diff --git a/dist/redact.js b/dist/redact.js new file mode 100644 index 0000000..fd83a0a --- /dev/null +++ b/dist/redact.js @@ -0,0 +1,127 @@ +import { scrubMessage } from "./log-scrub.js"; +import { describeKeySets, describePatterns } from "./scrub-descriptor.js"; +import { stripUserinfo, URL_AUTHORITY } from "./url-credentials.js"; +import { routeTemplateOf, templateUrlPath, TOKEN_PATH_TEMPLATES } from "./url-path.js"; +const URL_KEYS = new Set(["url.full", "url.original", "http.url", "http.target", "url.path"]); +const DROPPED_KEYS = new Set([ + "url.query", + "http.request.body", + "http.response.body", + "client.address", + "network.peer.address", + "bullmq.job.result", + "bullmq.job.progress", +]); +/** Kept so a failure is visible, but its content is the thrown message. */ +const REDACTED_KEYS = new Set(["exception.message", "bullmq.job.failed.reason"]); +const DROPPED_PREFIXES = [ + "http.request.header.", + "http.response.header.", + "messaging.message.body", + "db.query.parameter.", + "db.operation.parameter.", + "enduser.", + "user.", +]; +const STATEMENT_KEYS = ["db.statement", "db.query.text"]; +// A span with either key set to this is a Redis call, whose statement quotes the values it sent. +const REDIS_SYSTEM_KEYS = ["db.system.name", "db.system"]; +const REDIS_SYSTEM = "redis"; +const URL_QUERY_START = /[?#;]/; +const FRAME_LINE = /^\s+at /; +/** `schemeless` reads a value with no scheme and no leading `/` as starting at its authority. */ +export function stripQuery(url, schemeless = false) { + // Userinfo first: a password can hold a `?` or `#`. + const withoutUserinfo = stripUserinfo(url, schemeless); + const cut = withoutUserinfo.search(URL_QUERY_START); + return cut === -1 ? withoutUserinfo : withoutUserinfo.slice(0, cut); +} +// `METHOD /path`, `METHOD scheme://host/path` or `METHOD user@host/path`. +const NAMED_REQUEST = /^([A-Z]+) ((?:\/|[a-z][a-z0-9+.-]*:\/\/|[^\s/?#@]*@)\S*)$/i; +const NAME_KEYS = new Set(["next.span_name"]); +const WORD = /\S+/g; +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats these. */ +export const REDACT_PATTERNS = describePatterns({ + URL_AUTHORITY, + URL_QUERY_START, + NAMED_REQUEST, + FRAME_LINE, +}); +export const REDACT_KEY_SETS = describeKeySets({ + URL_KEYS, + DROPPED_KEYS, + REDACTED_KEYS, + DROPPED_PREFIXES, + STATEMENT_KEYS, + NAME_KEYS, + REDIS_SYSTEM_KEYS, +}); +export const REDACT_LITERALS = Object.freeze({ REDIS_SYSTEM }); +/** + * A span name of the form `METHOD /path`, `METHOD scheme://host/path` or `METHOD user@host/path` + * gets the same treatment as a URL attribute: no query, fragment or userinfo, and the path + * templated. Next.js names an unrouted request's span after its raw URL. Any other name gets the + * message scrub, and each of its words its token paths templated. + */ +export function scrubSpanName(name, route, tokenPaths = TOKEN_PATH_TEMPLATES) { + const match = NAMED_REQUEST.exec(name); + if (!match) { + return scrubMessage(name).replace(WORD, (word) => templateUrlPath(word, undefined, tokenPaths)); + } + return `${match[1]} ${templateUrlPath(stripQuery(match[2], true), route, tokenPaths)}`; +} +function isRedis(attributes) { + return REDIS_SYSTEM_KEYS.some((key) => attributes[key] === REDIS_SYSTEM); +} +function scrubExceptionStacktrace(stacktrace, exceptionType) { + const lines = stacktrace.split("\n"); + const type = exceptionType || "Error"; + let frameStartIndex = lines.findIndex((line) => FRAME_LINE.test(line)); + if (frameStartIndex === -1) { + return `${type}: [redacted]`; + } + const result = [`${type}: [redacted]`, ...lines.slice(frameStartIndex)]; + return result.join("\n"); +} +/** + * Scrubs one attribute set. Every URL attribute loses its query, fragment and userinfo, and its + * path becomes the span's `http.route` template when it has one, so a path parameter never + * leaves. Without a route (client spans, unmatched requests) `url.path` is dropped and known + * token-bearing paths are templated in the remaining URL attributes. + */ +export function scrubAttributes(attributes, tokenPaths = TOKEN_PATH_TEMPLATES) { + const redis = isRedis(attributes); + const route = routeTemplateOf(attributes["http.route"]); + const scrubbed = {}; + for (const [key, value] of Object.entries(attributes)) { + if (DROPPED_KEYS.has(key) || DROPPED_PREFIXES.some((prefix) => key.startsWith(prefix))) + continue; + if (redis && STATEMENT_KEYS.includes(key)) + continue; + if (key === "url.path" && !route) + continue; + if (REDACTED_KEYS.has(key)) { + scrubbed[key] = "[redacted]"; + } + else if (key === "exception.stacktrace") { + if (typeof value === "string") { + const exceptionType = attributes["exception.type"]; + scrubbed[key] = scrubExceptionStacktrace(value, exceptionType); + } + else { + scrubbed[key] = "[redacted]"; + } + } + else { + scrubbed[key] = + typeof value !== "string" + ? value + : URL_KEYS.has(key) + ? templateUrlPath(stripQuery(value, true), route, tokenPaths) + : NAME_KEYS.has(key) + ? scrubSpanName(value, route, tokenPaths) + : value; + } + } + return scrubbed; +} diff --git a/dist/redacting-exporter.d.ts b/dist/redacting-exporter.d.ts new file mode 100644 index 0000000..5743d2c --- /dev/null +++ b/dist/redacting-exporter.d.ts @@ -0,0 +1,17 @@ +import type { ExportResult } from "@opentelemetry/core"; +import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base"; +type TokenPaths = readonly (readonly [RegExp, string])[]; +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats it. */ +export declare const EXPORTER_LITERALS: Readonly<{ + REDACTED_STATUS_KEY: "message"; +}>; +export declare class RedactingSpanExporter implements SpanExporter { + private readonly inner; + private readonly tokenPaths; + /** `tokenPaths` are the path patterns templated on spans without a route template. */ + constructor(inner: SpanExporter, tokenPaths?: TokenPaths); + export(spans: ReadableSpan[], done: (result: ExportResult) => void): void; + shutdown(): Promise; + forceFlush(): Promise; +} +export {}; diff --git a/dist/redacting-exporter.js b/dist/redacting-exporter.js new file mode 100644 index 0000000..32a6b99 --- /dev/null +++ b/dist/redacting-exporter.js @@ -0,0 +1,51 @@ +import { scrubAttributes, scrubSpanName } from "./redact.js"; +import { routeTemplateOf, TOKEN_PATH_TEMPLATES } from "./url-path.js"; +// The span status field that carries the thrown message; it is kept, redacted, so a failure shows. +const REDACTED_STATUS_KEY = "message"; +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats it. */ +export const EXPORTER_LITERALS = Object.freeze({ REDACTED_STATUS_KEY }); +function scrubSpan(span, tokenPaths) { + const attributes = scrubAttributes(span.attributes, tokenPaths); + const name = scrubSpanName(span.name, routeTemplateOf(span.attributes["http.route"]), tokenPaths); + const events = span.events.map((event) => event.attributes + ? { ...event, attributes: scrubAttributes(event.attributes, tokenPaths) } + : event); + const links = span.links.map((link) => link.attributes ? { ...link, attributes: scrubAttributes(link.attributes, tokenPaths) } : link); + const status = span.status && span.status[REDACTED_STATUS_KEY] + ? { ...span.status, [REDACTED_STATUS_KEY]: "[redacted]" } + : span.status; + return new Proxy(span, { + get(target, property) { + if (property === "name") + return name; + if (property === "attributes") + return attributes; + if (property === "events") + return events; + if (property === "links") + return links; + if (property === "status") + return status; + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} +export class RedactingSpanExporter { + inner; + tokenPaths; + /** `tokenPaths` are the path patterns templated on spans without a route template. */ + constructor(inner, tokenPaths = TOKEN_PATH_TEMPLATES) { + this.inner = inner; + this.tokenPaths = tokenPaths; + } + export(spans, done) { + this.inner.export(spans.map((span) => scrubSpan(span, this.tokenPaths)), done); + } + shutdown() { + return this.inner.shutdown(); + } + forceFlush() { + return this.inner.forceFlush?.() ?? Promise.resolve(); + } +} diff --git a/dist/register.d.ts b/dist/register.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/register.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/register.js b/dist/register.js new file mode 100644 index 0000000..8275750 --- /dev/null +++ b/dist/register.js @@ -0,0 +1,5 @@ +// `node --import @codelittinc/otel-node/register`: starts telemetry before the app loads, for an +// app with no options to pass. `OTEL_SERVICE_NAME` names the service and is its log scope. +import { startTelemetry } from "./sdk.js"; +const serviceName = process.env.OTEL_SERVICE_NAME?.trim() || "unknown"; +await startTelemetry({ serviceName, logScope: serviceName }); diff --git a/dist/scrub-descriptor.d.ts b/dist/scrub-descriptor.d.ts new file mode 100644 index 0000000..406393c --- /dev/null +++ b/dist/scrub-descriptor.d.ts @@ -0,0 +1,7 @@ +/** A regex's source and flags, for the scrub contract test; never the stateful RegExp itself. */ +export interface PatternDescriptor { + readonly source: string; + readonly flags: string; +} +export declare function describePatterns(patterns: Record): Readonly>; +export declare function describeKeySets(sets: Record>): Readonly>; diff --git a/dist/scrub-descriptor.js b/dist/scrub-descriptor.js new file mode 100644 index 0000000..38c8850 --- /dev/null +++ b/dist/scrub-descriptor.js @@ -0,0 +1,14 @@ +export function describePatterns(patterns) { + const out = {}; + for (const name of Object.keys(patterns)) { + const { source, flags } = patterns[name]; + out[name] = Object.freeze({ source, flags }); + } + return Object.freeze(out); +} +export function describeKeySets(sets) { + const out = {}; + for (const name of Object.keys(sets)) + out[name] = Object.freeze([...sets[name]].sort()); + return Object.freeze(out); +} diff --git a/dist/sdk-boot.d.ts b/dist/sdk-boot.d.ts new file mode 100644 index 0000000..b0ed72d --- /dev/null +++ b/dist/sdk-boot.d.ts @@ -0,0 +1,23 @@ +import type { Telemetry } from "bullmq"; +import type { Instrumentation } from "@opentelemetry/instrumentation"; +import type { TelemetryConfig } from "./config.js"; +import type { MetricViewOptions } from "./options.js"; +export interface BootedSdk { + shutdown(): Promise; + bullmq: Telemetry; +} +export interface BootOptions { + logScope: string; + ignoredPaths?: readonly string[]; + esmLoaderHook?: boolean; + exportLogs?: boolean; + metricViews?: MetricViewOptions; + instrumentations?: readonly Instrumentation[]; +} +/** + * Registers the ESM loader hook (unless `esmLoaderHook` is false) and starts the Node SDK. Loaded + * only once telemetry is enabled, so a process without an endpoint never pays for the SDK and its + * instrumentations. With `exportLogs`, every line the shared logger writes is also sent over OTLP + * under `logScope`. + */ +export declare function bootSdk(config: TelemetryConfig, options: BootOptions): Promise; diff --git a/dist/sdk-boot.js b/dist/sdk-boot.js new file mode 100644 index 0000000..b522e03 --- /dev/null +++ b/dist/sdk-boot.js @@ -0,0 +1,123 @@ +import { register } from "node:module"; +import { createAddHookMessageChannel } from "import-in-the-middle"; +import { NodeSDK } from "@opentelemetry/sdk-node"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto"; +import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; +import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto"; +import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs"; +import { logs } from "@opentelemetry/api-logs"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { ATTR_SERVICE_INSTANCE_ID, ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, } from "@opentelemetry/semantic-conventions"; +import { BullMQOtel } from "bullmq-otel"; +import { buildInstrumentations } from "./instrumentations.js"; +import { metricViews } from "./metric-views.js"; +import { RedactingSpanExporter } from "./redacting-exporter.js"; +import { logSafely } from "./log-safely.js"; +import { setLogLineSink } from "./log-sink.js"; +import { logLineSink, ScopedLogRecordProcessor } from "./log-otlp.js"; +/** Longest boot waits for the loader thread to acknowledge the hook registration. */ +const HOOK_ACK_TIMEOUT_MS = 5_000; +/** + * Log records per OTLP request. Alloy refuses a whole request over 32 MiB + * (`agent/config/base.alloy` in codelittinc/carbon-observability), and the logger does not cap a + * line, so a batch is bounded by count: 64 records stay under the limit unless they average over + * 512 KiB each. The SDK default of 512 would fail at 64 KiB each. + */ +const LOG_EXPORT_BATCH_SIZE = 64; +/** + * Registers the ESM loader hook (unless `esmLoaderHook` is false) and starts the Node SDK. Loaded + * only once telemetry is enabled, so a process without an endpoint never pays for the SDK and its + * instrumentations. With `exportLogs`, every line the shared logger writes is also sent over OTLP + * under `logScope`. + */ +export async function bootSdk(config, options) { + const { logScope, esmLoaderHook = true, exportLogs = true } = options; + // A message channel limits import-in-the-middle to modules the instrumentations register, + // instead of wrapping every ESM module the app loads. + const hook = esmLoaderHook ? createAddHookMessageChannel() : undefined; + if (hook) + register("import-in-the-middle/hook.mjs", import.meta.url, hook.registerOptions); + const sdk = new NodeSDK({ + resource: resourceFromAttributes({ + [ATTR_SERVICE_NAME]: config.serviceName, + [ATTR_SERVICE_VERSION]: config.serviceVersion, + [ATTR_SERVICE_INSTANCE_ID]: config.serviceInstanceId, + "deployment.environment.name": config.environment, + }), + spanProcessors: [new BatchSpanProcessor(new RedactingSpanExporter(new OTLPTraceExporter()))], + metricReaders: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + exportIntervalMillis: 60_000, + }), + ], + views: metricViews(options.metricViews), + // Never omitted: an omitted list makes NodeSDK build its own log exporter from env. Records + // come only from the logger's scrubbed output (see log-otlp.ts), never from an + // instrumentation that sees a log call before it is scrubbed. + logRecordProcessors: exportLogs + ? [ + new ScopedLogRecordProcessor(new BatchLogRecordProcessor({ + exporter: new OTLPLogExporter(), + maxExportBatchSize: LOG_EXPORT_BATCH_SIZE, + }), logScope), + ] + : [], + instrumentations: buildInstrumentations(options.ignoredPaths ?? [], options.instrumentations), + }); + sdk.start(); + const booted = { + shutdown: () => { + setLogLineSink(undefined); + // After the records the sink has already queued for emission. + return Promise.resolve().then(() => sdk.shutdown()); + }, + bullmq: new BullMQOtel({ tracerName: logScope }), + }; + if (exportLogs) + setLogLineSink(logLineSink(logs.getLogger(logScope))); + if (hook) + await acknowledgedWithin(hook.waitForAllMessagesAcknowledged, HOOK_ACK_TIMEOUT_MS); + return booted; +} +/** + * Abandons a loader acknowledgement that never arrives, so boot cannot hang. The wait keeps a + * referenced interval alive until the ack lands; on timeout it is unreferenced, so an abandoned + * ack never holds the process open. + */ +async function acknowledgedWithin(wait, timeoutMs) { + const { result: ack, intervals } = captureIntervals(wait); + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve("timeout"), timeoutMs); + timer.unref(); + }); + try { + if ((await Promise.race([ack, timeout])) === "timeout") { + for (const interval of intervals) + interval.unref(); + logSafely("warn", { event: "telemetry_hook_ack_timeout" }, "telemetry_hook_ack_timeout"); + } + } + finally { + clearTimeout(timer); + } +} +/** Runs `fn` and returns the intervals it created synchronously. */ +function captureIntervals(fn) { + const intervals = []; + const original = globalThis.setInterval; + globalThis.setInterval = ((...args) => { + const interval = original(...args); + intervals.push(interval); + return interval; + }); + try { + return { result: fn(), intervals }; + } + finally { + globalThis.setInterval = original; + } +} diff --git a/dist/sdk.d.ts b/dist/sdk.d.ts new file mode 100644 index 0000000..0a8a6e1 --- /dev/null +++ b/dist/sdk.d.ts @@ -0,0 +1,22 @@ +import type { Telemetry } from "bullmq"; +import { type TelemetryConfig } from "./config.js"; +import { type TelemetryOptions } from "./options.js"; +/** + * Boots the OpenTelemetry Node SDK. Never rejects: a failed start is logged and the config + * is returned. Telemetry starts once per process; a restart after `shutdownTelemetry` is not + * supported (shutdown resets state only so tests can run in sequence). The ESM loader hook is + * registered here, so this module must run under `node --import` before the app loads `http`. + * The SDK itself is imported only once telemetry is enabled. A host that runs bundled CommonJS + * (Next.js) passes `esmLoaderHook: false`. The log scope, `redactKeys` and `onError` apply even + * when export is off, since they shape stdout and error reporting too. + */ +export declare function startTelemetry(options: TelemetryOptions): Promise; +/** True once `startTelemetry` has booted the SDK in this process. */ +export declare function isTelemetryStarted(): boolean; +/** The BullMQ adapter built by `startTelemetry`; undefined until telemetry has started. */ +export declare function startedBullmqTelemetry(): Telemetry | undefined; +/** + * Flushes pending spans, metrics, log records and profiles, bounded so a dead collector cannot + * stall a shutdown. + */ +export declare function shutdownTelemetry(timeoutMs?: number): Promise; diff --git a/dist/sdk.js b/dist/sdk.js new file mode 100644 index 0000000..f0daeca --- /dev/null +++ b/dist/sdk.js @@ -0,0 +1,101 @@ +import { diag, DiagLogLevel } from "@opentelemetry/api"; +import { resolveTelemetryConfig } from "./config.js"; +import { diagLogger, logSafely } from "./log-safely.js"; +import { addSensitiveKeys } from "./log-redact.js"; +import { applyOptions, logScope } from "./options.js"; +import { startProfiling, stopProfiling } from "./profiling.js"; +let sdk; +let started; +/** + * Boots the OpenTelemetry Node SDK. Never rejects: a failed start is logged and the config + * is returned. Telemetry starts once per process; a restart after `shutdownTelemetry` is not + * supported (shutdown resets state only so tests can run in sequence). The ESM loader hook is + * registered here, so this module must run under `node --import` before the app loads `http`. + * The SDK itself is imported only once telemetry is enabled. A host that runs bundled CommonJS + * (Next.js) passes `esmLoaderHook: false`. The log scope, `redactKeys` and `onError` apply even + * when export is off, since they shape stdout and error reporting too. + */ +export async function startTelemetry(options) { + if (started) + return started; + applyOptions(options); + if (options.redactKeys) + addSensitiveKeys(options.redactKeys); + const env = options.env ?? process.env; + const config = resolveTelemetryConfig(options.serviceName, env); + if (!config.enabled) { + await startProfiling(config, env); + return config; + } + started = config; + installDiagLogger(env.OTEL_LOG_LEVEL); + try { + const { bootSdk } = await import("./sdk-boot.js"); + sdk = await bootSdk(config, { + logScope: logScope(), + ignoredPaths: options.ignoredPaths ?? [], + esmLoaderHook: options.esmLoaderHook !== false, + exportLogs: options.logs !== false, + metricViews: options.metricViews, + instrumentations: options.instrumentations, + }); + logSafely("info", { + event: "telemetry_started", + serviceName: config.serviceName, + endpointHost: hostOf(env.OTEL_EXPORTER_OTLP_ENDPOINT), + }, "telemetry_started"); + } + catch (error) { + sdk = undefined; + logSafely("warn", { err: error, event: "telemetry_start_failed" }, "telemetry_start_failed"); + } + await startProfiling(config, env); + return config; +} +/** True once `startTelemetry` has booted the SDK in this process. */ +export function isTelemetryStarted() { + return sdk !== undefined; +} +/** The BullMQ adapter built by `startTelemetry`; undefined until telemetry has started. */ +export function startedBullmqTelemetry() { + return sdk?.bullmq; +} +/** + * Flushes pending spans, metrics, log records and profiles, bounded so a dead collector cannot + * stall a shutdown. + */ +export async function shutdownTelemetry(timeoutMs = 5_000) { + started = undefined; + const running = sdk; + sdk = undefined; + const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs).unref()); + await Promise.race([ + Promise.all([ + stopProfiling(), + Promise.resolve() + .then(() => running?.shutdown()) + .catch(() => undefined), + ]).then(() => undefined), + timeout, + ]); +} +/** SDK warnings (a rejected export, a bad option) go through the redacting logger. When + * `OTEL_LOG_LEVEL` is set, NodeSDK installs its own console logger at that level for debugging, + * so this default stays out of its way. */ +function installDiagLogger(level) { + if (level?.trim()) + return; + diag.setLogger(diagLogger, { + logLevel: DiagLogLevel.WARN, + suppressOverrideMessage: true, + }); +} +/** The endpoint's host only: a URL can carry credentials in its userinfo or query. */ +function hostOf(endpoint) { + try { + return new URL(endpoint?.trim() ?? "").host; + } + catch { + return "invalid"; + } +} diff --git a/dist/shared-log.d.ts b/dist/shared-log.d.ts new file mode 100644 index 0000000..01e59f3 --- /dev/null +++ b/dist/shared-log.d.ts @@ -0,0 +1,19 @@ +import type { Logger } from "pino"; +/** The configured service name, else `OTEL_SERVICE_NAME`, else "unknown". */ +export declare function currentService(): string; +/** + * The one logger every process shares. Apps import it directly; modules and packages log through a + * child that binds `component` (`log.child({ component: "db" })`), so their lines carry the + * process's name in `app`. + */ +export declare const log: Logger; +/** + * Names the process on every log line (the `app` field), including lines from loggers created + * before this call. Each entry point calls it once. The first name wins: a later call with a + * different name is ignored and warns, so no imported module can rename the process. + */ +export declare function configureLogger({ service }: { + service: string; +}): void; +/** Resolves once buffered log lines are written, or after `timeoutMs`; never rejects. */ +export declare function flushLog(timeoutMs?: number): Promise; diff --git a/dist/shared-log.js b/dist/shared-log.js new file mode 100644 index 0000000..12aee07 --- /dev/null +++ b/dist/shared-log.js @@ -0,0 +1,43 @@ +import { announceLogLevel, createLogger } from "./logger.js"; +let configuredService; +/** The configured service name, else `OTEL_SERVICE_NAME`, else "unknown". */ +export function currentService() { + return configuredService ?? (process.env.OTEL_SERVICE_NAME?.trim() || "unknown"); +} +/** + * The one logger every process shares. Apps import it directly; modules and packages log through a + * child that binds `component` (`log.child({ component: "db" })`), so their lines carry the + * process's name in `app`. + */ +export const log = createLogger(currentService); +/** + * Names the process on every log line (the `app` field), including lines from loggers created + * before this call. Each entry point calls it once. The first name wins: a later call with a + * different name is ignored and warns, so no imported module can rename the process. + */ +export function configureLogger({ service }) { + if (configuredService === undefined) { + configuredService = service; + announceLogLevel(log); + return; + } + if (service !== configuredService) { + log.warn({ event: "logger_reconfigure_ignored", requested: service }, "logger already configured, keeping the first service name"); + } +} +/** Resolves once buffered log lines are written, or after `timeoutMs`; never rejects. */ +export function flushLog(timeoutMs = 1_000) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + try { + log.flush(() => { + clearTimeout(timer); + resolve(); + }); + } + catch { + clearTimeout(timer); + resolve(); + } + }); +} diff --git a/dist/testing.d.ts b/dist/testing.d.ts new file mode 100644 index 0000000..18dbbe1 --- /dev/null +++ b/dist/testing.d.ts @@ -0,0 +1,6 @@ +import type { Logger } from "pino"; +/** A logger that captures parsed records in memory, for tests. */ +export declare function createTestLogger(): { + logger: Logger; + records: Record[]; +}; diff --git a/dist/testing.js b/dist/testing.js new file mode 100644 index 0000000..95d7837 --- /dev/null +++ b/dist/testing.js @@ -0,0 +1,12 @@ +import { createLogger } from "./logger.js"; +import { currentService } from "./shared-log.js"; +/** A logger that captures parsed records in memory, for tests. */ +export function createTestLogger() { + const records = []; + const logger = createLogger(currentService, { + write(line) { + records.push(JSON.parse(line)); + }, + }); + return { logger, records }; +} diff --git a/dist/trace-context.d.ts b/dist/trace-context.d.ts new file mode 100644 index 0000000..db26ac6 --- /dev/null +++ b/dist/trace-context.d.ts @@ -0,0 +1,16 @@ +import { type Link } from "@opentelemetry/api"; +/** The W3C `traceparent` of the active span, or undefined when no span is active. */ +export declare function currentTraceparent(): string | undefined; +/** A span link to a stored `traceparent`. A missing or malformed value gives no link. */ +export declare function linkFromTraceparent(value: string | null | undefined): Link | undefined; +/** + * Run `fn` inside a new active span that links to the trace in `traceparent`. A link, not a + * parent: the work runs later and apart from the request that stored the value. + */ +export declare function withLinkedSpan(name: string, traceparent: string | null | undefined, fn: () => Promise): Promise; +/** + * Run `fn` inside a new active root span, recorded even where the HTTP instrumentation suppressed + * tracing for an ignored path. The span ends with ERROR when `fn` throws or `failed` holds for its + * result, else OK. + */ +export declare function withRootSpan(name: string, fn: () => Promise, failed?: (result: T) => boolean): Promise; diff --git a/dist/trace-context.js b/dist/trace-context.js new file mode 100644 index 0000000..63e47fa --- /dev/null +++ b/dist/trace-context.js @@ -0,0 +1,71 @@ +import { context, defaultTextMapGetter, defaultTextMapSetter, isSpanContextValid, ROOT_CONTEXT, SpanStatusCode, trace, } from "@opentelemetry/api"; +import { unsuppressTracing, W3CTraceContextPropagator } from "@opentelemetry/core"; +// The W3C propagator is used directly, not the global one, so a stored value is always a +// `traceparent` header whatever OTEL_PROPAGATORS says. +const w3c = new W3CTraceContextPropagator(); +const TRACER_NAME = "@codelittinc/otel-node"; +/** The W3C `traceparent` of the active span, or undefined when no span is active. */ +export function currentTraceparent() { + const carrier = {}; + w3c.inject(context.active(), carrier, defaultTextMapSetter); + return carrier.traceparent; +} +/** A span link to a stored `traceparent`. A missing or malformed value gives no link. */ +export function linkFromTraceparent(value) { + if (!value) + return undefined; + try { + const extracted = w3c.extract(ROOT_CONTEXT, { traceparent: value }, defaultTextMapGetter); + const spanContext = trace.getSpanContext(extracted); + return spanContext && isSpanContextValid(spanContext) ? { context: spanContext } : undefined; + } + catch { + return undefined; + } +} +/** + * Run `fn` inside a new active span that links to the trace in `traceparent`. A link, not a + * parent: the work runs later and apart from the request that stored the value. + */ +export function withLinkedSpan(name, traceparent, fn) { + const link = linkFromTraceparent(traceparent); + return trace + .getTracer(TRACER_NAME) + .startActiveSpan(name, { links: link ? [link] : [] }, async (span) => { + try { + return await fn(); + } + catch (err) { + span.recordException(err instanceof Error ? err : String(err)); + span.setStatus({ code: SpanStatusCode.ERROR }); + throw err; + } + finally { + span.end(); + } + }); +} +/** + * Run `fn` inside a new active root span, recorded even where the HTTP instrumentation suppressed + * tracing for an ignored path. The span ends with ERROR when `fn` throws or `failed` holds for its + * result, else OK. + */ +export function withRootSpan(name, fn, failed = () => false) { + return trace + .getTracer(TRACER_NAME) + .startActiveSpan(name, { root: true }, unsuppressTracing(context.active()), async (span) => { + try { + const result = await fn(); + span.setStatus({ code: failed(result) ? SpanStatusCode.ERROR : SpanStatusCode.OK }); + return result; + } + catch (err) { + span.recordException(err instanceof Error ? err : String(err)); + span.setStatus({ code: SpanStatusCode.ERROR }); + throw err; + } + finally { + span.end(); + } + }); +} diff --git a/dist/url-credentials.d.ts b/dist/url-credentials.d.ts new file mode 100644 index 0000000..891da91 --- /dev/null +++ b/dist/url-credentials.d.ts @@ -0,0 +1,23 @@ +export declare const URL_TOKEN: RegExp; +export declare const URL_START: RegExp; +export declare const USERINFO_END: RegExp; +export declare const URL_END: RegExp; +export declare const QUOTE_END: RegExp; +export declare const USERINFO_QUOTE: RegExp; +export declare const HOST_PORT: RegExp; +export declare const URL_AUTHORITY: RegExp; +export declare const QUERY_START: RegExp; +/** + * In a URL token, each URL loses its userinfo, and a URL with a `?`, `#` or `;` after its + * authority loses everything from there to the next whitespace. A host and path run to URL_END, + * QUOTE_END or the next URL's start, whichever comes first. + */ +export declare function scrubUrlToken(token: string): string; +/** Every URL in free text, scrubbed as `scrubUrlToken` does. */ +export declare function scrubUrls(text: string): string; +/** + * A URL value without its userinfo. The authority starts after URL_AUTHORITY. With `schemeless`, + * a value without one that is not a path starts its authority at its first non-space character. + * The value is one URL, so its userinfo may run to the value's end. + */ +export declare function stripUserinfo(value: string, schemeless?: boolean): string; diff --git a/dist/url-credentials.js b/dist/url-credentials.js new file mode 100644 index 0000000..d7b0f9c --- /dev/null +++ b/dist/url-credentials.js @@ -0,0 +1,147 @@ +// URL userinfo and query removal, shared by the message scrub and the URL value scrub. It imports +// nothing, so a browser bundle can load it from the `./url-credentials` entry. +// +// Every scan moves forward through the text once, so a scrub is linear in the input length. +// Free text splits into URL tokens at a space or a line break only, so a tab or another space +// inside a password does not end its URL. +export const URL_TOKEN = /[^ \n\r]+/g; +// Where a URL's authority starts: after a scheme's `://` or JSON-escaped `:\/\/` (matched from +// the start of its run of scheme characters); after a scheme's `:/`, or a `//`, at the start of a +// token or after a delimiter. +export const URL_START = /(?]/; +// A host and path also end at any whitespace. +export const URL_END = /[\s`<>]/; +// A quote ends a URL only at the token's end or before whitespace or a JSON or list delimiter. +export const QUOTE_END = /["'](?=$|[\s,;:}\])>"'&])/y; +// A `'` before `)` or `'`, which an encoded password keeps raw, does not end userinfo. +export const USERINFO_QUOTE = /'[)']/y; +// Text before an authority's first `/`, `?`, `#` or `\` that reads as a host and an optional port +// (a bracketed IPv6 host, or a non-empty host with no `:`, then `:` and digits or nothing). +export const HOST_PORT = /^(?:\[[^\]]*\]|[^:[\]]+)(?::\d*)?$/; +// The leading `scheme:` chain and one or two `/` (or `\/\/`), or a bare `//`, of a URL value. +export const URL_AUTHORITY = /^\s*(?:(?:[0-9+.-]*[a-z][a-z0-9+.-]*:)+(?:\/\/?|\\\/\\\/)|\/\/)/i; +export const QUERY_START = /[?#;]/; +/** The first index at or after `from` whose character passes `test`, or the text's length. */ +function finder(text, test) { + let lastFrom = -1; + let found = -1; + return (from) => { + // Calls whose `from` never decreases reuse the last result, which keeps a scan linear. + if (from < lastFrom || found < from) { + found = from; + while (found < text.length && !test(text[found], found)) + found++; + } + lastFrom = from; + return found; + }; +} +const isDelimiter = (char) => char === "/" || char === "?" || char === "#" || char === "\\"; +function scanner(text) { + return { + at: finder(text, (char) => char === "@"), + delimiter: finder(text, isDelimiter), + slash: finder(text, (char) => char === "/"), + queryValue: finder(text, (char) => char === "=" || char === "/"), + delimiterAfterAt: finder(text, isDelimiter), + }; +} +/** + * Where the authority at `from` continues past its userinfo, which runs to the last `@` before + * the first `/`, `?`, `#` or `\` after its first `@`, within `end`. Text before a delimiter that + * comes ahead of the first `@` is a host, not userinfo: + * - with a `:` or a leading `[`, when HOST_PORT matches and the head is bracketed, the delimiter + * is `/` or an escaped `\/`, or a `?` or `#` delimiter has a `=` or `/` after it before the `@`; + * - with neither, when a `/` comes before the `@`. + * A `//` or `://` inside userinfo starts no URL. Returns `from` when there is no userinfo. + */ +function afterUserinfo(text, from, end, scan) { + const at = scan.at(from); + if (at >= end) + return from; + const delimiter = scan.delimiter(from); + if (delimiter < at) { + const head = text.slice(from, delimiter); + if (head.includes(":") || head.startsWith("[")) { + const sign = text[delimiter]; + const hostEvidence = head.startsWith("[") || + sign === "/" || + (sign === "\\" ? text[delimiter + 1] === "/" : scan.queryValue(delimiter) < at); + if (hostEvidence && HOST_PORT.test(head)) + return from; + } + else if (scan.slash(delimiter) < at) { + return from; + } + } + const close = Math.min(scan.delimiterAfterAt(at + 1), end); + return text.lastIndexOf("@", close - 1) + 1; +} +/** + * In a URL token, each URL loses its userinfo, and a URL with a `?`, `#` or `;` after its + * authority loses everything from there to the next whitespace. A host and path run to URL_END, + * QUOTE_END or the next URL's start, whichever comes first. + */ +export function scrubUrlToken(token) { + URL_START.lastIndex = 0; + let start = URL_START.exec(token); + if (!start) + return token; + const scan = scanner(token); + const quoteEnds = (char, index) => { + if (char !== '"' && char !== "'") + return false; + QUOTE_END.lastIndex = index; + return QUOTE_END.test(token); + }; + const keptQuote = (index) => { + USERINFO_QUOTE.lastIndex = index; + return USERINFO_QUOTE.test(token); + }; + const userinfoEnd = finder(token, (char, i) => USERINFO_END.test(char) || (quoteEnds(char, i) && !keptQuote(i))); + const urlEnd = finder(token, (char, i) => URL_END.test(char) || quoteEnds(char, i)); + const query = finder(token, (char) => QUERY_START.test(char)); + const space = finder(token, (char) => /\s/.test(char)); + let out = token.slice(0, start.index); + while (start) { + const authority = start.index + start[0].length; + const host = afterUserinfo(token, authority, userinfoEnd(authority), scan); + const end = urlEnd(host); + URL_START.lastIndex = host; + let next = URL_START.exec(token); + const cut = query(host); + out += token.slice(start.index, authority); + let resume = host; + if (cut < (next ? Math.min(next.index, end) : end)) { + out += token.slice(host, cut); + resume = space(cut); + if (next && next.index < resume) { + URL_START.lastIndex = resume; + next = URL_START.exec(token); + } + } + out += token.slice(resume, next ? next.index : token.length); + start = next; + } + return out; +} +/** Every URL in free text, scrubbed as `scrubUrlToken` does. */ +export function scrubUrls(text) { + return text.replace(URL_TOKEN, scrubUrlToken); +} +/** + * A URL value without its userinfo. The authority starts after URL_AUTHORITY. With `schemeless`, + * a value without one that is not a path starts its authority at its first non-space character. + * The value is one URL, so its userinfo may run to the value's end. + */ +export function stripUserinfo(value, schemeless = false) { + const lead = URL_AUTHORITY.exec(value); + const authority = lead ? lead[0].length : value.length - value.trimStart().length; + if (!lead && (!schemeless || value.startsWith("/", authority))) + return value; + const host = afterUserinfo(value, authority, value.length, scanner(value)); + return value.slice(0, authority) + value.slice(host); +} diff --git a/dist/url-path.d.ts b/dist/url-path.d.ts new file mode 100644 index 0000000..533e054 --- /dev/null +++ b/dist/url-path.d.ts @@ -0,0 +1,26 @@ +/** + * Paths whose segments are bearer secrets, with the template each becomes. Anchored at the start + * of the path. An applicant intake token opens and submits an application for whoever holds it: + * the web page (`/apply/[token]`) and the api routes it calls. Any letter case and any run of `/` + * match, as a server routes them alike. + */ +export declare const TOKEN_PATH_TEMPLATES: readonly (readonly [RegExp, string])[]; +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats these. */ +export declare const URL_PATH_PATTERNS: Readonly>; +export declare const URL_PATH_LITERALS: Readonly<{ + ROUTE_TEMPLATE_PREFIX: "/"; +}>; +export declare const TOKEN_PATH_CONTRACT: readonly Readonly<{ + source: string; + flags: string; + replacement: string; +}>[]; +/** + * The URL with its path replaced by the route template when there is one, else with any token + * segment templated. The origin is kept; the query and fragment are the caller's to strip. A + * leading `//` may open a path rather than a host, so it is never kept beside a route, and a + * token path right after it is templated as a path. Leading whitespace is kept as it is. + */ +export declare function templateUrlPath(value: string, route: string | undefined, tokenPaths?: readonly (readonly [RegExp, string])[]): string; +/** A usable route template: a path, and not the shared label for unmatched requests. */ +export declare function routeTemplateOf(route: unknown): string | undefined; diff --git a/dist/url-path.js b/dist/url-path.js new file mode 100644 index 0000000..c1630ce --- /dev/null +++ b/dist/url-path.js @@ -0,0 +1,52 @@ +import { describePatterns } from "./scrub-descriptor.js"; +/** + * Paths whose segments are bearer secrets, with the template each becomes. Anchored at the start + * of the path. An applicant intake token opens and submits an application for whoever holds it: + * the web page (`/apply/[token]`) and the api routes it calls. Any letter case and any run of `/` + * match, as a server routes them alike. + */ +export const TOKEN_PATH_TEMPLATES = [ + [/^\/+v1\/+applications\/+by-token\/+[^/?#]+/i, "/v1/applications/by-token/:token"], + [/^\/+apply\/+[^/?#]+/i, "/apply/[token]"], +]; +// A `scheme:` chain and `//`, or a bare `//`, then the host. +const ORIGIN = /^((?:[a-z][a-z0-9+.-]*:)*\/\/[^/?#]*)(.*)$/is; +// An `http.route` is a template only when it is a path. +const ROUTE_TEMPLATE_PREFIX = "/"; +/** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats these. */ +export const URL_PATH_PATTERNS = describePatterns({ ORIGIN }); +export const URL_PATH_LITERALS = Object.freeze({ ROUTE_TEMPLATE_PREFIX }); +export const TOKEN_PATH_CONTRACT = Object.freeze(TOKEN_PATH_TEMPLATES.map(([pattern, replacement]) => Object.freeze({ source: pattern.source, flags: pattern.flags, replacement }))); +/** Splits `scheme://host/path` or `//host/path` into its origin and the rest; a path has none. */ +function splitOrigin(value) { + const match = ORIGIN.exec(value); + return match ? [match[1], match[2]] : ["", value]; +} +function templatePath(path, tokenPaths) { + return tokenPaths.reduce((out, [pattern, template]) => out.replace(pattern, template), path); +} +/** + * The URL with its path replaced by the route template when there is one, else with any token + * segment templated. The origin is kept; the query and fragment are the caller's to strip. A + * leading `//` may open a path rather than a host, so it is never kept beside a route, and a + * token path right after it is templated as a path. Leading whitespace is kept as it is. + */ +export function templateUrlPath(value, route, tokenPaths = TOKEN_PATH_TEMPLATES) { + const url = value.trimStart(); + const lead = value.slice(0, value.length - url.length); + const [origin, rest] = splitOrigin(url); + if (origin.startsWith("/")) { + if (route) + return `${lead}${route}`; + const asPath = templatePath(url, tokenPaths); + if (asPath !== url) + return `${lead}${asPath}`; + } + if (route) + return `${lead}${origin}${route}`; + return `${lead}${origin}${templatePath(rest, tokenPaths)}`; +} +/** A usable route template: a path, and not the shared label for unmatched requests. */ +export function routeTemplateOf(route) { + return typeof route === "string" && route.startsWith(ROUTE_TEMPLATE_PREFIX) ? route : undefined; +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 5bbd1ff..8a32c38 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,8 @@ { "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": false, + "declarationMap": false + }, "exclude": ["src/**/__tests__/**"] } From 63d72e537a2f20517ae50a5e7cf1d5428efb8c18 Mon Sep 17 00:00:00 2001 From: Victor Carvalho Date: Mon, 12 Oct 2026 22:30:51 -0300 Subject: [PATCH 05/12] fix(console-capture): scrub the rendered console line before export Arguments were sanitized one by one before formatting, so a value that takes its shape only once formatted (split across %s arguments, a custom inspect or toString) reached the exporter unscrubbed, and a Map or Set printed its entries past the key redaction. Collections are now expanded before the field sanitizer runs, and the formatted message is scrubbed as the last step, with long digit runs included. Co-Authored-By: Claude Opus 5.5 --- dist/console-capture.js | 51 ++++++++++++++++++++++++--- src/__tests__/console-capture.test.ts | 51 +++++++++++++++++++++++++++ src/console-capture.ts | 48 ++++++++++++++++++++++--- 3 files changed, 142 insertions(+), 8 deletions(-) diff --git a/dist/console-capture.js b/dist/console-capture.js index 573b19e..11ca46d 100644 --- a/dist/console-capture.js +++ b/dist/console-capture.js @@ -1,9 +1,12 @@ import { formatWithOptions } from "node:util"; import { createLogger } from "./logger.js"; import { sanitizeLogValue } from "./log-redact.js"; +import { scrubErrorText } from "./log-scrub.js"; import { hasLogLineSink } from "./log-sink.js"; import { currentService } from "./shared-log.js"; const LEVELS = ["warn", "error"]; +/** Values visited per line while expanding collections, as the field sanitizer caps its walk. */ +const MAX_NODES = 5_000; let originals; /** * Sends every `console.warn` and `console.error` line to OTLP logs, through the logger's scrub and @@ -39,16 +42,21 @@ export function releaseConsole() { originals = undefined; } /** - * The first Error goes under `err`; every other argument is sanitized like a log field (PII keys - * redacted, strings scrubbed) before it is formatted into the message. + * The first Error goes under `err`. Every other argument is sanitized like a log field (PII keys + * redacted at any depth, including inside a Map or Set), then the arguments are formatted, and the + * rendered text is scrubbed last: a custom inspect, a toString or a value split across arguments + * only takes its final shape once formatted. */ function exportLine(logger, level, args) { if (!hasLogLineSink()) return; try { const err = args.find((arg) => arg instanceof Error); - const rest = args.filter((arg) => arg !== err).map(sanitizeLogValue); - const message = rest.length > 0 ? formatWithOptions({ colors: false }, ...rest) : undefined; + const budget = { nodes: 0 }; + const rest = args + .filter((arg) => arg !== err) + .map((arg) => sanitizeLogValue(expandCollections(arg, budget, new WeakSet()))); + const message = rest.length > 0 ? scrubErrorText(formatWithOptions({ colors: false }, ...rest)) : undefined; if (err === undefined) logger[level](message ?? ""); else if (message === undefined) @@ -60,3 +68,38 @@ function exportLine(logger, level, args) { // the console line is already written } } +/** + * A copy in which every Map becomes a plain object and every Set an array, so the field sanitizer + * sees their keys; a byte view becomes `{ bytes }`. The sanitizer treats these as opaque, which is + * safe in a JSON log line (a Map serializes as `{}`) but not in console formatting, which prints + * their contents. Errors, dates, URLs and toJSON values are left to the sanitizer. + */ +function expandCollections(value, budget, seen) { + if (typeof value !== "object" || value === null) + return value; + if (++budget.nodes > MAX_NODES) + return "[truncated]"; + if (ArrayBuffer.isView(value)) + return { bytes: value.byteLength }; + if (value instanceof Error || value instanceof Date || value instanceof URL) + return value; + if (typeof value.toJSON === "function") + return value; + if (seen.has(value)) + return "[circular]"; + seen.add(value); + try { + const expand = (item) => expandCollections(item, budget, seen); + if (value instanceof Map) { + return Object.fromEntries([...value].map(([key, item]) => [String(key), expand(item)])); + } + if (value instanceof Set) + return [...value].map(expand); + if (Array.isArray(value)) + return value.map(expand); + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, expand(item)])); + } + finally { + seen.delete(value); + } +} diff --git a/src/__tests__/console-capture.test.ts b/src/__tests__/console-capture.test.ts index 79c2f2e..1f9d23d 100644 --- a/src/__tests__/console-capture.test.ts +++ b/src/__tests__/console-capture.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { inspect } from "node:util"; import { logs } from "@opentelemetry/api-logs"; import { captureConsole, releaseConsole } from "../console-capture.js"; import { log } from "../shared-log.js"; @@ -71,6 +72,56 @@ describe("captureConsole", () => { expect(record!.attributes.err).toMatchObject({ type: "Error" }); }); + // WHY: arguments are sanitized one by one, so a value split across them only takes its shape + // once they are joined; the joined message is scrubbed again. + it("scrubs an SSN formed only once %s arguments are joined", async () => { + console.error("ssn %s-%s", "123-45", "6789"); + const [record] = await flushLogs(); + expect(JSON.stringify(record)).not.toContain("123-45-6789"); + expect(record!.body).toBe("ssn [redacted]"); + }); + + it("scrubs an account number formed only once arguments are joined", async () => { + console.error("refund to %s%s", "4111111", "111111111"); + const [record] = await flushLogs(); + expect(JSON.stringify(record)).not.toContain("4111111111111111"); + }); + + it("scrubs a second Error's message and stack", async () => { + console.error(new Error("first"), new Error("applicant 123-45-6789 failed")); + const [record] = await flushLogs(); + expect(JSON.stringify(record)).not.toContain("123-45-6789"); + }); + + // WHY: the logger's JSON shows a Map as {}, but the console format shows its entries. + it("redacts a PII key inside a Map, a Set and a class instance", async () => { + class Lookup { + readonly owner = new Map([["password", "hunter2"]]); + } + console.error( + "lookup failed", + new Map([["phone", "555-0100"]]), + new Set([{ ssn: "987654321" }]), + new Lookup(), + new Uint8Array([104, 117, 110]), + ); + const [record] = await flushLogs(); + const text = JSON.stringify(record); + expect(text).not.toMatch(/555-0100|987654321|hunter2/); + expect(text).not.toContain("104, 117, 110"); + }); + + // WHY: what is checked must be what is exported; a custom inspect, toString or toJSON renders + // text the field sanitizer never walked. + it("scrubs what custom inspect, toString and toJSON render", async () => { + const custom = { [inspect.custom]: () => "ssn 123-45-6789" }; + const stringy = { toString: () => "account 4111111111111111" }; + const json = { toJSON: () => ({ email: "jane@example.com", note: "123-45-6789" }) }; + console.error("a %s b %O c %j d", stringy, custom, json); + const [record] = await flushLogs(); + expect(JSON.stringify(record)).not.toMatch(/123-45-6789|4111111111111111|jane@example\.com/); + }); + // WHY: with no logScope set, the scope filter must still keep the app's own lines. it("exports a console line and a log line under the default scope", async () => { console.error("from console"); diff --git a/src/console-capture.ts b/src/console-capture.ts index b5b8b98..c16eca7 100644 --- a/src/console-capture.ts +++ b/src/console-capture.ts @@ -2,10 +2,13 @@ import { formatWithOptions } from "node:util"; import type { Logger } from "pino"; import { createLogger } from "./logger.js"; import { sanitizeLogValue } from "./log-redact.js"; +import { scrubErrorText } from "./log-scrub.js"; import { hasLogLineSink } from "./log-sink.js"; import { currentService } from "./shared-log.js"; const LEVELS = ["warn", "error"] as const; +/** Values visited per line while expanding collections, as the field sanitizer caps its walk. */ +const MAX_NODES = 5_000; type Level = (typeof LEVELS)[number]; type ConsoleMethod = (...args: unknown[]) => void; @@ -44,15 +47,21 @@ export function releaseConsole(): void { } /** - * The first Error goes under `err`; every other argument is sanitized like a log field (PII keys - * redacted, strings scrubbed) before it is formatted into the message. + * The first Error goes under `err`. Every other argument is sanitized like a log field (PII keys + * redacted at any depth, including inside a Map or Set), then the arguments are formatted, and the + * rendered text is scrubbed last: a custom inspect, a toString or a value split across arguments + * only takes its final shape once formatted. */ function exportLine(logger: Logger, level: Level, args: unknown[]): void { if (!hasLogLineSink()) return; try { const err = args.find((arg) => arg instanceof Error); - const rest = args.filter((arg) => arg !== err).map(sanitizeLogValue); - const message = rest.length > 0 ? formatWithOptions({ colors: false }, ...rest) : undefined; + const budget = { nodes: 0 }; + const rest = args + .filter((arg) => arg !== err) + .map((arg) => sanitizeLogValue(expandCollections(arg, budget, new WeakSet()))); + const message = + rest.length > 0 ? scrubErrorText(formatWithOptions({ colors: false }, ...rest)) : undefined; if (err === undefined) logger[level](message ?? ""); else if (message === undefined) logger[level]({ err }); else logger[level]({ err }, message); @@ -60,3 +69,34 @@ function exportLine(logger: Logger, level: Level, args: unknown[]): void { // the console line is already written } } + +/** + * A copy in which every Map becomes a plain object and every Set an array, so the field sanitizer + * sees their keys; a byte view becomes `{ bytes }`. The sanitizer treats these as opaque, which is + * safe in a JSON log line (a Map serializes as `{}`) but not in console formatting, which prints + * their contents. Errors, dates, URLs and toJSON values are left to the sanitizer. + */ +function expandCollections( + value: unknown, + budget: { nodes: number }, + seen: WeakSet, +): unknown { + if (typeof value !== "object" || value === null) return value; + if (++budget.nodes > MAX_NODES) return "[truncated]"; + if (ArrayBuffer.isView(value)) return { bytes: value.byteLength }; + if (value instanceof Error || value instanceof Date || value instanceof URL) return value; + if (typeof (value as { toJSON?: unknown }).toJSON === "function") return value; + if (seen.has(value)) return "[circular]"; + seen.add(value); + try { + const expand = (item: unknown) => expandCollections(item, budget, seen); + if (value instanceof Map) { + return Object.fromEntries([...value].map(([key, item]) => [String(key), expand(item)])); + } + if (value instanceof Set) return [...value].map(expand); + if (Array.isArray(value)) return value.map(expand); + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, expand(item)])); + } finally { + seen.delete(value); + } +} From 38293af0d2053e4d0353791e58e130d85e5a74bf Mon Sep 17 00:00:00 2001 From: Victor Carvalho Date: Mon, 12 Oct 2026 22:30:56 -0300 Subject: [PATCH 06/12] feat(otel-node): shared Node telemetry package, extracted from Backbone CI runs the type check, the tests and the dist/ check on pull requests and pushes to main, read-only and with pinned actions. The README covers install by commit, the options, the two-layer scrub and the rules for an app using the package. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 43 +++++++++++ README.md | 152 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1a16502 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +# pull_request only (never pull_request_target): a fork's PR runs with no secrets and a +# read-only token. Nothing is published from here. +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm test + # Apps install this repo by commit, so the committed dist/ must be exactly a fresh build. + - name: dist/ matches a fresh build + run: | + pnpm build + git diff --exit-code -- dist + untracked="$(git status --porcelain -- dist)" + if [ -n "$untracked" ]; then + echo "dist/ differs from a fresh build:" + echo "$untracked" + exit 1 + fi diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f32968 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# @codelittinc/otel-node + +In-process OpenTelemetry for Node services: SDK boot (traces, metrics, OTLP logs, Pyroscope +profiles), layer 1 of the two-layer PII scrub, a pino logger whose scrubbed lines are exported +as OTLP logs, and trace-context helpers. Extracted from Backbone's `packages/telemetry`. + +Telemetry exports only when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. Without it nothing is exported +and no SDK module is loaded, so local runs, CI and tests stay silent and cheap. + +## Install by commit + +Nothing is published. Install the repo as a git dependency pinned to a commit; the built +`dist/` is committed, so there is no build step and no token is needed: + +```json +{ + "dependencies": { + "@codelittinc/otel-node": "github:codelittinc/otel-node#" + } +} +``` + +Pin a full commit SHA, never a branch. To upgrade, move the SHA. + +## Start telemetry + +Start it before the app loads `http`, from a file run with `node --import`: + +```ts +// src/instrumentation.ts, run as: node --import ./dist/instrumentation.js dist/index.js +import { PrismaInstrumentation } from "@prisma/instrumentation"; +import { startTelemetry } from "@codelittinc/otel-node"; + +await startTelemetry({ + serviceName: "api", + logScope: "acp", + metricViews: { instrumentPrefix: "acp.", allowedAttributes: ["queue", "outcome", "route"] }, + instrumentations: [new PrismaInstrumentation()], + redactKeys: ["unitLabel"], + onError: (err, context) => reportToErrorTracker(err, context), + ignoredPaths: ["/health"], +}); +``` + +An app with no options to pass can use the register entry instead. It reads +`OTEL_SERVICE_NAME`, which is also its log scope: + +```sh +OTEL_SERVICE_NAME=acp-worker node --import @codelittinc/otel-node/register dist/worker.js +``` + +`startTelemetry` never throws: a failed start logs `telemetry_start_failed` and the app runs on. +`shutdownTelemetry(timeoutMs = 5000)` flushes spans, metrics, log records and profiles, bounded +so a dead collector cannot stall a shutdown. + +## Options + +| Option | Default | What it does | +| ------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `serviceName` | required | `service.name`, unless `OTEL_SERVICE_NAME` is set. | +| `logScope` | `serviceName` | The OTel logger scope of exported log records. Only records from this scope are exported. Also names the BullMQ tracer and the `.request_id` span attribute. | +| `metricViews` | none | `{ instrumentPrefix, allowedAttributes }`: instruments named `*` export only those attributes. Without it, every attribute is kept. | +| `instrumentations` | `[]` | Appended to the package's list (http, undici, ioredis, pg, runtime-node). Pass Prisma here, at the app's own version. | +| `redactKeys` | `[]` | Log field names (whole name, any case) redacted on top of the default PII key list. It can only add; no default is ever removed. | +| `onError` | none | `(err, { service })`: receives each fatal error `installFatalHandlers` sees when it is given no `report`. Wire your error tracker here. | +| `ignoredPaths` | `[]` | Incoming paths that open no server span; each covers itself and everything below it. | +| `esmLoaderHook` | `true` | `false` for a host that runs bundled CommonJS (Next.js). | +| `logs` | `true` | `false` keeps log lines on stdout only. | +| `env` | `process.env` | Read instead of the process environment (tests). | + +Environment: `OTEL_EXPORTER_OTLP_ENDPOINT` (on/off), `OTEL_SDK_DISABLED=true` (kill switch), +`OTEL_SERVICE_NAME`, `OTEL_LOG_LEVEL` (SDK debug logging), `LOG_LEVEL`, +`PYROSCOPE_SERVER_ADDRESS` (profiling, independent of the endpoint), and on Railway +`RAILWAY_GIT_COMMIT_SHA`, `RAILWAY_DEPLOYMENT_ID`, `RAILWAY_ENVIRONMENT_NAME` and +`RAILWAY_REPLICA_ID` for the resource. + +## Entries + +| Import | What | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@codelittinc/otel-node` | `startTelemetry`, `shutdownTelemetry`, `isTelemetryStarted`, `bullmqTelemetry`, `setHttpRoute`, `getMeter`, `log`, `configureLogger`, `flushLog`, `captureConsole`, `installFatalHandlers`, `boundedLabel`, `currentTraceparent`, `withLinkedSpan`, `withRootSpan`, and the `TelemetryOptions`, `Meter` and `BatchObservableResult` types | +| `@codelittinc/otel-node/register` | `node --import` entry, no options | +| `@codelittinc/otel-node/testing` | `createTestLogger()`: the real logger, capturing parsed records in memory | +| `@codelittinc/otel-node/url-credentials` | URL userinfo and query scrub. Imports nothing from Node or OpenTelemetry, so a browser bundle can use it | + +`bullmq` is an optional peer dependency: `bullmqTelemetry()` returns the adapter for a Queue or +Worker `telemetry` option once telemetry has started, and `undefined` before. + +## Logging + +`log` is the one pino logger a process shares. Each entry point calls +`configureLogger({ service })` once; modules log through a child that binds `component` +(`log.child({ component: "db" })`). Every line is scrubbed before it is written (below), then the +same scrubbed line is copied to OTLP logs. + +An app that logs with `console` calls `captureConsole()` once at startup. Every `console.warn` and +`console.error` line still reaches the console unchanged, and a scrubbed copy is exported under +the log scope. `console.log` and `console.info` are left alone. The console copy on stdout is not +scrubbed: move a line that can carry personal data to `log`. + +## The two-layer scrub + +Personal data is removed twice before it is stored. + +1. **Layer 1, in the process (this package).** The logger redacts PII and credential keys at any + depth (`ssn`, `email`, `phone`, names, addresses, account and routing numbers, tokens, secrets, + plus `redactKeys`), serializes errors deny-by-default, and scrubs SSN shapes, emails and URL + userinfo and queries from every string. The span exporter drops request and response bodies + and headers, bound SQL values and Redis statements, redacts exception messages, strips URL + queries and userinfo, and replaces a URL path with its route template. +2. **Layer 2, in the agent.** The Alloy agent in `codelittinc/carbon-observability` repeats the + pattern scrub on every span, log and metric before it leaves the host. + +`src/scrub-contract.json` pins every layer-1 pattern and key set the agent repeats. This repo is +its source; `codelittinc/carbon-observability` follows with a copy at +`agent/contracts/otel-node-scrub-contract.json`. Changing a scrub pattern fails +`scrub-contract.test.ts` until the contract is updated here, then the agent's copy and its +`scrub.alloy` and `scrub-logs-metrics.alloy` follow in a PR there. + +## Rules for an app using this package + +1. **No PII in span or event attributes, span names, or log fields.** Record identifiers go on + spans and logs only, never on metrics. +2. **Metric labels come from fixed sets:** route template, method, status, queue, kind. Never a + raw path, an id, or an error message. Set `metricViews` so an app instrument exports only the + labels in `allowedAttributes`; a label missing there is dropped. `boundedLabel` collapses a + value outside an allowlist to `"other"`. +3. **Money is never a metric value.** Count events instead. +4. **A new instrumentation needs a scrub test** in `redacting-exporter.test.ts` before it merges. +5. **Only the scrubbed log line is exported.** Records are exported only from the log scope, built + from the shared logger's already scrubbed output; OTel's own diagnostics + (`event: "otel_diag"`) stay on stdout. Never add a logger instrumentation or a log record + processor that sees a record before the logger scrubs it. +6. **Only process entry points boot the SDK and call `installFatalHandlers`.** Libraries and + shared packages import only the logger API (`log`, `configureLogger`, `flushLog`) and + `currentTraceparent`. The `Meter` and `BatchObservableResult` types let callers type + instruments without `@opentelemetry/api`. +7. **Keep `@opentelemetry/*` versions in lockstep with this package.** Two versions of one OTel + package in an app's tree register two providers, and one of them silently never exports. + +## Development + +Node 22 and pnpm. + +```sh +pnpm install +pnpm typecheck +pnpm test +pnpm build # then commit dist/ +``` + +CI runs the type check, the tests, and a fresh build that must match the committed `dist/`. From 1c2ad128dd0e8040298cd3a449de31c20b614078 Mon Sep 17 00:00:00 2001 From: Victor Carvalho Date: Mon, 12 Oct 2026 22:44:39 -0300 Subject: [PATCH 07/12] fix: console capture never exports more than log; bullmq on its own entry Console arguments are now reduced to the logger's JSON form before formatting, so a function's properties and source, and Map, Set or byte view contents (including inside a toJSON result) are never exported, and a huge array is read only up to the sanitizer's breadth. A test pins that the SDK's console diag logger bypasses the capture. bullmqTelemetry moves to the ./bullmq entry, so the main entry's types never name bullmq; the optional peer is ^6.3.8, as bullmq-otel needs. A test typechecks the built main entry without bullmq, skipLibCheck off. Co-Authored-By: Claude Opus 5.5 --- README.md | 15 ++++-- dist/console-capture.js | 63 ++++++++-------------- dist/index.d.ts | 1 - dist/index.js | 1 - dist/instrumentations.js | 2 +- dist/sdk.d.ts | 8 +-- dist/sdk.js | 5 +- package.json | 6 ++- src/__tests__/console-capture.test.ts | 43 +++++++++++++++ src/__tests__/diag-console.test.ts | 49 +++++++++++++++++ src/__tests__/sdk-lazy.test.ts | 3 +- src/__tests__/types-without-bullmq.test.ts | 48 +++++++++++++++++ src/bullmq.ts | 2 +- src/console-capture.ts | 56 +++++++------------ src/index.ts | 1 - src/instrumentations.ts | 2 +- src/sdk.ts | 8 +-- 17 files changed, 217 insertions(+), 96 deletions(-) create mode 100644 src/__tests__/diag-console.test.ts create mode 100644 src/__tests__/types-without-bullmq.test.ts diff --git a/README.md b/README.md index 2f32968..6fe3eea 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,15 @@ Environment: `OTEL_EXPORTER_OTLP_ENDPOINT` (on/off), `OTEL_SDK_DISABLED=true` (k | Import | What | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@codelittinc/otel-node` | `startTelemetry`, `shutdownTelemetry`, `isTelemetryStarted`, `bullmqTelemetry`, `setHttpRoute`, `getMeter`, `log`, `configureLogger`, `flushLog`, `captureConsole`, `installFatalHandlers`, `boundedLabel`, `currentTraceparent`, `withLinkedSpan`, `withRootSpan`, and the `TelemetryOptions`, `Meter` and `BatchObservableResult` types | +| `@codelittinc/otel-node` | `startTelemetry`, `shutdownTelemetry`, `isTelemetryStarted`, `setHttpRoute`, `getMeter`, `log`, `configureLogger`, `flushLog`, `captureConsole`, `installFatalHandlers`, `boundedLabel`, `currentTraceparent`, `withLinkedSpan`, `withRootSpan`, and the `TelemetryOptions`, `Meter` and `BatchObservableResult` types | +| `@codelittinc/otel-node/bullmq` | `bullmqTelemetry()`: the adapter for a BullMQ Queue or Worker `telemetry` option | | `@codelittinc/otel-node/register` | `node --import` entry, no options | | `@codelittinc/otel-node/testing` | `createTestLogger()`: the real logger, capturing parsed records in memory | | `@codelittinc/otel-node/url-credentials` | URL userinfo and query scrub. Imports nothing from Node or OpenTelemetry, so a browser bundle can use it | -`bullmq` is an optional peer dependency: `bullmqTelemetry()` returns the adapter for a Queue or -Worker `telemetry` option once telemetry has started, and `undefined` before. +`bullmq` (`^6.3.8`) is an optional peer dependency, used only by the `./bullmq` entry, so an app +without it typechecks the main entry even with `skipLibCheck` off. `bullmqTelemetry()` returns +the adapter once telemetry has started, and `undefined` before. ## Logging @@ -95,8 +97,11 @@ same scrubbed line is copied to OTLP logs. An app that logs with `console` calls `captureConsole()` once at startup. Every `console.warn` and `console.error` line still reaches the console unchanged, and a scrubbed copy is exported under -the log scope. `console.log` and `console.info` are left alone. The console copy on stdout is not -scrubbed: move a line that can carry personal data to `log`. +the log scope. Each argument is first reduced to what `log` would export for it (PII keys +redacted, no function, Map or Set contents), and the formatted line is scrubbed last, so the +exported copy never holds more than a `log` line would. `console.log` and `console.info` are left +alone. The console copy on stdout is not scrubbed: move a line that can carry personal data to +`log`. The SDK's own diagnostics (`OTEL_LOG_LEVEL`) bypass the capture and are never exported. ## The two-layer scrub diff --git a/dist/console-capture.js b/dist/console-capture.js index 11ca46d..8f30117 100644 --- a/dist/console-capture.js +++ b/dist/console-capture.js @@ -5,8 +5,6 @@ import { scrubErrorText } from "./log-scrub.js"; import { hasLogLineSink } from "./log-sink.js"; import { currentService } from "./shared-log.js"; const LEVELS = ["warn", "error"]; -/** Values visited per line while expanding collections, as the field sanitizer caps its walk. */ -const MAX_NODES = 5_000; let originals; /** * Sends every `console.warn` and `console.error` line to OTLP logs, through the logger's scrub and @@ -42,20 +40,16 @@ export function releaseConsole() { originals = undefined; } /** - * The first Error goes under `err`. Every other argument is sanitized like a log field (PII keys - * redacted at any depth, including inside a Map or Set), then the arguments are formatted, and the - * rendered text is scrubbed last: a custom inspect, a toString or a value split across arguments - * only takes its final shape once formatted. + * The first Error goes under `err`. Every other argument is reduced to what the logger would + * export for it, then the arguments are formatted, and the rendered text is scrubbed last: a value + * split across arguments only takes its final shape once formatted. */ function exportLine(logger, level, args) { if (!hasLogLineSink()) return; try { const err = args.find((arg) => arg instanceof Error); - const budget = { nodes: 0 }; - const rest = args - .filter((arg) => arg !== err) - .map((arg) => sanitizeLogValue(expandCollections(arg, budget, new WeakSet()))); + const rest = args.filter((arg) => arg !== err).map(asLogged); const message = rest.length > 0 ? scrubErrorText(formatWithOptions({ colors: false }, ...rest)) : undefined; if (err === undefined) logger[level](message ?? ""); @@ -69,37 +63,26 @@ function exportLine(logger, level, args) { } } /** - * A copy in which every Map becomes a plain object and every Set an array, so the field sanitizer - * sees their keys; a byte view becomes `{ bytes }`. The sanitizer treats these as opaque, which is - * safe in a JSON log line (a Map serializes as `{}`) but not in console formatting, which prints - * their contents. Errors, dates, URLs and toJSON values are left to the sanitizer. + * An argument as the logger's JSON line would carry it: sanitized (bounded, PII keys redacted), + * then through JSON, so console formatting has nothing left to print that the line would not + * hold. No function, Map or Set contents, getter, custom inspect or toString survives. */ -function expandCollections(value, budget, seen) { - if (typeof value !== "object" || value === null) - return value; - if (++budget.nodes > MAX_NODES) - return "[truncated]"; +function asLogged(arg) { + if (typeof arg === "function") + return "[Function]"; + const sanitized = sanitizeLogValue(arg); + if (typeof sanitized !== "object" || sanitized === null) + return sanitized; + const json = JSON.stringify(sanitized, jsonSafe); + return json === undefined ? undefined : JSON.parse(json); +} +/** A byte view becomes its size, a Map or Set `{}`, a bigint its digits. */ +function jsonSafe(_key, value) { + if (typeof value === "bigint") + return value.toString(); if (ArrayBuffer.isView(value)) return { bytes: value.byteLength }; - if (value instanceof Error || value instanceof Date || value instanceof URL) - return value; - if (typeof value.toJSON === "function") - return value; - if (seen.has(value)) - return "[circular]"; - seen.add(value); - try { - const expand = (item) => expandCollections(item, budget, seen); - if (value instanceof Map) { - return Object.fromEntries([...value].map(([key, item]) => [String(key), expand(item)])); - } - if (value instanceof Set) - return [...value].map(expand); - if (Array.isArray(value)) - return value.map(expand); - return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, expand(item)])); - } - finally { - seen.delete(value); - } + if (value instanceof Map || value instanceof Set) + return {}; + return value; } diff --git a/dist/index.d.ts b/dist/index.d.ts index 9ee81ba..94db880 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,7 +1,6 @@ export { startTelemetry, shutdownTelemetry, isTelemetryStarted } from "./sdk.js"; export type { TelemetryConfig, TelemetryEnv } from "./config.js"; export type { ErrorHookContext, MetricViewOptions, TelemetryOptions } from "./options.js"; -export { bullmqTelemetry } from "./bullmq.js"; export { setHttpRoute } from "./http-route.js"; export { getMeter, type BatchObservableResult, type Meter } from "./meter.js"; export { log, configureLogger, flushLog } from "./shared-log.js"; diff --git a/dist/index.js b/dist/index.js index 5a504cf..9e051de 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,5 +1,4 @@ export { startTelemetry, shutdownTelemetry, isTelemetryStarted } from "./sdk.js"; -export { bullmqTelemetry } from "./bullmq.js"; export { setHttpRoute } from "./http-route.js"; export { getMeter } from "./meter.js"; export { log, configureLogger, flushLog } from "./shared-log.js"; diff --git a/dist/instrumentations.js b/dist/instrumentations.js index a643727..66ff8be 100644 --- a/dist/instrumentations.js +++ b/dist/instrumentations.js @@ -22,7 +22,7 @@ export function buildInstrumentations(ignoredPaths, appInstrumentations = []) { }), new UndiciInstrumentation(), new IORedisInstrumentation({ requireParentSpan: true }), - // SQL text only, never bound values (Prisma 7 runs every query through `pg`). + // SQL text only, never bound values. new PgInstrumentation({ enhancedDatabaseReporting: false, requireParentSpan: true }), ...appInstrumentations, ]; diff --git a/dist/sdk.d.ts b/dist/sdk.d.ts index 0a8a6e1..17eaaa9 100644 --- a/dist/sdk.d.ts +++ b/dist/sdk.d.ts @@ -1,4 +1,3 @@ -import type { Telemetry } from "bullmq"; import { type TelemetryConfig } from "./config.js"; import { type TelemetryOptions } from "./options.js"; /** @@ -13,8 +12,11 @@ import { type TelemetryOptions } from "./options.js"; export declare function startTelemetry(options: TelemetryOptions): Promise; /** True once `startTelemetry` has booted the SDK in this process. */ export declare function isTelemetryStarted(): boolean; -/** The BullMQ adapter built by `startTelemetry`; undefined until telemetry has started. */ -export declare function startedBullmqTelemetry(): Telemetry | undefined; +/** + * The BullMQ adapter built by `startTelemetry`; undefined until telemetry has started. Typed + * `unknown` so this module's types never name bullmq; `./bullmq` gives it its type. + */ +export declare function startedBullmqTelemetry(): unknown; /** * Flushes pending spans, metrics, log records and profiles, bounded so a dead collector cannot * stall a shutdown. diff --git a/dist/sdk.js b/dist/sdk.js index f0daeca..68eb671 100644 --- a/dist/sdk.js +++ b/dist/sdk.js @@ -56,7 +56,10 @@ export async function startTelemetry(options) { export function isTelemetryStarted() { return sdk !== undefined; } -/** The BullMQ adapter built by `startTelemetry`; undefined until telemetry has started. */ +/** + * The BullMQ adapter built by `startTelemetry`; undefined until telemetry has started. Typed + * `unknown` so this module's types never name bullmq; `./bullmq` gives it its type. + */ export function startedBullmqTelemetry() { return sdk?.bullmq; } diff --git a/package.json b/package.json index 55fe30e..ff9a427 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./bullmq": { + "types": "./dist/bullmq.d.ts", + "default": "./dist/bullmq.js" + }, "./testing": { "types": "./dist/testing.d.ts", "default": "./dist/testing.js" @@ -67,7 +71,7 @@ "vitest": "3.2.7" }, "peerDependencies": { - "bullmq": "^5.0.0 || ^6.0.0" + "bullmq": "^6.3.8" }, "peerDependenciesMeta": { "bullmq": { diff --git a/src/__tests__/console-capture.test.ts b/src/__tests__/console-capture.test.ts index 1f9d23d..9afc80f 100644 --- a/src/__tests__/console-capture.test.ts +++ b/src/__tests__/console-capture.test.ts @@ -122,6 +122,49 @@ describe("captureConsole", () => { expect(JSON.stringify(record)).not.toMatch(/123-45-6789|4111111111111111|jane@example\.com/); }); + // WHY: console formatting prints a function's own properties and, under %s, its source; the + // logger's JSON drops functions. Console capture must never export more than `log` would. + it("exports no property of a function, top-level or nested", async () => { + const handler = Object.assign(function handler() { + return "source with hunter2"; + }, { owner: { firstName: "Zelda", password: "hunter2" } }); + console.error("b", handler, { cb: handler }); + console.error("c %s %o", handler, handler); + const records = await flushLogs(); + expect(records).toHaveLength(2); + expect(JSON.stringify(records)).not.toMatch(/Zelda|hunter2/); + }); + + // WHY: the logger's JSON shows a Map or Set as {}, wherever a toJSON put it. + it("exports no Map, Set or byte view contents from inside a toJSON result", async () => { + const value = { + toJSON: () => ({ + nested: new Set([{ password: "hunter2" }]), + map: new Map([["k", "Zelda"]]), + bytes: new Uint8Array([104, 117, 110]), + }), + }; + console.error("d", value); + const [record] = await flushLogs(); + expect(JSON.stringify(record)).not.toMatch(/hunter2|Zelda|104, 117, 110/); + }); + + // WHY: the capture runs on the caller's path; a huge array must cost bounded time. + it("reads a bounded number of items from a huge array", async () => { + let reads = 0; + const huge = new Proxy(Array.from({ length: 200_000 }, (_, i) => i), { + get(target, key, receiver) { + if (typeof key === "string" && /^\d+$/.test(key)) reads++; + return Reflect.get(target, key, receiver); + }, + }); + written.error.mockImplementationOnce(() => undefined); + console.error("big", huge); + const [record] = await flushLogs(); + expect(reads).toBeLessThanOrEqual(2_000); + expect(record!.body).toMatch(/^big \[/); + }); + // WHY: with no logScope set, the scope filter must still keep the app's own lines. it("exports a console line and a log line under the default scope", async () => { console.error("from console"); diff --git a/src/__tests__/diag-console.test.ts b/src/__tests__/diag-console.test.ts new file mode 100644 index 0000000..ceb6c8d --- /dev/null +++ b/src/__tests__/diag-console.test.ts @@ -0,0 +1,49 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { diag } from "@opentelemetry/api"; +import { captureConsole, releaseConsole } from "../console-capture.js"; +import { shutdownTelemetry, startTelemetry } from "../sdk.js"; +import { exported, flushLogs, stopTelemetry } from "./otlp-export-helpers.js"; + +vi.mock("@opentelemetry/exporter-logs-otlp-proto", async () => + (await import("./otlp-export-helpers.js")).memoryLogExporterModule(), +); + +// Installed before @opentelemetry/api loads, so the SDK's console diag logger writes here. +const printed = vi.hoisted(() => { + const lines: string[] = []; + const keep = (...args: unknown[]) => void lines.push(args.map(String).join(" ")); + console.error = keep; + console.warn = keep; + return lines; +}); + +beforeAll(async () => { + // With OTEL_LOG_LEVEL set, NodeSDK installs its own console diag logger. + vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:9"); + vi.stubEnv("OTEL_LOG_LEVEL", "ERROR"); + await startTelemetry({ + serviceName: "acp-api", + esmLoaderHook: false, + env: { OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:9", OTEL_LOG_LEVEL: "ERROR" }, + }); + captureConsole(); + await flushLogs(); + exported.length = 0; +}); + +afterAll(async () => { + releaseConsole(); + await shutdownTelemetry(1_000); + await stopTelemetry(); +}); + +// WHY: an exporter failure reported through the console would otherwise be exported, fail, and +// report itself again, once per batch, forever. +describe("the SDK's console diag logger under captureConsole", () => { + it("prints an SDK error but never exports it", async () => { + diag.error("export failed: diag-sentinel"); + expect(printed.join("\n")).toContain("diag-sentinel"); + await flushLogs(); + expect(JSON.stringify(exported)).not.toContain("diag-sentinel"); + }); +}); diff --git a/src/__tests__/sdk-lazy.test.ts b/src/__tests__/sdk-lazy.test.ts index 3d9779a..8d850ef 100644 --- a/src/__tests__/sdk-lazy.test.ts +++ b/src/__tests__/sdk-lazy.test.ts @@ -16,8 +16,9 @@ vi.mock("bullmq-otel", () => { describe("@codelittinc/otel-node with no endpoint", () => { it("loads neither the SDK nor the BullMQ adapter", async () => { const telemetry = await import("../index.js"); + const { bullmqTelemetry } = await import("../bullmq.js"); expect((await telemetry.startTelemetry({ serviceName: "api", env: {} })).enabled).toBe(false); - expect(telemetry.bullmqTelemetry()).toBeUndefined(); + expect(bullmqTelemetry()).toBeUndefined(); expect(loaded.modules).toEqual([]); }); }); diff --git a/src/__tests__/types-without-bullmq.test.ts b/src/__tests__/types-without-bullmq.test.ts new file mode 100644 index 0000000..cfc4b17 --- /dev/null +++ b/src/__tests__/types-without-bullmq.test.ts @@ -0,0 +1,48 @@ +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const ROOT = fileURLToPath(new URL("../../", import.meta.url)); + +/** Type errors in `source`, compiled as an app's file with `bullmq` absent and lib checks on. */ +function typeErrors(source: string): string[] { + const file = `${ROOT}consumer-check.ts`; + const options: ts.CompilerOptions = { + strict: true, + noEmit: true, + skipLibCheck: false, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + target: ts.ScriptTarget.ES2022, + types: ["node"], + }; + const host = ts.createCompilerHost(options); + const hidden = (path: string) => /[\\/]node_modules[\\/](\.pnpm[\\/]bullmq@|bullmq[\\/])/.test(path); + const { fileExists, readFile, getSourceFile } = host; + host.fileExists = (path) => path === file || (!hidden(path) && fileExists.call(host, path)); + host.readFile = (path) => (path === file ? source : hidden(path) ? undefined : readFile.call(host, path)); + host.getSourceFile = (path, ...rest) => + path === file + ? ts.createSourceFile(path, source, ts.ScriptTarget.ES2022, true) + : getSourceFile.call(host, path, ...rest); + const program = ts.createProgram([file], options, host); + return ts + .getPreEmitDiagnostics(program) + .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n")); +} + +// WHY: bullmq is an optional peer. An app without it must typecheck the main entry even with +// skipLibCheck off; only the `./bullmq` entry may name its types. +describe("the built types without bullmq installed", () => { + it("typecheck the main entry", () => { + const errors = typeErrors( + 'import * as otel from "./dist/index.js";\nexport const start = otel.startTelemetry;\n', + ); + expect(errors).toEqual([]); + }); + + it("fail the ./bullmq entry, so bullmq is really hidden", () => { + const errors = typeErrors('export * from "./dist/bullmq.js";\n'); + expect(errors.join("\n")).toMatch(/bullmq/); + }); +}); diff --git a/src/bullmq.ts b/src/bullmq.ts index af4d5e4..2febaa2 100644 --- a/src/bullmq.ts +++ b/src/bullmq.ts @@ -6,5 +6,5 @@ import { startedBullmqTelemetry } from "./sdk.js"; * telemetry has started, which leaves BullMQ on its untraced code path. */ export function bullmqTelemetry(): Telemetry | undefined { - return startedBullmqTelemetry(); + return startedBullmqTelemetry() as Telemetry | undefined; } diff --git a/src/console-capture.ts b/src/console-capture.ts index c16eca7..f82830c 100644 --- a/src/console-capture.ts +++ b/src/console-capture.ts @@ -7,8 +7,6 @@ import { hasLogLineSink } from "./log-sink.js"; import { currentService } from "./shared-log.js"; const LEVELS = ["warn", "error"] as const; -/** Values visited per line while expanding collections, as the field sanitizer caps its walk. */ -const MAX_NODES = 5_000; type Level = (typeof LEVELS)[number]; type ConsoleMethod = (...args: unknown[]) => void; @@ -47,19 +45,15 @@ export function releaseConsole(): void { } /** - * The first Error goes under `err`. Every other argument is sanitized like a log field (PII keys - * redacted at any depth, including inside a Map or Set), then the arguments are formatted, and the - * rendered text is scrubbed last: a custom inspect, a toString or a value split across arguments - * only takes its final shape once formatted. + * The first Error goes under `err`. Every other argument is reduced to what the logger would + * export for it, then the arguments are formatted, and the rendered text is scrubbed last: a value + * split across arguments only takes its final shape once formatted. */ function exportLine(logger: Logger, level: Level, args: unknown[]): void { if (!hasLogLineSink()) return; try { const err = args.find((arg) => arg instanceof Error); - const budget = { nodes: 0 }; - const rest = args - .filter((arg) => arg !== err) - .map((arg) => sanitizeLogValue(expandCollections(arg, budget, new WeakSet()))); + const rest = args.filter((arg) => arg !== err).map(asLogged); const message = rest.length > 0 ? scrubErrorText(formatWithOptions({ colors: false }, ...rest)) : undefined; if (err === undefined) logger[level](message ?? ""); @@ -71,32 +65,22 @@ function exportLine(logger: Logger, level: Level, args: unknown[]): void { } /** - * A copy in which every Map becomes a plain object and every Set an array, so the field sanitizer - * sees their keys; a byte view becomes `{ bytes }`. The sanitizer treats these as opaque, which is - * safe in a JSON log line (a Map serializes as `{}`) but not in console formatting, which prints - * their contents. Errors, dates, URLs and toJSON values are left to the sanitizer. + * An argument as the logger's JSON line would carry it: sanitized (bounded, PII keys redacted), + * then through JSON, so console formatting has nothing left to print that the line would not + * hold. No function, Map or Set contents, getter, custom inspect or toString survives. */ -function expandCollections( - value: unknown, - budget: { nodes: number }, - seen: WeakSet, -): unknown { - if (typeof value !== "object" || value === null) return value; - if (++budget.nodes > MAX_NODES) return "[truncated]"; +function asLogged(arg: unknown): unknown { + if (typeof arg === "function") return "[Function]"; + const sanitized = sanitizeLogValue(arg); + if (typeof sanitized !== "object" || sanitized === null) return sanitized; + const json = JSON.stringify(sanitized, jsonSafe); + return json === undefined ? undefined : (JSON.parse(json) as unknown); +} + +/** A byte view becomes its size, a Map or Set `{}`, a bigint its digits. */ +function jsonSafe(_key: string, value: unknown): unknown { + if (typeof value === "bigint") return value.toString(); if (ArrayBuffer.isView(value)) return { bytes: value.byteLength }; - if (value instanceof Error || value instanceof Date || value instanceof URL) return value; - if (typeof (value as { toJSON?: unknown }).toJSON === "function") return value; - if (seen.has(value)) return "[circular]"; - seen.add(value); - try { - const expand = (item: unknown) => expandCollections(item, budget, seen); - if (value instanceof Map) { - return Object.fromEntries([...value].map(([key, item]) => [String(key), expand(item)])); - } - if (value instanceof Set) return [...value].map(expand); - if (Array.isArray(value)) return value.map(expand); - return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, expand(item)])); - } finally { - seen.delete(value); - } + if (value instanceof Map || value instanceof Set) return {}; + return value; } diff --git a/src/index.ts b/src/index.ts index 9ee81ba..94db880 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,6 @@ export { startTelemetry, shutdownTelemetry, isTelemetryStarted } from "./sdk.js"; export type { TelemetryConfig, TelemetryEnv } from "./config.js"; export type { ErrorHookContext, MetricViewOptions, TelemetryOptions } from "./options.js"; -export { bullmqTelemetry } from "./bullmq.js"; export { setHttpRoute } from "./http-route.js"; export { getMeter, type BatchObservableResult, type Meter } from "./meter.js"; export { log, configureLogger, flushLog } from "./shared-log.js"; diff --git a/src/instrumentations.ts b/src/instrumentations.ts index b87f0e5..f463d13 100644 --- a/src/instrumentations.ts +++ b/src/instrumentations.ts @@ -28,7 +28,7 @@ export function buildInstrumentations( }), new UndiciInstrumentation(), new IORedisInstrumentation({ requireParentSpan: true }), - // SQL text only, never bound values (Prisma 7 runs every query through `pg`). + // SQL text only, never bound values. new PgInstrumentation({ enhancedDatabaseReporting: false, requireParentSpan: true }), ...appInstrumentations, ]; diff --git a/src/sdk.ts b/src/sdk.ts index 16ad178..6352522 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -1,5 +1,4 @@ import { diag, DiagLogLevel } from "@opentelemetry/api"; -import type { Telemetry } from "bullmq"; import { resolveTelemetryConfig, type TelemetryConfig } from "./config.js"; import type { BootedSdk } from "./sdk-boot.js"; import { diagLogger, logSafely } from "./log-safely.js"; @@ -63,8 +62,11 @@ export function isTelemetryStarted(): boolean { return sdk !== undefined; } -/** The BullMQ adapter built by `startTelemetry`; undefined until telemetry has started. */ -export function startedBullmqTelemetry(): Telemetry | undefined { +/** + * The BullMQ adapter built by `startTelemetry`; undefined until telemetry has started. Typed + * `unknown` so this module's types never name bullmq; `./bullmq` gives it its type. + */ +export function startedBullmqTelemetry(): unknown { return sdk?.bullmq; } From 431f061d6b4ed8da04842efaf530325bf9b20975 Mon Sep 17 00:00:00 2001 From: Victor Carvalho Date: Mon, 12 Oct 2026 22:52:00 -0300 Subject: [PATCH 08/12] fix: no serialization hook of the caller's runs after the sanitizer The sanitizer's output is now inert in both log and console capture. A function is dropped (its own toJSON never runs), what any toJSON returns is walked like any value, a Map, Set or RegExp becomes {} and a byte view { bytes } whatever toJSON or own properties it carries, and a date is read with the built-in methods. An error's message and Prisma's meta target are copied only as strings, and a non-string message never reaches the stack header. A table test covers each hook at the top level, nested, in an array, Map, Set and toJSON result. Co-Authored-By: Claude Opus 5.5 --- dist/log-errors.js | 17 ++- dist/log-redact.d.ts | 6 +- dist/log-redact.js | 46 +++++--- src/__tests__/console-capture.test.ts | 10 ++ src/__tests__/serialization-hooks.test.ts | 138 ++++++++++++++++++++++ src/log-errors.ts | 17 ++- src/log-redact.ts | 38 ++++-- 7 files changed, 234 insertions(+), 38 deletions(-) create mode 100644 src/__tests__/serialization-hooks.test.ts diff --git a/dist/log-errors.js b/dist/log-errors.js index 93743c7..e768f99 100644 --- a/dist/log-errors.js +++ b/dist/log-errors.js @@ -81,8 +81,11 @@ function prismaMeta(err) { const target = read(meta, "target"); const modelName = read(meta, "modelName"); const out = {}; - if (typeof target === "string" || Array.isArray(target)) + // Column names only: an array keeps its strings, so no caller object reaches the serializer. + if (typeof target === "string") out.target = target; + else if (Array.isArray(target)) + out.target = target.filter((item) => typeof item === "string"); if (typeof modelName === "string") out.modelName = modelName; return out; @@ -126,7 +129,14 @@ function sanitizeFields(err, depth, ancestors) { out.message = scrubErrorText(rawMessage); const stack = str(read(err, "stack")); if (stack !== undefined) { - out.stack = scrubStack(stack, safe === undefined ? undefined : `${name ?? "Error"}: ${safe}`); + // A message that is not a string is rendered into the stack header by the runtime, through + // its own toString; the header then names the type only. + const header = safe !== undefined + ? `${name ?? "Error"}: ${safe}` + : rawMessage === undefined && read(err, "message") !== undefined + ? out.type + : undefined; + out.stack = scrubStack(stack, header); } for (const key of [...COMMON_KEYS, ...family.keys]) { const value = primitive(read(err, key)); @@ -163,7 +173,8 @@ export function safeErrorMessage(value) { return scrubErrorText(String(value)); const { raw, name } = nameOf(err); const safe = familyOf(raw ?? "", err).message?.(err, name ?? "Error"); - const message = safe ?? scrubErrorText(String(read(err, "message") ?? "")); + // A string message only: String() would run a caller object's toString or toPrimitive. + const message = safe ?? scrubErrorText(str(read(err, "message")) ?? ""); return message === "" ? typeOf(err, name) : message; } catch { diff --git a/dist/log-redact.d.ts b/dist/log-redact.d.ts index 5510b11..ce3a30c 100644 --- a/dist/log-redact.d.ts +++ b/dist/log-redact.d.ts @@ -6,8 +6,10 @@ export declare function addSensitiveKeys(keys: Iterable): void; export declare function resetSensitiveKeys(): void; export declare function isSensitiveKey(key: string): boolean; /** - * The JSON-facing form of an object: URL, Buffer and toJSON values converted; `done` stops the - * walk. + * The JSON-facing form of an object: URL, byte view, date and toJSON values converted; `done` + * stops the walk. A done value is inert: a plain string or object, so no serialization hook of + * the caller's (an own toJSON on a Map, a Date subclass) runs after the sanitizer. A Map, Set or + * RegExp becomes `{}`, as JSON shows one without own properties. */ export declare function convert(value: object): { done: boolean; diff --git a/dist/log-redact.js b/dist/log-redact.js index 555a3c2..02ad460 100644 --- a/dist/log-redact.js +++ b/dist/log-redact.js @@ -58,22 +58,29 @@ function isOpaque(value) { return (value instanceof Map || value instanceof Set || value instanceof RegExp || - value instanceof ArrayBuffer || - ArrayBuffer.isView(value)); + value instanceof ArrayBuffer); +} +/** A date as JSON shows it, read with the built-in method so a subclass cannot change it. */ +function isoDate(value) { + return Number.isNaN(Date.prototype.getTime.call(value)) + ? null + : Date.prototype.toISOString.call(value); } /** - * The JSON-facing form of an object: URL, Buffer and toJSON values converted; `done` stops the - * walk. + * The JSON-facing form of an object: URL, byte view, date and toJSON values converted; `done` + * stops the walk. A done value is inert: a plain string or object, so no serialization hook of + * the caller's (an own toJSON on a Map, a Date subclass) runs after the sanitizer. A Map, Set or + * RegExp becomes `{}`, as JSON shows one without own properties. */ export function convert(value) { if (value instanceof URL) return { done: true, value: scrubMessage(stripQuery(value.href)) }; - if (Buffer.isBuffer(value)) - return { done: true, value: { bytes: value.length } }; + if (ArrayBuffer.isView(value)) + return { done: true, value: { bytes: value.byteLength } }; if (value instanceof Date) - return { done: true, value }; + return { done: true, value: isoDate(value) }; if (isOpaque(value)) - return { done: true, value }; + return { done: true, value: {} }; const toJSON = value.toJSON; if (typeof toJSON === "function") return { done: false, value: toJSON.call(value) }; @@ -89,9 +96,8 @@ export function plainFields(fields, nested = false) { return { bytes: fields.byteLength }; if (Array.isArray(fields)) return { value: fields }; - if (fields instanceof Date) { - return { value: Number.isNaN(fields.getTime()) ? null : fields.toISOString() }; - } + if (fields instanceof Date) + return { value: isoDate(fields) }; if (fields instanceof Map) return { value: Object.fromEntries(fields) }; const { value } = convert(fields); @@ -156,6 +162,9 @@ function walk(value, depth, state) { return TRUNCATED; if (typeof value === "string") return scrubMessage(value); + // JSON drops a function; returning it would let its own toJSON run after the sanitizer. + if (typeof value === "function") + return undefined; if (typeof value !== "object" || value === null) return value; if (value instanceof Error) @@ -164,10 +173,9 @@ function walk(value, depth, state) { if (converted.done) return converted.value; const target = converted.value; - if (typeof target === "string") - return scrubMessage(target); - if (typeof target !== "object" || target === null) - return target; + // What a toJSON returns is walked like any value: a returned date, map or toJSON is reduced too. + if (target !== value) + return depth > MAX_DEPTH ? TRUNCATED : walk(target, depth + 1, state); if (state.ancestors.has(target)) return "[circular]"; if (depth > MAX_DEPTH) @@ -188,8 +196,12 @@ function walk(value, depth, state) { out[key] = REDACTED; else if (ERROR_KEYS.has(k)) out[key] = walkErrorValue(child, depth + 1, state); - else - out[key] = walk(child, depth + 1, state); + else { + // A dropped value (a function) leaves no key, as in JSON, so it cannot shadow a method. + const walked = walk(child, depth + 1, state); + if (walked !== undefined) + out[key] = walked; + } } return out; } diff --git a/src/__tests__/console-capture.test.ts b/src/__tests__/console-capture.test.ts index 9afc80f..f819b41 100644 --- a/src/__tests__/console-capture.test.ts +++ b/src/__tests__/console-capture.test.ts @@ -135,6 +135,16 @@ describe("captureConsole", () => { expect(JSON.stringify(records)).not.toMatch(/Zelda|hunter2/); }); + // WHY: JSON calls a function's own toJSON; the logger drops the function, so capture must too. + it("exports nothing from a nested function's own toJSON", async () => { + const fj = Object.assign(() => undefined, { + toJSON: () => ({ password: "hunter2", note: "Zelda" }), + }); + console.error("x", { cb: fj }, [fj]); + const [record] = await flushLogs(); + expect(JSON.stringify(record)).not.toMatch(/hunter2|Zelda/); + }); + // WHY: the logger's JSON shows a Map or Set as {}, wherever a toJSON put it. it("exports no Map, Set or byte view contents from inside a toJSON result", async () => { const value = { diff --git a/src/__tests__/serialization-hooks.test.ts b/src/__tests__/serialization-hooks.test.ts new file mode 100644 index 0000000..98fb2f0 --- /dev/null +++ b/src/__tests__/serialization-hooks.test.ts @@ -0,0 +1,138 @@ +import { inspect } from "node:util"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { captureConsole, releaseConsole } from "../console-capture.js"; +import { createTestLogger } from "../testing.js"; +import { bootWithMemoryLogs, exported, flushLogs, stopTelemetry } from "./otlp-export-helpers.js"; + +vi.mock("@opentelemetry/exporter-logs-otlp-proto", async () => + (await import("./otlp-export-helpers.js")).memoryLogExporterModule(), +); + +const LEAK = /hunter2|4111111111111111|123-45-6789/; +const TEXT = "hunter2 4111111111111111 123-45-6789"; +const payload = () => ({ + password: "hunter2", + accountNumber: "4111111111111111", + note: "ssn 123-45-6789", +}); + +class LeakyDate extends Date { + override toJSON(): string { + return payload() as unknown as string; + } + override toISOString(): string { + return TEXT; + } +} + +/** Values whose own serialization hook emits PII the sanitizer never walked. */ +const HOOKS: [string, () => unknown][] = [ + ["toJSON on an object", () => ({ toJSON: payload })], + ["toJSON on a function", () => Object.assign(() => undefined, { toJSON: payload })], + ["toJSON on a Map", () => Object.assign(new Map(), { toJSON: payload })], + ["toJSON on a Set", () => Object.assign(new Set(), { toJSON: payload })], + ["toJSON and toISOString on a Date subclass", () => new LeakyDate(0)], + ["toJSON on a RegExp", () => Object.assign(/x/, { toJSON: payload })], + ["toJSON on a byte view", () => Object.assign(new Uint8Array(2), { toJSON: payload })], + ["toJSON returning a value with its own toJSON", () => ({ toJSON: () => ({ toJSON: payload }) })], + ["own properties on a Map", () => Object.assign(new Map(), payload())], + ["own properties on a function", () => Object.assign(() => undefined, payload())], + ["inspect.custom", () => ({ [inspect.custom]: () => TEXT })], + ["toString", () => ({ toString: () => TEXT })], + ["Symbol.toPrimitive", () => ({ [Symbol.toPrimitive]: () => TEXT })], + [ + "getters", + () => ({ + get password() { + return "hunter2"; + }, + get accountNumber() { + return "4111111111111111"; + }, + get note() { + return "ssn 123-45-6789"; + }, + }), + ], +]; + +const POSITIONS: [string, (value: unknown) => unknown][] = [ + ["top level", (value) => value], + ["nested", (value) => ({ outer: { value } })], + ["inside an array", (value) => [1, value]], + ["inside a Map", (value) => new Map([["k", value]])], + ["inside a Set", (value) => new Set([value])], + ["inside a toJSON result", (value) => ({ toJSON: () => ({ value }) })], +]; + +const CASES = HOOKS.flatMap(([hook, make]) => + POSITIONS.map(([position, place]) => [hook, position, () => place(make())] as const), +); + +const original = { warn: console.warn, error: console.error }; + +beforeAll(async () => { + console.warn = () => undefined; + console.error = () => undefined; + await bootWithMemoryLogs({ serviceName: "acp-api" }); + captureConsole(); + await flushLogs(); +}); + +afterAll(async () => { + releaseConsole(); + console.warn = original.warn; + console.error = original.error; + await stopTelemetry(); +}); + +beforeEach(async () => { + await flushLogs(); + exported.length = 0; +}); + +// WHY: a value's own serialization hook runs after the sanitizer unless the sanitizer's output is +// inert; whatever the hook emits would be exported unredacted. +describe("serialization hooks", () => { + it.each(CASES)("log: %s, %s", (_hook, _position, value) => { + const { logger, records } = createTestLogger(); + logger.error({ field: value() }, "as a field"); + logger.error("as format args %s %o %j", value(), value() as object, value() as object); + const placed = value(); + if (typeof placed === "object" && placed !== null) logger.error(placed, "as the object"); + expect(records.length).toBeGreaterThanOrEqual(2); + expect(JSON.stringify(records)).not.toMatch(LEAK); + }); + + it.each(CASES)("console capture: %s, %s", async (_hook, _position, value) => { + console.error("as an argument", value()); + console.error("as format args %s %o %O %j", value(), value(), value(), value()); + const records = await flushLogs(); + expect(records).toHaveLength(2); + expect(JSON.stringify(records.map((r) => [r.body, r.attributes]))).not.toMatch(LEAK); + }); + + // An error is serialized deny-by-default; its message and Prisma's meta target are the fields + // that are copied from the caller's value. + it.each(HOOKS)("log: %s as an error's message or Prisma meta target", (_hook, make) => { + const { logger, records } = createTestLogger(); + const withMessage = Object.assign(new Error("x"), { message: make() }); + const prisma = Object.assign(new Error("unique"), { + name: "PrismaClientKnownRequestError", + code: "P2002", + meta: { target: ["email", make()] }, + }); + logger.error({ err: withMessage }); + logger.error(withMessage); + logger.error({ err: prisma }, "prisma"); + expect(JSON.stringify(records)).not.toMatch(LEAK); + }); + + it.each(HOOKS)("console capture: %s as an error's message", async (_hook, make) => { + console.error(Object.assign(new Error("x"), { message: make() })); + console.error("second", Object.assign(new Error("y"), { message: make() })); + const records = await flushLogs(); + expect(records).toHaveLength(2); + expect(JSON.stringify(records.map((r) => [r.body, r.attributes]))).not.toMatch(LEAK); + }); +}); diff --git a/src/log-errors.ts b/src/log-errors.ts index 80834b0..a476959 100644 --- a/src/log-errors.ts +++ b/src/log-errors.ts @@ -91,7 +91,9 @@ function prismaMeta(err: ErrorRecord): ErrorRecord | undefined { const target = read(meta, "target"); const modelName = read(meta, "modelName"); const out: ErrorRecord = {}; - if (typeof target === "string" || Array.isArray(target)) out.target = target; + // Column names only: an array keeps its strings, so no caller object reaches the serializer. + if (typeof target === "string") out.target = target; + else if (Array.isArray(target)) out.target = target.filter((item) => typeof item === "string"); if (typeof modelName === "string") out.modelName = modelName; return out; } @@ -133,7 +135,15 @@ function sanitizeFields(err: ErrorRecord, depth: number, ancestors: WeakSet { if (ArrayBuffer.isView(fields)) return { bytes: fields.byteLength }; if (Array.isArray(fields)) return { value: fields }; - if (fields instanceof Date) { - return { value: Number.isNaN(fields.getTime()) ? null : fields.toISOString() }; - } + if (fields instanceof Date) return { value: isoDate(fields) }; if (fields instanceof Map) return { value: Object.fromEntries(fields) }; const { value } = convert(fields); if (value === fields) return fields as Record; @@ -164,13 +170,15 @@ function keyedEntries(target: object): { function walk(value: unknown, depth: number, state: WalkState): unknown { if (++state.nodes > MAX_NODES) return TRUNCATED; if (typeof value === "string") return scrubMessage(value); + // JSON drops a function; returning it would let its own toJSON run after the sanitizer. + if (typeof value === "function") return undefined; if (typeof value !== "object" || value === null) return value; if (value instanceof Error) return serializeError(value); const converted = convert(value); if (converted.done) return converted.value; const target = converted.value; - if (typeof target === "string") return scrubMessage(target); - if (typeof target !== "object" || target === null) return target; + // What a toJSON returns is walked like any value: a returned date, map or toJSON is reduced too. + if (target !== value) return depth > MAX_DEPTH ? TRUNCATED : walk(target, depth + 1, state); if (state.ancestors.has(target)) return "[circular]"; if (depth > MAX_DEPTH) return TRUNCATED; state.ancestors.add(target); @@ -186,7 +194,11 @@ function walk(value: unknown, depth: number, state: WalkState): unknown { } if (isSensitiveKey(k)) out[key] = REDACTED; else if (ERROR_KEYS.has(k)) out[key] = walkErrorValue(child, depth + 1, state); - else out[key] = walk(child, depth + 1, state); + else { + // A dropped value (a function) leaves no key, as in JSON, so it cannot shadow a method. + const walked = walk(child, depth + 1, state); + if (walked !== undefined) out[key] = walked; + } } return out; } finally { From 96758cd4cd1e5a1802074b9aff435644ee40da50 Mon Sep 17 00:00:00 2001 From: Victor Carvalho Date: Mon, 12 Oct 2026 23:05:20 -0300 Subject: [PATCH 09/12] fix: final review findings in the scrub and startTelemetry - A primitive toJSON result (a Decimal's string) costs no depth. - Console capture puts an Error's safe message where %s takes it. - redactKeys also redacts span attributes, by whole name or last segment; the agent's layer 2 does not know them. - startTelemetry: the first call's options win whether or not it exports, with a telemetry_restart_ignored warning, and options it cannot read log telemetry_start_failed instead of rejecting. - Tests pin the inert format-arg rendering and assert the hooks table never passes by failing closed. Co-Authored-By: Claude Opus 5.5 --- dist/app-keys.d.ts | 8 ++++ dist/app-keys.js | 27 +++++++++++ dist/console-capture.js | 16 +++---- dist/log-redact.d.ts | 5 +-- dist/log-redact.js | 24 ++++------ dist/options.d.ts | 2 + dist/options.js | 25 +++++++++++ dist/redact.d.ts | 3 +- dist/redact.js | 6 ++- dist/sdk.d.ts | 4 +- dist/sdk.js | 54 ++++++++++++++++------ src/__tests__/console-capture.test.ts | 10 +++++ src/__tests__/log-format-args.test.ts | 15 +++++++ src/__tests__/log-redact.test.ts | 10 +++++ src/__tests__/options.test.ts | 55 +++++++++++++++++++++++ src/__tests__/serialization-hooks.test.ts | 8 ++++ src/app-keys.ts | 27 +++++++++++ src/console-capture.ts | 16 +++---- src/log-redact.ts | 25 ++++------- src/options.ts | 22 +++++++++ src/redact.ts | 6 ++- src/sdk.ts | 52 +++++++++++++++------ 22 files changed, 334 insertions(+), 86 deletions(-) create mode 100644 dist/app-keys.d.ts create mode 100644 dist/app-keys.js create mode 100644 src/app-keys.ts diff --git a/dist/app-keys.d.ts b/dist/app-keys.d.ts new file mode 100644 index 0000000..5427452 --- /dev/null +++ b/dist/app-keys.d.ts @@ -0,0 +1,8 @@ +/** Adds key names (matched whole, any letter case); throws on a key that is not a string. */ +export declare function addAppKeys(keys: Iterable): void; +/** Test seam: forgets the app-supplied keys. */ +export declare function resetAppKeys(): void; +/** True when `key` is an app-supplied key name, in any letter case. */ +export declare function isAppKey(key: string): boolean; +/** A span attribute is an app key when its whole name or its last `.` segment is one. */ +export declare function isAppAttributeKey(key: string): boolean; diff --git a/dist/app-keys.js b/dist/app-keys.js new file mode 100644 index 0000000..4e4d58e --- /dev/null +++ b/dist/app-keys.js @@ -0,0 +1,27 @@ +/** App-supplied key names, lower-cased; redacted in log fields and span attributes. */ +const appKeys = new Set(); +/** Adds key names (matched whole, any letter case); throws on a key that is not a string. */ +export function addAppKeys(keys) { + const names = [...keys].map((key) => { + if (typeof key !== "string") + throw new TypeError("redactKeys must hold strings"); + return key.trim().toLowerCase(); + }); + for (const name of names) + if (name) + appKeys.add(name); +} +/** Test seam: forgets the app-supplied keys. */ +export function resetAppKeys() { + appKeys.clear(); +} +/** True when `key` is an app-supplied key name, in any letter case. */ +export function isAppKey(key) { + return appKeys.size > 0 && appKeys.has(key.toLowerCase()); +} +/** A span attribute is an app key when its whole name or its last `.` segment is one. */ +export function isAppAttributeKey(key) { + if (appKeys.size === 0) + return false; + return isAppKey(key) || isAppKey(key.slice(key.lastIndexOf(".") + 1)); +} diff --git a/dist/console-capture.js b/dist/console-capture.js index 8f30117..0882bf2 100644 --- a/dist/console-capture.js +++ b/dist/console-capture.js @@ -1,5 +1,6 @@ import { formatWithOptions } from "node:util"; import { createLogger } from "./logger.js"; +import { safeErrorMessage } from "./log-errors.js"; import { sanitizeLogValue } from "./log-redact.js"; import { scrubErrorText } from "./log-scrub.js"; import { hasLogLineSink } from "./log-sink.js"; @@ -40,21 +41,20 @@ export function releaseConsole() { originals = undefined; } /** - * The first Error goes under `err`. Every other argument is reduced to what the logger would - * export for it, then the arguments are formatted, and the rendered text is scrubbed last: a value - * split across arguments only takes its final shape once formatted. + * The first Error goes under `err`, and its safe message takes its place in the text, so a `%s` + * still reads as on the console. Every other argument is reduced to what the logger would export + * for it, then the arguments are formatted, and the rendered text is scrubbed last: a value split + * across arguments only takes its final shape once formatted. */ function exportLine(logger, level, args) { if (!hasLogLineSink()) return; try { const err = args.find((arg) => arg instanceof Error); - const rest = args.filter((arg) => arg !== err).map(asLogged); - const message = rest.length > 0 ? scrubErrorText(formatWithOptions({ colors: false }, ...rest)) : undefined; + const parts = args.map((arg) => (arg === err ? safeErrorMessage(err) : asLogged(arg))); + const message = scrubErrorText(formatWithOptions({ colors: false }, ...parts)); if (err === undefined) - logger[level](message ?? ""); - else if (message === undefined) - logger[level]({ err }); + logger[level](message); else logger[level]({ err }, message); } diff --git a/dist/log-redact.d.ts b/dist/log-redact.d.ts index ce3a30c..97b2a64 100644 --- a/dist/log-redact.d.ts +++ b/dist/log-redact.d.ts @@ -1,9 +1,6 @@ /** Pinned by scrub-contract.test.ts: the agent's layer-2 log scrub repeats these patterns. */ export declare const LOG_REDACT_PATTERNS: Readonly>; -/** Adds key names (matched whole, any letter case) to the redacted set; a default stays. */ -export declare function addSensitiveKeys(keys: Iterable): void; -/** Test seam: forgets the app-supplied keys. */ -export declare function resetSensitiveKeys(): void; +export { addAppKeys as addSensitiveKeys, resetAppKeys as resetSensitiveKeys } from "./app-keys.js"; export declare function isSensitiveKey(key: string): boolean; /** * The JSON-facing form of an object: URL, byte view, date and toJSON values converted; `done` diff --git a/dist/log-redact.js b/dist/log-redact.js index 02ad460..bad169b 100644 --- a/dist/log-redact.js +++ b/dist/log-redact.js @@ -2,6 +2,7 @@ import { serializeError } from "./log-serializer.js"; import { scrubMessage } from "./log-scrub.js"; import { stripQuery } from "./redact.js"; import { describePatterns } from "./scrub-descriptor.js"; +import { isAppKey } from "./app-keys.js"; // Matches PII and credential key names (camelCase, snake_case, kebab-case) at any depth. // `ssn`, `dob` and `ein` are anchored so words such as "businessName", "adobe" or "protein" // stay readable; a camelCase `...Ein` is matched case-sensitively in isSensitiveKey. @@ -24,22 +25,9 @@ export const LOG_REDACT_PATTERNS = describePatterns({ PERSON_ID, RECORD_ID, }); -/** App-supplied key names, lower-cased; redacted on top of the patterns above. */ -const extraKeys = new Set(); -/** Adds key names (matched whole, any letter case) to the redacted set; a default stays. */ -export function addSensitiveKeys(keys) { - for (const key of keys) { - const name = key.trim().toLowerCase(); - if (name) - extraKeys.add(name); - } -} -/** Test seam: forgets the app-supplied keys. */ -export function resetSensitiveKeys() { - extraKeys.clear(); -} +export { addAppKeys as addSensitiveKeys, resetAppKeys as resetSensitiveKeys } from "./app-keys.js"; export function isSensitiveKey(key) { - if (extraKeys.size > 0 && extraKeys.has(key.toLowerCase())) + if (isAppKey(key)) return true; if (PERSON_ID.test(key)) return true; @@ -174,8 +162,12 @@ function walk(value, depth, state) { return converted.value; const target = converted.value; // What a toJSON returns is walked like any value: a returned date, map or toJSON is reduced too. - if (target !== value) + // A primitive result (a Decimal's string) costs no depth. + if (target !== value) { + if (typeof target !== "object" || target === null) + return walk(target, depth, state); return depth > MAX_DEPTH ? TRUNCATED : walk(target, depth + 1, state); + } if (state.ancestors.has(target)) return "[circular]"; if (depth > MAX_DEPTH) diff --git a/dist/options.d.ts b/dist/options.d.ts index ccc20e0..984e9e7 100644 --- a/dist/options.d.ts +++ b/dist/options.d.ts @@ -34,5 +34,7 @@ export declare function applyOptions(options: TelemetryOptions): void; export declare function logScope(): string; /** Calls the app's error hook, if any, with `service` set; never throws. */ export declare function reportError(err: unknown, context?: ErrorHookContext): void; +/** True when two calls pass the same options; `env` aside, since it only says whether to export. */ +export declare function sameOptions(a: TelemetryOptions, b: TelemetryOptions): boolean; /** Test seam: forgets the recorded options. */ export declare function resetOptions(): void; diff --git a/dist/options.js b/dist/options.js index b7051aa..8b0912c 100644 --- a/dist/options.js +++ b/dist/options.js @@ -20,6 +20,31 @@ export function reportError(err, context = {}) { // reporting must never break the caller } } +/** Equal arrays: the same items, by identity. */ +function sameItems(a, b) { + if (!Array.isArray(a) || !Array.isArray(b)) + return a === b; + return a.length === b.length && a.every((item, i) => item === b[i]); +} +/** True when two calls pass the same options; `env` aside, since it only says whether to export. */ +export function sameOptions(a, b) { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + keys.delete("env"); + for (const key of keys) { + const x = a[key]; + const y = b[key]; + if (key === "metricViews") { + const [m, n] = [x, y]; + if (m?.instrumentPrefix !== n?.instrumentPrefix) + return false; + if (!sameItems(m?.allowedAttributes, n?.allowedAttributes)) + return false; + } + else if (!sameItems(x, y)) + return false; + } + return true; +} /** Test seam: forgets the recorded options. */ export function resetOptions() { scope = undefined; diff --git a/dist/redact.d.ts b/dist/redact.d.ts index 40f041a..9df37af 100644 --- a/dist/redact.d.ts +++ b/dist/redact.d.ts @@ -18,6 +18,7 @@ export declare function scrubSpanName(name: string, route: string | undefined, t * Scrubs one attribute set. Every URL attribute loses its query, fragment and userinfo, and its * path becomes the span's `http.route` template when it has one, so a path parameter never * leaves. Without a route (client spans, unmatched requests) `url.path` is dropped and known - * token-bearing paths are templated in the remaining URL attributes. + * token-bearing paths are templated in the remaining URL attributes. An attribute named by the + * app's `redactKeys` (its whole name or last `.` segment) is redacted. */ export declare function scrubAttributes(attributes: Attributes, tokenPaths?: readonly (readonly [RegExp, string])[]): Attributes; diff --git a/dist/redact.js b/dist/redact.js index fd83a0a..e22efe5 100644 --- a/dist/redact.js +++ b/dist/redact.js @@ -1,3 +1,4 @@ +import { isAppAttributeKey } from "./app-keys.js"; import { scrubMessage } from "./log-scrub.js"; import { describeKeySets, describePatterns } from "./scrub-descriptor.js"; import { stripUserinfo, URL_AUTHORITY } from "./url-credentials.js"; @@ -87,7 +88,8 @@ function scrubExceptionStacktrace(stacktrace, exceptionType) { * Scrubs one attribute set. Every URL attribute loses its query, fragment and userinfo, and its * path becomes the span's `http.route` template when it has one, so a path parameter never * leaves. Without a route (client spans, unmatched requests) `url.path` is dropped and known - * token-bearing paths are templated in the remaining URL attributes. + * token-bearing paths are templated in the remaining URL attributes. An attribute named by the + * app's `redactKeys` (its whole name or last `.` segment) is redacted. */ export function scrubAttributes(attributes, tokenPaths = TOKEN_PATH_TEMPLATES) { const redis = isRedis(attributes); @@ -100,7 +102,7 @@ export function scrubAttributes(attributes, tokenPaths = TOKEN_PATH_TEMPLATES) { continue; if (key === "url.path" && !route) continue; - if (REDACTED_KEYS.has(key)) { + if (REDACTED_KEYS.has(key) || isAppAttributeKey(key)) { scrubbed[key] = "[redacted]"; } else if (key === "exception.stacktrace") { diff --git a/dist/sdk.d.ts b/dist/sdk.d.ts index 17eaaa9..f7434a9 100644 --- a/dist/sdk.d.ts +++ b/dist/sdk.d.ts @@ -7,7 +7,9 @@ import { type TelemetryOptions } from "./options.js"; * registered here, so this module must run under `node --import` before the app loads `http`. * The SDK itself is imported only once telemetry is enabled. A host that runs bundled CommonJS * (Next.js) passes `esmLoaderHook: false`. The log scope, `redactKeys` and `onError` apply even - * when export is off, since they shape stdout and error reporting too. + * when export is off, since they shape stdout and error reporting too. The first call's options + * win whether or not it exported; a later call may only turn export on through its `env`, and + * warns `telemetry_restart_ignored` when its options differ. */ export declare function startTelemetry(options: TelemetryOptions): Promise; /** True once `startTelemetry` has booted the SDK in this process. */ diff --git a/dist/sdk.js b/dist/sdk.js index 68eb671..0e45546 100644 --- a/dist/sdk.js +++ b/dist/sdk.js @@ -2,10 +2,11 @@ import { diag, DiagLogLevel } from "@opentelemetry/api"; import { resolveTelemetryConfig } from "./config.js"; import { diagLogger, logSafely } from "./log-safely.js"; import { addSensitiveKeys } from "./log-redact.js"; -import { applyOptions, logScope } from "./options.js"; +import { applyOptions, logScope, sameOptions } from "./options.js"; import { startProfiling, stopProfiling } from "./profiling.js"; let sdk; let started; +let applied; /** * Boots the OpenTelemetry Node SDK. Never rejects: a failed start is logged and the config * is returned. Telemetry starts once per process; a restart after `shutdownTelemetry` is not @@ -13,16 +14,26 @@ let started; * registered here, so this module must run under `node --import` before the app loads `http`. * The SDK itself is imported only once telemetry is enabled. A host that runs bundled CommonJS * (Next.js) passes `esmLoaderHook: false`. The log scope, `redactKeys` and `onError` apply even - * when export is off, since they shape stdout and error reporting too. + * when export is off, since they shape stdout and error reporting too. The first call's options + * win whether or not it exported; a later call may only turn export on through its `env`, and + * warns `telemetry_restart_ignored` when its options differ. */ export async function startTelemetry(options) { - if (started) - return started; - applyOptions(options); - if (options.redactKeys) - addSensitiveKeys(options.redactKeys); - const env = options.env ?? process.env; - const config = resolveTelemetryConfig(options.serviceName, env); + let effective; + let env; + let config; + try { + effective = resolveOptions(options); + if (started) + return started; + env = effective.env ?? process.env; + config = resolveTelemetryConfig(effective.serviceName, env); + } + catch (error) { + logSafely("warn", { err: error, event: "telemetry_start_failed" }, "telemetry_start_failed"); + const name = typeof options?.serviceName === "string" ? options.serviceName : "unknown"; + return { ...resolveTelemetryConfig(name, {}), enabled: false }; + } if (!config.enabled) { await startProfiling(config, env); return config; @@ -33,11 +44,11 @@ export async function startTelemetry(options) { const { bootSdk } = await import("./sdk-boot.js"); sdk = await bootSdk(config, { logScope: logScope(), - ignoredPaths: options.ignoredPaths ?? [], - esmLoaderHook: options.esmLoaderHook !== false, - exportLogs: options.logs !== false, - metricViews: options.metricViews, - instrumentations: options.instrumentations, + ignoredPaths: effective.ignoredPaths ?? [], + esmLoaderHook: effective.esmLoaderHook !== false, + exportLogs: effective.logs !== false, + metricViews: effective.metricViews, + instrumentations: effective.instrumentations, }); logSafely("info", { event: "telemetry_started", @@ -52,6 +63,20 @@ export async function startTelemetry(options) { await startProfiling(config, env); return config; } +/** The first call's options, applied once; a later call keeps them and only brings its `env`. */ +function resolveOptions(options) { + if (applied) { + if (!sameOptions(applied, options)) { + logSafely("warn", { event: "telemetry_restart_ignored" }, "telemetry_restart_ignored"); + } + return { ...applied, env: options.env }; + } + applyOptions(options); + if (options.redactKeys) + addSensitiveKeys(options.redactKeys); + applied = options; + return options; +} /** True once `startTelemetry` has booted the SDK in this process. */ export function isTelemetryStarted() { return sdk !== undefined; @@ -69,6 +94,7 @@ export function startedBullmqTelemetry() { */ export async function shutdownTelemetry(timeoutMs = 5_000) { started = undefined; + applied = undefined; const running = sdk; sdk = undefined; const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs).unref()); diff --git a/src/__tests__/console-capture.test.ts b/src/__tests__/console-capture.test.ts index f819b41..578d410 100644 --- a/src/__tests__/console-capture.test.ts +++ b/src/__tests__/console-capture.test.ts @@ -72,6 +72,16 @@ describe("captureConsole", () => { expect(record!.attributes.err).toMatchObject({ type: "Error" }); }); + // WHY: the exported line must read like the console line, so a Loki search finds it. + it("puts an Error's scrubbed message where a format directive takes it", async () => { + const err = new Error("charge for 123-45-6789 declined"); + console.error("charge failed: %s", err); + const [record] = await flushLogs(); + expect(record!.body).toBe("charge failed: charge for [redacted] declined"); + expect(record!.attributes.err).toMatchObject({ type: "Error" }); + expect(JSON.stringify(record)).not.toContain("123-45-6789"); + }); + // WHY: arguments are sanitized one by one, so a value split across them only takes its shape // once they are joined; the joined message is scrubbed again. it("scrubs an SSN formed only once %s arguments are joined", async () => { diff --git a/src/__tests__/log-format-args.test.ts b/src/__tests__/log-format-args.test.ts index e3e6c5f..bcc23a3 100644 --- a/src/__tests__/log-format-args.test.ts +++ b/src/__tests__/log-format-args.test.ts @@ -64,4 +64,19 @@ describe("format args are sanitized like the log object", () => { expect(String(records[0]!.msg)).toContain('"code":"23505"'); expect(String(records[0]!.msg)).toContain('"message":"duplicate key for [redacted]"'); }); + + // Format args are the sanitized, inert values: no Date or function hook of the caller's runs + // after the sanitizer, so these renderings are intended. + it.each([ + ["date %s", new Date(0), "date 1970-01-01T00:00:00.000Z"], + ["date %d", new Date(0), "date NaN"], + ["re %s", /x/, "re [object Object]"], + ["map %s", new Map([["a", 1]]), "map [object Object]"], + ["fn %s", function named() {}, "fn undefined"], + ["fn %o", function named() {}, "fn %o"], + ])("renders %s as the sanitized value", (format, value, expected) => { + const { logger, records } = createTestLogger(); + (logger.info as (...args: unknown[]) => void).call(logger, format, value); + expect(records[0]!.msg).toBe(expected); + }); }); diff --git a/src/__tests__/log-redact.test.ts b/src/__tests__/log-redact.test.ts index 66290f4..313d26c 100644 --- a/src/__tests__/log-redact.test.ts +++ b/src/__tests__/log-redact.test.ts @@ -216,4 +216,14 @@ describe("value conversion", () => { expect(JSON.stringify(records[0])).not.toContain(VALUE); expect(records[0]).toMatchObject({ a: { name: "n" } }); }); + + // WHY: a Prisma Decimal logs as the string its toJSON returns; depth must not cost it its value. + it("keeps a primitive toJSON result at depth 9", () => { + const decimal = { toJSON: () => "12.5000" }; + let nested: Record = { amount: decimal }; + for (let i = 0; i < 7; i++) nested = { d: nested }; + const { logger, records } = createTestLogger(); + logger.info({ d: nested }, "m"); + expect(JSON.stringify(records[0])).toContain('"amount":"12.5000"'); + }); }); diff --git a/src/__tests__/options.test.ts b/src/__tests__/options.test.ts index 22b2156..b08b290 100644 --- a/src/__tests__/options.test.ts +++ b/src/__tests__/options.test.ts @@ -1,6 +1,8 @@ import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; import { InstrumentationBase } from "@opentelemetry/instrumentation"; +import { log as telemetryLog } from "../log-safely.js"; +import { scrubAttributes } from "../redact.js"; import { installFatalHandlers } from "../fatal.js"; import { buildInstrumentations } from "../instrumentations.js"; import { isSensitiveKey, resetSensitiveKeys } from "../log-redact.js"; @@ -95,3 +97,56 @@ describe("onError", () => { expect(() => crash()).not.toThrow(); }); }); + +describe("a second startTelemetry call", () => { + // WHY: stdout redaction and error reporting must not differ between a local run (no endpoint) + // and a deployed one: the first call's options win in both. + it("keeps the first call's options, and warns, with export off", async () => { + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + await startTelemetry({ serviceName: "api", env: {}, redactKeys: ["unitLabel"] }); + await startTelemetry({ serviceName: "api", env: {}, redactKeys: ["roomCode"] }); + expect(isSensitiveKey("unitLabel")).toBe(true); + expect(isSensitiveKey("roomCode")).toBe(false); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ event: "telemetry_restart_ignored" }), + "telemetry_restart_ignored", + ); + }); + + it("does not warn when the same options are passed again", async () => { + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + const options = { serviceName: "api", env: {}, redactKeys: ["unitLabel"] }; + await startTelemetry(options); + await startTelemetry({ ...options, redactKeys: ["unitLabel"] }); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +describe("startTelemetry with bad options", () => { + // WHY: an untyped caller can pass anything; telemetry must never break the app's boot. + it("resolves and logs telemetry_start_failed for a non-string redact key", async () => { + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + await expect( + startTelemetry({ serviceName: "api", env: {}, redactKeys: [42 as unknown as string] }), + ).resolves.toMatchObject({ enabled: false }); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ event: "telemetry_start_failed" }), + "telemetry_start_failed", + ); + }); +}); + +describe("redactKeys on spans", () => { + // WHY: an app's PII key must not leave on a span attribute either. The agent's layer-2 scrub + // does not know app keys, so layer 1 is the only place they are redacted. + it("redacts a span attribute named by an app key, whole or as its last segment", async () => { + await startTelemetry({ serviceName: "api", env: {}, redactKeys: ["unitLabel"] }); + expect( + scrubAttributes({ unitLabel: "4B", "acp.unit_label": "x", "acp.UNITLABEL": "4C", queue: "q" }), + ).toEqual({ unitLabel: "[redacted]", "acp.unit_label": "x", "acp.UNITLABEL": "[redacted]", queue: "q" }); + }); + + it("leaves span attributes alone when the app sets no keys", () => { + expect(scrubAttributes({ unitLabel: "4B" })).toEqual({ unitLabel: "4B" }); + }); +}); diff --git a/src/__tests__/serialization-hooks.test.ts b/src/__tests__/serialization-hooks.test.ts index 98fb2f0..05924df 100644 --- a/src/__tests__/serialization-hooks.test.ts +++ b/src/__tests__/serialization-hooks.test.ts @@ -102,6 +102,7 @@ describe("serialization hooks", () => { if (typeof placed === "object" && placed !== null) logger.error(placed, "as the object"); expect(records.length).toBeGreaterThanOrEqual(2); expect(JSON.stringify(records)).not.toMatch(LEAK); + expect(JSON.stringify(records)).not.toContain("log_redaction_failed"); }); it.each(CASES)("console capture: %s, %s", async (_hook, _position, value) => { @@ -110,6 +111,9 @@ describe("serialization hooks", () => { const records = await flushLogs(); expect(records).toHaveLength(2); expect(JSON.stringify(records.map((r) => [r.body, r.attributes]))).not.toMatch(LEAK); + expect(JSON.stringify(records.map((r) => [r.body, r.attributes]))).not.toContain( + "log_redaction_failed", + ); }); // An error is serialized deny-by-default; its message and Prisma's meta target are the fields @@ -126,6 +130,7 @@ describe("serialization hooks", () => { logger.error(withMessage); logger.error({ err: prisma }, "prisma"); expect(JSON.stringify(records)).not.toMatch(LEAK); + expect(JSON.stringify(records)).not.toContain("log_redaction_failed"); }); it.each(HOOKS)("console capture: %s as an error's message", async (_hook, make) => { @@ -134,5 +139,8 @@ describe("serialization hooks", () => { const records = await flushLogs(); expect(records).toHaveLength(2); expect(JSON.stringify(records.map((r) => [r.body, r.attributes]))).not.toMatch(LEAK); + expect(JSON.stringify(records.map((r) => [r.body, r.attributes]))).not.toContain( + "log_redaction_failed", + ); }); }); diff --git a/src/app-keys.ts b/src/app-keys.ts new file mode 100644 index 0000000..3d3a4d0 --- /dev/null +++ b/src/app-keys.ts @@ -0,0 +1,27 @@ +/** App-supplied key names, lower-cased; redacted in log fields and span attributes. */ +const appKeys = new Set(); + +/** Adds key names (matched whole, any letter case); throws on a key that is not a string. */ +export function addAppKeys(keys: Iterable): void { + const names = [...keys].map((key) => { + if (typeof key !== "string") throw new TypeError("redactKeys must hold strings"); + return key.trim().toLowerCase(); + }); + for (const name of names) if (name) appKeys.add(name); +} + +/** Test seam: forgets the app-supplied keys. */ +export function resetAppKeys(): void { + appKeys.clear(); +} + +/** True when `key` is an app-supplied key name, in any letter case. */ +export function isAppKey(key: string): boolean { + return appKeys.size > 0 && appKeys.has(key.toLowerCase()); +} + +/** A span attribute is an app key when its whole name or its last `.` segment is one. */ +export function isAppAttributeKey(key: string): boolean { + if (appKeys.size === 0) return false; + return isAppKey(key) || isAppKey(key.slice(key.lastIndexOf(".") + 1)); +} diff --git a/src/console-capture.ts b/src/console-capture.ts index f82830c..545b73f 100644 --- a/src/console-capture.ts +++ b/src/console-capture.ts @@ -1,6 +1,7 @@ import { formatWithOptions } from "node:util"; import type { Logger } from "pino"; import { createLogger } from "./logger.js"; +import { safeErrorMessage } from "./log-errors.js"; import { sanitizeLogValue } from "./log-redact.js"; import { scrubErrorText } from "./log-scrub.js"; import { hasLogLineSink } from "./log-sink.js"; @@ -45,19 +46,18 @@ export function releaseConsole(): void { } /** - * The first Error goes under `err`. Every other argument is reduced to what the logger would - * export for it, then the arguments are formatted, and the rendered text is scrubbed last: a value - * split across arguments only takes its final shape once formatted. + * The first Error goes under `err`, and its safe message takes its place in the text, so a `%s` + * still reads as on the console. Every other argument is reduced to what the logger would export + * for it, then the arguments are formatted, and the rendered text is scrubbed last: a value split + * across arguments only takes its final shape once formatted. */ function exportLine(logger: Logger, level: Level, args: unknown[]): void { if (!hasLogLineSink()) return; try { const err = args.find((arg) => arg instanceof Error); - const rest = args.filter((arg) => arg !== err).map(asLogged); - const message = - rest.length > 0 ? scrubErrorText(formatWithOptions({ colors: false }, ...rest)) : undefined; - if (err === undefined) logger[level](message ?? ""); - else if (message === undefined) logger[level]({ err }); + const parts = args.map((arg) => (arg === err ? safeErrorMessage(err) : asLogged(arg))); + const message = scrubErrorText(formatWithOptions({ colors: false }, ...parts)); + if (err === undefined) logger[level](message); else logger[level]({ err }, message); } catch { // the console line is already written diff --git a/src/log-redact.ts b/src/log-redact.ts index 6c4d945..8da2f61 100644 --- a/src/log-redact.ts +++ b/src/log-redact.ts @@ -2,6 +2,7 @@ import { serializeError } from "./log-serializer.js"; import { scrubMessage } from "./log-scrub.js"; import { stripQuery } from "./redact.js"; import { describePatterns } from "./scrub-descriptor.js"; +import { isAppKey } from "./app-keys.js"; // Matches PII and credential key names (camelCase, snake_case, kebab-case) at any depth. // `ssn`, `dob` and `ein` are anchored so words such as "businessName", "adobe" or "protein" @@ -28,24 +29,10 @@ export const LOG_REDACT_PATTERNS = describePatterns({ RECORD_ID, }); -/** App-supplied key names, lower-cased; redacted on top of the patterns above. */ -const extraKeys = new Set(); - -/** Adds key names (matched whole, any letter case) to the redacted set; a default stays. */ -export function addSensitiveKeys(keys: Iterable): void { - for (const key of keys) { - const name = key.trim().toLowerCase(); - if (name) extraKeys.add(name); - } -} - -/** Test seam: forgets the app-supplied keys. */ -export function resetSensitiveKeys(): void { - extraKeys.clear(); -} +export { addAppKeys as addSensitiveKeys, resetAppKeys as resetSensitiveKeys } from "./app-keys.js"; export function isSensitiveKey(key: string): boolean { - if (extraKeys.size > 0 && extraKeys.has(key.toLowerCase())) return true; + if (isAppKey(key)) return true; if (PERSON_ID.test(key)) return true; if (RECORD_ID.test(key)) return false; return SENSITIVE_KEY_PATTERN.test(key) || CAMEL_EIN.test(key); @@ -178,7 +165,11 @@ function walk(value: unknown, depth: number, state: WalkState): unknown { if (converted.done) return converted.value; const target = converted.value; // What a toJSON returns is walked like any value: a returned date, map or toJSON is reduced too. - if (target !== value) return depth > MAX_DEPTH ? TRUNCATED : walk(target, depth + 1, state); + // A primitive result (a Decimal's string) costs no depth. + if (target !== value) { + if (typeof target !== "object" || target === null) return walk(target, depth, state); + return depth > MAX_DEPTH ? TRUNCATED : walk(target, depth + 1, state); + } if (state.ancestors.has(target)) return "[circular]"; if (depth > MAX_DEPTH) return TRUNCATED; state.ancestors.add(target); diff --git a/src/options.ts b/src/options.ts index 2bc085f..0638040 100644 --- a/src/options.ts +++ b/src/options.ts @@ -57,6 +57,28 @@ export function reportError(err: unknown, context: ErrorHookContext = {}): void } } +/** Equal arrays: the same items, by identity. */ +function sameItems(a: unknown, b: unknown): boolean { + if (!Array.isArray(a) || !Array.isArray(b)) return a === b; + return a.length === b.length && a.every((item, i) => item === b[i]); +} + +/** True when two calls pass the same options; `env` aside, since it only says whether to export. */ +export function sameOptions(a: TelemetryOptions, b: TelemetryOptions): boolean { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + keys.delete("env"); + for (const key of keys) { + const x = a[key as keyof TelemetryOptions]; + const y = b[key as keyof TelemetryOptions]; + if (key === "metricViews") { + const [m, n] = [x as MetricViewOptions | undefined, y as MetricViewOptions | undefined]; + if (m?.instrumentPrefix !== n?.instrumentPrefix) return false; + if (!sameItems(m?.allowedAttributes, n?.allowedAttributes)) return false; + } else if (!sameItems(x, y)) return false; + } + return true; +} + /** Test seam: forgets the recorded options. */ export function resetOptions(): void { scope = undefined; diff --git a/src/redact.ts b/src/redact.ts index fddaaf6..7c6ac9b 100644 --- a/src/redact.ts +++ b/src/redact.ts @@ -1,4 +1,5 @@ import type { Attributes } from "@opentelemetry/api"; +import { isAppAttributeKey } from "./app-keys.js"; import { scrubMessage } from "./log-scrub.js"; import { describeKeySets, describePatterns } from "./scrub-descriptor.js"; import { stripUserinfo, URL_AUTHORITY } from "./url-credentials.js"; @@ -100,7 +101,8 @@ function scrubExceptionStacktrace(stacktrace: string, exceptionType?: string): s * Scrubs one attribute set. Every URL attribute loses its query, fragment and userinfo, and its * path becomes the span's `http.route` template when it has one, so a path parameter never * leaves. Without a route (client spans, unmatched requests) `url.path` is dropped and known - * token-bearing paths are templated in the remaining URL attributes. + * token-bearing paths are templated in the remaining URL attributes. An attribute named by the + * app's `redactKeys` (its whole name or last `.` segment) is redacted. */ export function scrubAttributes( attributes: Attributes, @@ -115,7 +117,7 @@ export function scrubAttributes( if (redis && STATEMENT_KEYS.includes(key)) continue; if (key === "url.path" && !route) continue; - if (REDACTED_KEYS.has(key)) { + if (REDACTED_KEYS.has(key) || isAppAttributeKey(key)) { scrubbed[key] = "[redacted]"; } else if (key === "exception.stacktrace") { if (typeof value === "string") { diff --git a/src/sdk.ts b/src/sdk.ts index 6352522..8de39f4 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -1,13 +1,14 @@ import { diag, DiagLogLevel } from "@opentelemetry/api"; -import { resolveTelemetryConfig, type TelemetryConfig } from "./config.js"; +import { resolveTelemetryConfig, type TelemetryConfig, type TelemetryEnv } from "./config.js"; import type { BootedSdk } from "./sdk-boot.js"; import { diagLogger, logSafely } from "./log-safely.js"; import { addSensitiveKeys } from "./log-redact.js"; -import { applyOptions, logScope, type TelemetryOptions } from "./options.js"; +import { applyOptions, logScope, sameOptions, type TelemetryOptions } from "./options.js"; import { startProfiling, stopProfiling } from "./profiling.js"; let sdk: BootedSdk | undefined; let started: TelemetryConfig | undefined; +let applied: TelemetryOptions | undefined; /** * Boots the OpenTelemetry Node SDK. Never rejects: a failed start is logged and the config @@ -16,14 +17,24 @@ let started: TelemetryConfig | undefined; * registered here, so this module must run under `node --import` before the app loads `http`. * The SDK itself is imported only once telemetry is enabled. A host that runs bundled CommonJS * (Next.js) passes `esmLoaderHook: false`. The log scope, `redactKeys` and `onError` apply even - * when export is off, since they shape stdout and error reporting too. + * when export is off, since they shape stdout and error reporting too. The first call's options + * win whether or not it exported; a later call may only turn export on through its `env`, and + * warns `telemetry_restart_ignored` when its options differ. */ export async function startTelemetry(options: TelemetryOptions): Promise { - if (started) return started; - applyOptions(options); - if (options.redactKeys) addSensitiveKeys(options.redactKeys); - const env = options.env ?? process.env; - const config = resolveTelemetryConfig(options.serviceName, env); + let effective: TelemetryOptions; + let env: TelemetryEnv; + let config: TelemetryConfig; + try { + effective = resolveOptions(options); + if (started) return started; + env = effective.env ?? process.env; + config = resolveTelemetryConfig(effective.serviceName, env); + } catch (error) { + logSafely("warn", { err: error, event: "telemetry_start_failed" }, "telemetry_start_failed"); + const name = typeof options?.serviceName === "string" ? options.serviceName : "unknown"; + return { ...resolveTelemetryConfig(name, {}), enabled: false }; + } if (!config.enabled) { await startProfiling(config, env); return config; @@ -34,11 +45,11 @@ export async function startTelemetry(options: TelemetryOptions): Promise { started = undefined; + applied = undefined; const running = sdk; sdk = undefined; const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs).unref()); From 95a03686bc85bbff84bde120c2d4813bdb1d2f20 Mon Sep 17 00:00:00 2001 From: Victor Carvalho Date: Mon, 12 Oct 2026 23:05:20 -0300 Subject: [PATCH 10/12] docs: README, CI concurrency and linguist marking for dist/ The README lists every exported type, says how an app checks an instrumentation it passes in, and states that layer 2 does not repeat redactKeys. CI cancels superseded runs on pull requests only, so every main commit gets a verdict. dist/ is marked generated. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 1 + .github/workflows/ci.yml | 3 ++- README.md | 23 +++++++++++++++++------ 3 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e62788b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +dist/** linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a16502..4eab6a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,8 @@ permissions: concurrency: group: ci-${{ github.ref }} - cancel-in-progress: true + # A main commit can be pinned by SHA, so its run always finishes; only PR runs are superseded. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: ci: diff --git a/README.md b/README.md index 6fe3eea..1ee402f 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,10 @@ An app with no options to pass can use the register entry instead. It reads OTEL_SERVICE_NAME=acp-worker node --import @codelittinc/otel-node/register dist/worker.js ``` -`startTelemetry` never throws: a failed start logs `telemetry_start_failed` and the app runs on. +`startTelemetry` never throws: a failed start, or options it cannot read, logs +`telemetry_start_failed` and the app runs on. Telemetry starts once per process: the first call's +options win whether or not it exports, and a later call with different options is ignored with a +`telemetry_restart_ignored` warning. `shutdownTelemetry(timeoutMs = 5000)` flushes spans, metrics, log records and profiles, bounded so a dead collector cannot stall a shutdown. @@ -61,7 +64,7 @@ so a dead collector cannot stall a shutdown. | `logScope` | `serviceName` | The OTel logger scope of exported log records. Only records from this scope are exported. Also names the BullMQ tracer and the `.request_id` span attribute. | | `metricViews` | none | `{ instrumentPrefix, allowedAttributes }`: instruments named `*` export only those attributes. Without it, every attribute is kept. | | `instrumentations` | `[]` | Appended to the package's list (http, undici, ioredis, pg, runtime-node). Pass Prisma here, at the app's own version. | -| `redactKeys` | `[]` | Log field names (whole name, any case) redacted on top of the default PII key list. It can only add; no default is ever removed. | +| `redactKeys` | `[]` | Key names (whole name, any case) redacted on top of the default PII key list: log fields, and span attributes by whole name or last `.` segment. It can only add; no default is ever removed. The agent's layer-2 scrub does not repeat them. | | `onError` | none | `(err, { service })`: receives each fatal error `installFatalHandlers` sees when it is given no `report`. Wire your error tracker here. | | `ignoredPaths` | `[]` | Incoming paths that open no server span; each covers itself and everything below it. | | `esmLoaderHook` | `true` | `false` for a host that runs bundled CommonJS (Next.js). | @@ -78,7 +81,7 @@ Environment: `OTEL_EXPORTER_OTLP_ENDPOINT` (on/off), `OTEL_SDK_DISABLED=true` (k | Import | What | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@codelittinc/otel-node` | `startTelemetry`, `shutdownTelemetry`, `isTelemetryStarted`, `setHttpRoute`, `getMeter`, `log`, `configureLogger`, `flushLog`, `captureConsole`, `installFatalHandlers`, `boundedLabel`, `currentTraceparent`, `withLinkedSpan`, `withRootSpan`, and the `TelemetryOptions`, `Meter` and `BatchObservableResult` types | +| `@codelittinc/otel-node` | `startTelemetry`, `shutdownTelemetry`, `isTelemetryStarted`, `setHttpRoute`, `getMeter`, `log`, `configureLogger`, `flushLog`, `captureConsole`, `installFatalHandlers`, `boundedLabel`, `currentTraceparent`, `withLinkedSpan`, `withRootSpan`, and the `TelemetryOptions`, `TelemetryConfig`, `TelemetryEnv`, `MetricViewOptions`, `ErrorHookContext`, `Meter` and `BatchObservableResult` types | | `@codelittinc/otel-node/bullmq` | `bullmqTelemetry()`: the adapter for a BullMQ Queue or Worker `telemetry` option | | `@codelittinc/otel-node/register` | `node --import` entry, no options | | `@codelittinc/otel-node/testing` | `createTestLogger()`: the real logger, capturing parsed records in memory | @@ -112,9 +115,12 @@ Personal data is removed twice before it is stored. plus `redactKeys`), serializes errors deny-by-default, and scrubs SSN shapes, emails and URL userinfo and queries from every string. The span exporter drops request and response bodies and headers, bound SQL values and Redis statements, redacts exception messages, strips URL - queries and userinfo, and replaces a URL path with its route template. + queries and userinfo, replaces a URL path with its route template, and redacts any attribute + named by `redactKeys`. 2. **Layer 2, in the agent.** The Alloy agent in `codelittinc/carbon-observability` repeats the - pattern scrub on every span, log and metric before it leaves the host. + pattern scrub on every span, log and metric before it leaves the host. It repeats only this + package's defaults (the contract below), never an app's `redactKeys`: for those, layer 1 is + the only scrub. `src/scrub-contract.json` pins every layer-1 pattern and key set the agent repeats. This repo is its source; `codelittinc/carbon-observability` follows with a copy at @@ -131,7 +137,12 @@ its source; `codelittinc/carbon-observability` follows with a copy at labels in `allowedAttributes`; a label missing there is dropped. `boundedLabel` collapses a value outside an allowlist to `"other"`. 3. **Money is never a metric value.** Count events instead. -4. **A new instrumentation needs a scrub test** in `redacting-exporter.test.ts` before it merges. +4. **Every instrumentation is checked against the span scrub.** One added to this package needs a + scrub test in `redacting-exporter.test.ts` before it merges. An app that passes its own + through `instrumentations` (Prisma, for example) checks the attributes it emits: an attribute + key that carries values the scrub does not already drop or redact needs a PR here, adding the + key to the scrub with a test, before the app ships it. `redactKeys` covers a key in the + meantime, in layer 1 only. 5. **Only the scrubbed log line is exported.** Records are exported only from the log scope, built from the shared logger's already scrubbed output; OTel's own diagnostics (`event: "otel_diag"`) stay on stdout. Never add a logger instrumentation or a log record From b6f61234223fd7a08b7049a716dcb35941bfa140 Mon Sep 17 00:00:00 2001 From: Victor Carvalho Date: Mon, 12 Oct 2026 23:10:05 -0300 Subject: [PATCH 11/12] fix: check options before applying any; keep OTel attributes from app keys startTelemetry validates and copies its options before it changes any state, then applies the log scope, error hook and redact keys in one step, so a failed call leaves nothing half-applied. A redact key that is not a string is dropped with one invalid_redact_key warning naming only its type; the valid keys still apply. Unreadable options on a later call return the running config. An app key matches a span attribute's last segment only outside OpenTelemetry's namespaces, so `name` never redacts db.system.name. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- dist/app-keys.d.ts | 5 +- dist/app-keys.js | 48 ++++++++++++++++++- dist/log-redact.d.ts | 2 +- dist/log-redact.js | 2 +- dist/options.d.ts | 15 +++++- dist/options.js | 33 ++++++++++++- dist/sdk.js | 13 +++-- src/__tests__/options.test.ts | 73 +++++++++++++++++++++++++++-- src/__tests__/sdk-lifecycle.test.ts | 8 ++++ src/app-keys.ts | 47 ++++++++++++++++++- src/log-redact.ts | 2 +- src/options.ts | 39 ++++++++++++++- src/sdk.ts | 17 +++++-- 14 files changed, 281 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 1ee402f..c95cf82 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ so a dead collector cannot stall a shutdown. | `logScope` | `serviceName` | The OTel logger scope of exported log records. Only records from this scope are exported. Also names the BullMQ tracer and the `.request_id` span attribute. | | `metricViews` | none | `{ instrumentPrefix, allowedAttributes }`: instruments named `*` export only those attributes. Without it, every attribute is kept. | | `instrumentations` | `[]` | Appended to the package's list (http, undici, ioredis, pg, runtime-node). Pass Prisma here, at the app's own version. | -| `redactKeys` | `[]` | Key names (whole name, any case) redacted on top of the default PII key list: log fields, and span attributes by whole name or last `.` segment. It can only add; no default is ever removed. The agent's layer-2 scrub does not repeat them. | +| `redactKeys` | `[]` | Key names (whole name, any case) redacted on top of the default PII key list: log fields, and span attributes by whole name, or by last `.` segment outside OpenTelemetry's namespaces (so `name` redacts `acp.tenant.name` but never `db.system.name`). It can only add; no default is ever removed. The agent's layer-2 scrub does not repeat them. | | `onError` | none | `(err, { service })`: receives each fatal error `installFatalHandlers` sees when it is given no `report`. Wire your error tracker here. | | `ignoredPaths` | `[]` | Incoming paths that open no server span; each covers itself and everything below it. | | `esmLoaderHook` | `true` | `false` for a host that runs bundled CommonJS (Next.js). | diff --git a/dist/app-keys.d.ts b/dist/app-keys.d.ts index 5427452..058b4f3 100644 --- a/dist/app-keys.d.ts +++ b/dist/app-keys.d.ts @@ -4,5 +4,8 @@ export declare function addAppKeys(keys: Iterable): void; export declare function resetAppKeys(): void; /** True when `key` is an app-supplied key name, in any letter case. */ export declare function isAppKey(key: string): boolean; -/** A span attribute is an app key when its whole name or its last `.` segment is one. */ +/** + * A span attribute is an app key when its whole name is one, or, outside the OpenTelemetry + * namespaces, when its last `.` segment is one (`acp.tenant.name`). + */ export declare function isAppAttributeKey(key: string): boolean; diff --git a/dist/app-keys.js b/dist/app-keys.js index 4e4d58e..b20b613 100644 --- a/dist/app-keys.js +++ b/dist/app-keys.js @@ -19,9 +19,53 @@ export function resetAppKeys() { export function isAppKey(key) { return appKeys.size > 0 && appKeys.has(key.toLowerCase()); } -/** A span attribute is an app key when its whole name or its last `.` segment is one. */ +/** + * OpenTelemetry semantic-convention namespaces, and those of the instrumentations this package and + * its apps load. Their attributes are matched by whole name only, so a generic app key (`name`, + * `id`) never wipes `db.system.name` or `bullmq.job.id`. + */ +const OTEL_NAMESPACES = new Set([ + "bullmq", + "client", + "code", + "db", + "deployment", + "enduser", + "error", + "exception", + "host", + "http", + "messaging", + "net", + "network", + "next", + "nodejs", + "otel", + "peer", + "pg", + "prisma", + "process", + "rpc", + "server", + "service", + "telemetry", + "thread", + "url", + "user", + "user_agent", + "v8js", +]); +/** + * A span attribute is an app key when its whole name is one, or, outside the OpenTelemetry + * namespaces, when its last `.` segment is one (`acp.tenant.name`). + */ export function isAppAttributeKey(key) { if (appKeys.size === 0) return false; - return isAppKey(key) || isAppKey(key.slice(key.lastIndexOf(".") + 1)); + if (isAppKey(key)) + return true; + const dot = key.indexOf("."); + if (dot === -1 || OTEL_NAMESPACES.has(key.slice(0, dot).toLowerCase())) + return false; + return isAppKey(key.slice(key.lastIndexOf(".") + 1)); } diff --git a/dist/log-redact.d.ts b/dist/log-redact.d.ts index 97b2a64..c907146 100644 --- a/dist/log-redact.d.ts +++ b/dist/log-redact.d.ts @@ -1,6 +1,6 @@ /** Pinned by scrub-contract.test.ts: the agent's layer-2 log scrub repeats these patterns. */ export declare const LOG_REDACT_PATTERNS: Readonly>; -export { addAppKeys as addSensitiveKeys, resetAppKeys as resetSensitiveKeys } from "./app-keys.js"; +export { resetAppKeys as resetSensitiveKeys } from "./app-keys.js"; export declare function isSensitiveKey(key: string): boolean; /** * The JSON-facing form of an object: URL, byte view, date and toJSON values converted; `done` diff --git a/dist/log-redact.js b/dist/log-redact.js index bad169b..664655e 100644 --- a/dist/log-redact.js +++ b/dist/log-redact.js @@ -25,7 +25,7 @@ export const LOG_REDACT_PATTERNS = describePatterns({ PERSON_ID, RECORD_ID, }); -export { addAppKeys as addSensitiveKeys, resetAppKeys as resetSensitiveKeys } from "./app-keys.js"; +export { resetAppKeys as resetSensitiveKeys } from "./app-keys.js"; export function isSensitiveKey(key) { if (isAppKey(key)) return true; diff --git a/dist/options.d.ts b/dist/options.d.ts index 984e9e7..b00a3bc 100644 --- a/dist/options.d.ts +++ b/dist/options.d.ts @@ -28,7 +28,20 @@ export interface TelemetryOptions { /** False keeps log lines on stdout only; a host whose own code does not use the shared logger. */ logs?: boolean; } -/** Records the options that outlive `startTelemetry`: the log scope and the error hook. */ +/** A checked copy of the caller's options, and the types of the redact keys it dropped. */ +export interface CheckedOptions { + options: TelemetryOptions; + invalidKeyTypes: string[]; +} +/** + * Validates and copies options without touching any state. Throws on options that cannot be read; + * a redact key that is not a string is dropped and only its type is reported. + */ +export declare function checkOptions(input: TelemetryOptions): CheckedOptions; +/** + * Applies checked options in one step: the log scope, the error hook and the redact keys. Nothing + * here can throw, so a call never leaves its options half-applied. + */ export declare function applyOptions(options: TelemetryOptions): void; /** The configured log scope; before `startTelemetry`, the service name from the environment. */ export declare function logScope(): string; diff --git a/dist/options.js b/dist/options.js index 8b0912c..b717a54 100644 --- a/dist/options.js +++ b/dist/options.js @@ -1,11 +1,42 @@ +import { addAppKeys } from "./app-keys.js"; let scope; let service; let errorHook; -/** Records the options that outlive `startTelemetry`: the log scope and the error hook. */ +function check(ok, message) { + if (!ok) + throw new TypeError(message); +} +/** + * Validates and copies options without touching any state. Throws on options that cannot be read; + * a redact key that is not a string is dropped and only its type is reported. + */ +export function checkOptions(input) { + check(typeof input === "object" && input !== null, "options must be an object"); + const options = { ...input }; + check(typeof options.serviceName === "string", "serviceName must be a string"); + check(options.logScope === undefined || typeof options.logScope === "string", "bad logScope"); + check(options.onError === undefined || typeof options.onError === "function", "bad onError"); + check(options.env === undefined || typeof options.env === "object", "bad env"); + check(options.redactKeys === undefined || Array.isArray(options.redactKeys), "bad redactKeys"); + const keys = [...(options.redactKeys ?? [])]; + const invalidKeyTypes = keys + .filter((key) => typeof key !== "string") + .map((key) => (key === null ? "null" : typeof key)); + if (options.redactKeys) { + options.redactKeys = keys.filter((key) => typeof key === "string"); + } + return { options, invalidKeyTypes }; +} +/** + * Applies checked options in one step: the log scope, the error hook and the redact keys. Nothing + * here can throw, so a call never leaves its options half-applied. + */ export function applyOptions(options) { scope = options.logScope?.trim() || options.serviceName; service = options.serviceName; errorHook = options.onError; + if (options.redactKeys) + addAppKeys(options.redactKeys); } /** The configured log scope; before `startTelemetry`, the service name from the environment. */ export function logScope() { diff --git a/dist/sdk.js b/dist/sdk.js index 0e45546..b9fe33f 100644 --- a/dist/sdk.js +++ b/dist/sdk.js @@ -1,8 +1,7 @@ import { diag, DiagLogLevel } from "@opentelemetry/api"; import { resolveTelemetryConfig } from "./config.js"; import { diagLogger, logSafely } from "./log-safely.js"; -import { addSensitiveKeys } from "./log-redact.js"; -import { applyOptions, logScope, sameOptions } from "./options.js"; +import { applyOptions, checkOptions, logScope, sameOptions, } from "./options.js"; import { startProfiling, stopProfiling } from "./profiling.js"; let sdk; let started; @@ -31,6 +30,8 @@ export async function startTelemetry(options) { } catch (error) { logSafely("warn", { err: error, event: "telemetry_start_failed" }, "telemetry_start_failed"); + if (started) + return started; const name = typeof options?.serviceName === "string" ? options.serviceName : "unknown"; return { ...resolveTelemetryConfig(name, {}), enabled: false }; } @@ -64,16 +65,18 @@ export async function startTelemetry(options) { return config; } /** The first call's options, applied once; a later call keeps them and only brings its `env`. */ -function resolveOptions(options) { +function resolveOptions(input) { + const { options, invalidKeyTypes } = checkOptions(input); if (applied) { if (!sameOptions(applied, options)) { logSafely("warn", { event: "telemetry_restart_ignored" }, "telemetry_restart_ignored"); } return { ...applied, env: options.env }; } + if (invalidKeyTypes.length > 0) { + logSafely("warn", { event: "invalid_redact_key", types: invalidKeyTypes }, "invalid_redact_key"); + } applyOptions(options); - if (options.redactKeys) - addSensitiveKeys(options.redactKeys); applied = options; return options; } diff --git a/src/__tests__/options.test.ts b/src/__tests__/options.test.ts index b08b290..51fc310 100644 --- a/src/__tests__/options.test.ts +++ b/src/__tests__/options.test.ts @@ -6,7 +6,7 @@ import { scrubAttributes } from "../redact.js"; import { installFatalHandlers } from "../fatal.js"; import { buildInstrumentations } from "../instrumentations.js"; import { isSensitiveKey, resetSensitiveKeys } from "../log-redact.js"; -import { resetOptions } from "../options.js"; +import { logScope, resetOptions } from "../options.js"; import { shutdownTelemetry, startTelemetry } from "../sdk.js"; import { log } from "../shared-log.js"; import { createTestLogger } from "../testing.js"; @@ -123,16 +123,57 @@ describe("a second startTelemetry call", () => { }); describe("startTelemetry with bad options", () => { - // WHY: an untyped caller can pass anything; telemetry must never break the app's boot. - it("resolves and logs telemetry_start_failed for a non-string redact key", async () => { + // WHY: an untyped caller can pass anything; telemetry must never break the app's boot, and one + // bad key must not cost the app every good one. + it("keeps the valid redact keys and warns with only a bad entry's type", async () => { const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); await expect( - startTelemetry({ serviceName: "api", env: {}, redactKeys: [42 as unknown as string] }), + startTelemetry({ + serviceName: "api", + env: {}, + redactKeys: ["unitLabel", 4111111111111111, { secret: "hunter2" }] as unknown as string[], + }), + ).resolves.toMatchObject({ enabled: false }); + expect(isSensitiveKey("unitLabel")).toBe(true); + const calls = warn.mock.calls.filter( + ([fields]) => (fields as { event?: string }).event === "invalid_redact_key", + ); + expect(calls).toEqual([ + [{ event: "invalid_redact_key", types: ["number", "object"] }, "invalid_redact_key"], + ]); + expect(JSON.stringify(warn.mock.calls)).not.toMatch(/hunter2|4111111111111111/); + }); + + it("leaves no option applied when a call cannot be read, and a later call applies in full", async () => { + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + await expect( + startTelemetry({ + serviceName: "api", + env: {}, + logScope: "half", + redactKeys: ["roomCode"], + onError: 5 as unknown as () => void, + }), ).resolves.toMatchObject({ enabled: false }); expect(warn).toHaveBeenCalledWith( expect.objectContaining({ event: "telemetry_start_failed" }), "telemetry_start_failed", ); + expect(logScope()).not.toBe("half"); + expect(isSensitiveKey("roomCode")).toBe(false); + + await startTelemetry({ serviceName: "api", env: {}, logScope: "acp", redactKeys: ["unitLabel"] }); + expect(logScope()).toBe("acp"); + expect(isSensitiveKey("unitLabel")).toBe(true); + expect(warn).not.toHaveBeenCalledWith( + expect.objectContaining({ event: "telemetry_restart_ignored" }), + expect.anything(), + ); + }); + + it("resolves for options that are not an object", async () => { + vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + await expect(startTelemetry(null as never)).resolves.toMatchObject({ enabled: false }); }); }); @@ -146,6 +187,30 @@ describe("redactKeys on spans", () => { ).toEqual({ unitLabel: "[redacted]", "acp.unit_label": "x", "acp.UNITLABEL": "[redacted]", queue: "q" }); }); + // WHY: a generic app key (a bare `name` for a person's name) must not wipe the OpenTelemetry + // attributes that traces are searched and grouped by. + it("matches a last segment only outside OpenTelemetry's namespaces", async () => { + await startTelemetry({ + serviceName: "api", + env: {}, + redactKeys: ["name", "route", "method", "id", "type"], + }); + const kept = { + "db.system.name": "postgresql", + "http.route": "/v1/tenants/:id", + "http.request.method": "GET", + "messaging.destination.name": "late-fees", + "bullmq.job.id": "42", + "error.type": "TimeoutError", + "exception.type": "Error", + }; + expect(scrubAttributes({ ...kept, "acp.tenant.name": "Jane", name: "Jane" })).toEqual({ + ...kept, + "acp.tenant.name": "[redacted]", + name: "[redacted]", + }); + }); + it("leaves span attributes alone when the app sets no keys", () => { expect(scrubAttributes({ unitLabel: "4B" })).toEqual({ unitLabel: "4B" }); }); diff --git a/src/__tests__/sdk-lifecycle.test.ts b/src/__tests__/sdk-lifecycle.test.ts index 2c28782..ae2d0e9 100644 --- a/src/__tests__/sdk-lifecycle.test.ts +++ b/src/__tests__/sdk-lifecycle.test.ts @@ -264,4 +264,12 @@ describe("telemetry lifecycle", () => { state.shutdownThrows = true; await expect(shutdownTelemetry(100)).resolves.toBeUndefined(); }); + + // WHY: options that cannot be read on a later call must not report export off while it runs. + it("returns the running config for unreadable options on a later call", async () => { + const first = await startTelemetry({ serviceName: "api", env: enabled }); + const warn = vi.spyOn(telemetryLog, "warn").mockImplementation(() => undefined); + await expect(startTelemetry(null as never)).resolves.toBe(first); + warn.mockRestore(); + }); }); diff --git a/src/app-keys.ts b/src/app-keys.ts index 3d3a4d0..27ba5b4 100644 --- a/src/app-keys.ts +++ b/src/app-keys.ts @@ -20,8 +20,51 @@ export function isAppKey(key: string): boolean { return appKeys.size > 0 && appKeys.has(key.toLowerCase()); } -/** A span attribute is an app key when its whole name or its last `.` segment is one. */ +/** + * OpenTelemetry semantic-convention namespaces, and those of the instrumentations this package and + * its apps load. Their attributes are matched by whole name only, so a generic app key (`name`, + * `id`) never wipes `db.system.name` or `bullmq.job.id`. + */ +const OTEL_NAMESPACES = new Set([ + "bullmq", + "client", + "code", + "db", + "deployment", + "enduser", + "error", + "exception", + "host", + "http", + "messaging", + "net", + "network", + "next", + "nodejs", + "otel", + "peer", + "pg", + "prisma", + "process", + "rpc", + "server", + "service", + "telemetry", + "thread", + "url", + "user", + "user_agent", + "v8js", +]); + +/** + * A span attribute is an app key when its whole name is one, or, outside the OpenTelemetry + * namespaces, when its last `.` segment is one (`acp.tenant.name`). + */ export function isAppAttributeKey(key: string): boolean { if (appKeys.size === 0) return false; - return isAppKey(key) || isAppKey(key.slice(key.lastIndexOf(".") + 1)); + if (isAppKey(key)) return true; + const dot = key.indexOf("."); + if (dot === -1 || OTEL_NAMESPACES.has(key.slice(0, dot).toLowerCase())) return false; + return isAppKey(key.slice(key.lastIndexOf(".") + 1)); } diff --git a/src/log-redact.ts b/src/log-redact.ts index 8da2f61..242263f 100644 --- a/src/log-redact.ts +++ b/src/log-redact.ts @@ -29,7 +29,7 @@ export const LOG_REDACT_PATTERNS = describePatterns({ RECORD_ID, }); -export { addAppKeys as addSensitiveKeys, resetAppKeys as resetSensitiveKeys } from "./app-keys.js"; +export { resetAppKeys as resetSensitiveKeys } from "./app-keys.js"; export function isSensitiveKey(key: string): boolean { if (isAppKey(key)) return true; diff --git a/src/options.ts b/src/options.ts index 0638040..0b3c742 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,4 +1,5 @@ import type { Instrumentation } from "@opentelemetry/instrumentation"; +import { addAppKeys } from "./app-keys.js"; import type { TelemetryEnv } from "./config.js"; /** An allowlist view: instruments named `*` export only these attributes. */ @@ -36,11 +37,47 @@ let scope: string | undefined; let service: string | undefined; let errorHook: TelemetryOptions["onError"]; -/** Records the options that outlive `startTelemetry`: the log scope and the error hook. */ +/** A checked copy of the caller's options, and the types of the redact keys it dropped. */ +export interface CheckedOptions { + options: TelemetryOptions; + invalidKeyTypes: string[]; +} + +function check(ok: boolean, message: string): void { + if (!ok) throw new TypeError(message); +} + +/** + * Validates and copies options without touching any state. Throws on options that cannot be read; + * a redact key that is not a string is dropped and only its type is reported. + */ +export function checkOptions(input: TelemetryOptions): CheckedOptions { + check(typeof input === "object" && input !== null, "options must be an object"); + const options = { ...input }; + check(typeof options.serviceName === "string", "serviceName must be a string"); + check(options.logScope === undefined || typeof options.logScope === "string", "bad logScope"); + check(options.onError === undefined || typeof options.onError === "function", "bad onError"); + check(options.env === undefined || typeof options.env === "object", "bad env"); + check(options.redactKeys === undefined || Array.isArray(options.redactKeys), "bad redactKeys"); + const keys: unknown[] = [...(options.redactKeys ?? [])]; + const invalidKeyTypes = keys + .filter((key) => typeof key !== "string") + .map((key) => (key === null ? "null" : typeof key)); + if (options.redactKeys) { + options.redactKeys = keys.filter((key): key is string => typeof key === "string"); + } + return { options, invalidKeyTypes }; +} + +/** + * Applies checked options in one step: the log scope, the error hook and the redact keys. Nothing + * here can throw, so a call never leaves its options half-applied. + */ export function applyOptions(options: TelemetryOptions): void { scope = options.logScope?.trim() || options.serviceName; service = options.serviceName; errorHook = options.onError; + if (options.redactKeys) addAppKeys(options.redactKeys); } /** The configured log scope; before `startTelemetry`, the service name from the environment. */ diff --git a/src/sdk.ts b/src/sdk.ts index 8de39f4..421a19d 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -2,8 +2,13 @@ import { diag, DiagLogLevel } from "@opentelemetry/api"; import { resolveTelemetryConfig, type TelemetryConfig, type TelemetryEnv } from "./config.js"; import type { BootedSdk } from "./sdk-boot.js"; import { diagLogger, logSafely } from "./log-safely.js"; -import { addSensitiveKeys } from "./log-redact.js"; -import { applyOptions, logScope, sameOptions, type TelemetryOptions } from "./options.js"; +import { + applyOptions, + checkOptions, + logScope, + sameOptions, + type TelemetryOptions, +} from "./options.js"; import { startProfiling, stopProfiling } from "./profiling.js"; let sdk: BootedSdk | undefined; @@ -32,6 +37,7 @@ export async function startTelemetry(options: TelemetryOptions): Promise 0) { + logSafely("warn", { event: "invalid_redact_key", types: invalidKeyTypes }, "invalid_redact_key"); + } applyOptions(options); - if (options.redactKeys) addSensitiveKeys(options.redactKeys); applied = options; return options; } From f56b7d102c3b81f3e90f03cf7cb630ac1127b45e Mon Sep 17 00:00:00 2001 From: Victor Carvalho Date: Mon, 12 Oct 2026 23:15:43 -0300 Subject: [PATCH 12/12] fix: exempt exact instrumentation attributes, not whole namespaces An app key now matches a span attribute's last segment everywhere except a fixed list of attributes the instrumentations, BullMQ and Prisma emit (db.system.name, http.route, bullmq.job.id, ...), so an attribute the app sets under an OpenTelemetry namespace is still redacted. Content-bearing attributes stay off the list. Prisma's bare name, method and model are matched by whole name, as documented. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- dist/app-keys.d.ts | 13 ++++- dist/app-keys.js | 107 ++++++++++++++++++++++------------ src/__tests__/options.test.ts | 39 ++++++++++++- src/app-keys.ts | 107 ++++++++++++++++++++++------------ 5 files changed, 190 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index c95cf82..7250e17 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ so a dead collector cannot stall a shutdown. | `logScope` | `serviceName` | The OTel logger scope of exported log records. Only records from this scope are exported. Also names the BullMQ tracer and the `.request_id` span attribute. | | `metricViews` | none | `{ instrumentPrefix, allowedAttributes }`: instruments named `*` export only those attributes. Without it, every attribute is kept. | | `instrumentations` | `[]` | Appended to the package's list (http, undici, ioredis, pg, runtime-node). Pass Prisma here, at the app's own version. | -| `redactKeys` | `[]` | Key names (whole name, any case) redacted on top of the default PII key list: log fields, and span attributes by whole name, or by last `.` segment outside OpenTelemetry's namespaces (so `name` redacts `acp.tenant.name` but never `db.system.name`). It can only add; no default is ever removed. The agent's layer-2 scrub does not repeat them. | +| `redactKeys` | `[]` | Key names (whole name, any case) redacted on top of the default PII key list: log fields, and span attributes by whole name or last `.` segment. A fixed list of attributes the instrumentations emit (`db.system.name`, `http.route`, `bullmq.job.id`, ...) is matched by whole name only, so `name` never wipes them; any other attribute, under any namespace, fails closed. Prisma's bare `name`, `method` and `model` are matched by whole name, so an app key `name` also redacts Prisma's span name attribute. It can only add; no default is ever removed. The agent's layer-2 scrub does not repeat them. | | `onError` | none | `(err, { service })`: receives each fatal error `installFatalHandlers` sees when it is given no `report`. Wire your error tracker here. | | `ignoredPaths` | `[]` | Incoming paths that open no server span; each covers itself and everything below it. | | `esmLoaderHook` | `true` | `false` for a host that runs bundled CommonJS (Next.js). | diff --git a/dist/app-keys.d.ts b/dist/app-keys.d.ts index 058b4f3..e4f40b6 100644 --- a/dist/app-keys.d.ts +++ b/dist/app-keys.d.ts @@ -5,7 +5,16 @@ export declare function resetAppKeys(): void; /** True when `key` is an app-supplied key name, in any letter case. */ export declare function isAppKey(key: string): boolean; /** - * A span attribute is an app key when its whole name is one, or, outside the OpenTelemetry - * namespaces, when its last `.` segment is one (`acp.tenant.name`). + * Attributes the package's instrumentations, BullMQ and Prisma emit whose last segment is a + * plausible generic app key (`name`, `id`, `method`). Only these exact names skip last-segment + * matching, so `name` never wipes `db.system.name`, while an app's own `http.unitLabel` is still + * redacted. Content-bearing attributes (`url.full`, `db.query.text`, job options and reasons) are + * deliberately absent. Prisma's bare `name`, `method` and `model` are not exempt: a whole-name app + * key matches them. + */ +export declare const EXEMPT_ATTRIBUTES: ReadonlySet; +/** + * A span attribute is an app key when its whole name is one, or its last `.` segment is one, + * unless it is an exempt instrumentation attribute. */ export declare function isAppAttributeKey(key: string): boolean; diff --git a/dist/app-keys.js b/dist/app-keys.js index b20b613..349278a 100644 --- a/dist/app-keys.js +++ b/dist/app-keys.js @@ -20,52 +20,85 @@ export function isAppKey(key) { return appKeys.size > 0 && appKeys.has(key.toLowerCase()); } /** - * OpenTelemetry semantic-convention namespaces, and those of the instrumentations this package and - * its apps load. Their attributes are matched by whole name only, so a generic app key (`name`, - * `id`) never wipes `db.system.name` or `bullmq.job.id`. + * Attributes the package's instrumentations, BullMQ and Prisma emit whose last segment is a + * plausible generic app key (`name`, `id`, `method`). Only these exact names skip last-segment + * matching, so `name` never wipes `db.system.name`, while an app's own `http.unitLabel` is still + * redacted. Content-bearing attributes (`url.full`, `db.query.text`, job options and reasons) are + * deliberately absent. Prisma's bare `name`, `method` and `model` are not exempt: a whole-name app + * key matches them. */ -const OTEL_NAMESPACES = new Set([ - "bullmq", - "client", - "code", - "db", - "deployment", - "enduser", - "error", - "exception", - "host", - "http", - "messaging", - "net", - "network", - "next", - "nodejs", - "otel", - "peer", - "pg", - "prisma", - "process", - "rpc", - "server", - "service", - "telemetry", - "thread", - "url", - "user", - "user_agent", - "v8js", +export const EXEMPT_ATTRIBUTES = new Set([ + // http and undici + "http.request.method", + "http.request.method_original", + "http.response.status_code", + "http.route", + "server.address", + "server.port", + "network.peer.port", + "network.protocol.version", + "url.scheme", + "error.type", + "user_agent.synthetic.type", + // ioredis, pg and Prisma + "db.system.name", + "db.operation.name", + "db.namespace", + "db.client.connection.pool.name", + "db.client.connection.state", + // messaging semantic conventions + "messaging.destination.name", + // bullmq: names and identifiers + "bullmq.queue.name", + "bullmq.queue.operation", + "bullmq.job.name", + "bullmq.job.id", + "bullmq.job.ids", + "bullmq.job.key", + "bullmq.job.type", + "bullmq.job.state", + "bullmq.job.scheduler.id", + "bullmq.worker.name", + "bullmq.worker.id", + "bullmq.flow.name", + "bullmq.queue.jobs.state", + // bullmq: counts and timings + "bullmq.job.attempts.made", + "bullmq.job.bulk.count", + "bullmq.job.processed.timestamp", + "bullmq.job.attempt_finished_timestamp", + "bullmq.job.duration", + "bullmq.queue.drain.delay", + "bullmq.queue.grace", + "bullmq.queue.clean.limit", + "bullmq.queue.clean.count", + "bullmq.queue.rate.limit", + "bullmq.queue.event.max.length", + "bullmq.worker.rate.limit", + "bullmq.worker.do.not.wait.active", + "bullmq.worker.force.close", + "bullmq.worker.stalled.jobs", + "bullmq.worker.failed.jobs", + "bullmq.worker.jobs.to.extend.locks", + "bullmq.jobs.completed", + "bullmq.jobs.failed", + "bullmq.jobs.delayed", + "bullmq.jobs.retried", + "bullmq.jobs.waiting", + "bullmq.jobs.waiting_children", + // exception events + "exception.type", ]); /** - * A span attribute is an app key when its whole name is one, or, outside the OpenTelemetry - * namespaces, when its last `.` segment is one (`acp.tenant.name`). + * A span attribute is an app key when its whole name is one, or its last `.` segment is one, + * unless it is an exempt instrumentation attribute. */ export function isAppAttributeKey(key) { if (appKeys.size === 0) return false; if (isAppKey(key)) return true; - const dot = key.indexOf("."); - if (dot === -1 || OTEL_NAMESPACES.has(key.slice(0, dot).toLowerCase())) + if (EXEMPT_ATTRIBUTES.has(key)) return false; return isAppKey(key.slice(key.lastIndexOf(".") + 1)); } diff --git a/src/__tests__/options.test.ts b/src/__tests__/options.test.ts index 51fc310..1a046ee 100644 --- a/src/__tests__/options.test.ts +++ b/src/__tests__/options.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { InstrumentationBase } from "@opentelemetry/instrumentation"; import { log as telemetryLog } from "../log-safely.js"; import { scrubAttributes } from "../redact.js"; +import { EXEMPT_ATTRIBUTES } from "../app-keys.js"; import { installFatalHandlers } from "../fatal.js"; import { buildInstrumentations } from "../instrumentations.js"; import { isSensitiveKey, resetSensitiveKeys } from "../log-redact.js"; @@ -189,7 +190,7 @@ describe("redactKeys on spans", () => { // WHY: a generic app key (a bare `name` for a person's name) must not wipe the OpenTelemetry // attributes that traces are searched and grouped by. - it("matches a last segment only outside OpenTelemetry's namespaces", async () => { + it("keeps the listed semantic-convention attributes from a generic key", async () => { await startTelemetry({ serviceName: "api", env: {}, @@ -211,6 +212,42 @@ describe("redactKeys on spans", () => { }); }); + it("keeps every listed attribute from keys built from their own last segments", async () => { + const keys = [...EXEMPT_ATTRIBUTES].map((name) => name.slice(name.lastIndexOf(".") + 1)); + await startTelemetry({ serviceName: "api", env: {}, redactKeys: keys }); + const attributes = Object.fromEntries([...EXEMPT_ATTRIBUTES].map((name) => [name, "v"])); + expect(scrubAttributes(attributes)).toEqual(attributes); + }); + + // WHY: an attribute the app sets under an OpenTelemetry namespace is still the app's data; only + // the exact names the instrumentations emit are exempt. + it("redacts an unlisted attribute under an OpenTelemetry namespace", async () => { + await startTelemetry({ serviceName: "api", env: {}, redactKeys: ["unitLabel", "name"] }); + expect( + scrubAttributes({ + "http.unitLabel": "4B", + "db.unitlabel": "4B", + "messaging.unitLabel": "4B", + "bullmq.job.tenant.name": "Jane", + "db.system.name": "postgresql", + }), + ).toEqual({ + "http.unitLabel": "[redacted]", + "db.unitlabel": "[redacted]", + "messaging.unitLabel": "[redacted]", + "bullmq.job.tenant.name": "[redacted]", + "db.system.name": "postgresql", + }); + }); + + // Prisma sets bare `name`, `method` and `model`; a whole-name app key matches them. + it("redacts Prisma's bare attributes under a whole-name key", async () => { + await startTelemetry({ serviceName: "api", env: {}, redactKeys: ["name", "model"] }); + expect(scrubAttributes({ name: "Tenant.findMany", model: "Tenant", method: "findMany" })).toEqual( + { name: "[redacted]", model: "[redacted]", method: "findMany" }, + ); + }); + it("leaves span attributes alone when the app sets no keys", () => { expect(scrubAttributes({ unitLabel: "4B" })).toEqual({ unitLabel: "4B" }); }); diff --git a/src/app-keys.ts b/src/app-keys.ts index 27ba5b4..00d7af4 100644 --- a/src/app-keys.ts +++ b/src/app-keys.ts @@ -21,50 +21,83 @@ export function isAppKey(key: string): boolean { } /** - * OpenTelemetry semantic-convention namespaces, and those of the instrumentations this package and - * its apps load. Their attributes are matched by whole name only, so a generic app key (`name`, - * `id`) never wipes `db.system.name` or `bullmq.job.id`. + * Attributes the package's instrumentations, BullMQ and Prisma emit whose last segment is a + * plausible generic app key (`name`, `id`, `method`). Only these exact names skip last-segment + * matching, so `name` never wipes `db.system.name`, while an app's own `http.unitLabel` is still + * redacted. Content-bearing attributes (`url.full`, `db.query.text`, job options and reasons) are + * deliberately absent. Prisma's bare `name`, `method` and `model` are not exempt: a whole-name app + * key matches them. */ -const OTEL_NAMESPACES = new Set([ - "bullmq", - "client", - "code", - "db", - "deployment", - "enduser", - "error", - "exception", - "host", - "http", - "messaging", - "net", - "network", - "next", - "nodejs", - "otel", - "peer", - "pg", - "prisma", - "process", - "rpc", - "server", - "service", - "telemetry", - "thread", - "url", - "user", - "user_agent", - "v8js", +export const EXEMPT_ATTRIBUTES: ReadonlySet = new Set([ + // http and undici + "http.request.method", + "http.request.method_original", + "http.response.status_code", + "http.route", + "server.address", + "server.port", + "network.peer.port", + "network.protocol.version", + "url.scheme", + "error.type", + "user_agent.synthetic.type", + // ioredis, pg and Prisma + "db.system.name", + "db.operation.name", + "db.namespace", + "db.client.connection.pool.name", + "db.client.connection.state", + // messaging semantic conventions + "messaging.destination.name", + // bullmq: names and identifiers + "bullmq.queue.name", + "bullmq.queue.operation", + "bullmq.job.name", + "bullmq.job.id", + "bullmq.job.ids", + "bullmq.job.key", + "bullmq.job.type", + "bullmq.job.state", + "bullmq.job.scheduler.id", + "bullmq.worker.name", + "bullmq.worker.id", + "bullmq.flow.name", + "bullmq.queue.jobs.state", + // bullmq: counts and timings + "bullmq.job.attempts.made", + "bullmq.job.bulk.count", + "bullmq.job.processed.timestamp", + "bullmq.job.attempt_finished_timestamp", + "bullmq.job.duration", + "bullmq.queue.drain.delay", + "bullmq.queue.grace", + "bullmq.queue.clean.limit", + "bullmq.queue.clean.count", + "bullmq.queue.rate.limit", + "bullmq.queue.event.max.length", + "bullmq.worker.rate.limit", + "bullmq.worker.do.not.wait.active", + "bullmq.worker.force.close", + "bullmq.worker.stalled.jobs", + "bullmq.worker.failed.jobs", + "bullmq.worker.jobs.to.extend.locks", + "bullmq.jobs.completed", + "bullmq.jobs.failed", + "bullmq.jobs.delayed", + "bullmq.jobs.retried", + "bullmq.jobs.waiting", + "bullmq.jobs.waiting_children", + // exception events + "exception.type", ]); /** - * A span attribute is an app key when its whole name is one, or, outside the OpenTelemetry - * namespaces, when its last `.` segment is one (`acp.tenant.name`). + * A span attribute is an app key when its whole name is one, or its last `.` segment is one, + * unless it is an exempt instrumentation attribute. */ export function isAppAttributeKey(key: string): boolean { if (appKeys.size === 0) return false; if (isAppKey(key)) return true; - const dot = key.indexOf("."); - if (dot === -1 || OTEL_NAMESPACES.has(key.slice(0, dot).toLowerCase())) return false; + if (EXEMPT_ATTRIBUTES.has(key)) return false; return isAppKey(key.slice(key.lastIndexOf(".") + 1)); }