From 9b7901796ef2bb5d4adeb3f0ff02791ab4269de1 Mon Sep 17 00:00:00 2001 From: ety001 Date: Wed, 26 Aug 2026 04:02:18 +0800 Subject: [PATCH] feat(telemetry): add opt-in OpenTelemetry tracing for the Node BFF Wallet is now the parent of jussi/conveyor RPC calls; without a server span those traces cannot be attributed to a wallet API. Keep the SDK off unless an OTLP endpoint is set so empty collectors cannot flood logs. --- .env.example | 12 ++ docker/docker-compose.yml | 8 + next.config.ts | 17 +- package.json | 10 + pnpm-lock.yaml | 300 +++++++++++++++++++++++++- src/instrumentation.ts | 20 ++ src/lib/telemetry/config.ts | 138 ++++++++++++ src/lib/telemetry/ignore.ts | 120 +++++++++++ src/lib/telemetry/register.ts | 182 ++++++++++++++++ tests/unit/instrumentation.test.ts | 42 ++++ tests/unit/telemetry-config.test.ts | 193 +++++++++++++++++ tests/unit/telemetry-ignore.test.ts | 127 +++++++++++ tests/unit/telemetry-register.test.ts | 230 ++++++++++++++++++++ 13 files changed, 1387 insertions(+), 12 deletions(-) create mode 100644 src/instrumentation.ts create mode 100644 src/lib/telemetry/config.ts create mode 100644 src/lib/telemetry/ignore.ts create mode 100644 src/lib/telemetry/register.ts create mode 100644 tests/unit/instrumentation.test.ts create mode 100644 tests/unit/telemetry-config.test.ts create mode 100644 tests/unit/telemetry-ignore.test.ts create mode 100644 tests/unit/telemetry-register.test.ts diff --git a/.env.example b/.env.example index 28c511a5..d7873d01 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,15 @@ STEEM_RPC_URL=https://api.steemit.com NEXT_PUBLIC_SIGNUP_URL=https://signup.steemit.com # Test: # NEXT_PUBLIC_SIGNUP_URL=https://signup.steemitdev.com + +# OpenTelemetry tracing (server-side only). Disabled unless an OTLP endpoint +# is set — do not default-on against localhost or logs will flood when no +# collector is running. Matches jussi/conveyor: OTLP HTTP + optional custom +# path/headers for OpenObserve. +# WALLET_TELEMETRY_ENABLED=true +# WALLET_TELEMETRY_SERVICE_NAME=wallet +# WALLET_TELEMETRY_OTLP_ENDPOINT=http://10.188.1.50:5080 +# WALLET_TELEMETRY_OTLP_PATH=/api/default/v1/traces +# WALLET_TELEMETRY_OTLP_HEADERS=Authorization=Basic ... +# WALLET_TELEMETRY_RESOURCE_ATTRIBUTES=deployment.environment=dev +# OTEL_SDK_DISABLED=true # emergency kill switch (standard OTel env) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6c72d4cd..1f25ddd4 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -57,6 +57,14 @@ services: - LOG_LEVEL=${LOG_LEVEL:-info} - LOG_REQUESTS=${LOG_REQUESTS:-false} + # OpenTelemetry (optional; off unless WALLET_TELEMETRY_OTLP_ENDPOINT is set) + - WALLET_TELEMETRY_ENABLED=${WALLET_TELEMETRY_ENABLED:-} + - WALLET_TELEMETRY_SERVICE_NAME=${WALLET_TELEMETRY_SERVICE_NAME:-wallet} + - WALLET_TELEMETRY_OTLP_ENDPOINT=${WALLET_TELEMETRY_OTLP_ENDPOINT:-} + - WALLET_TELEMETRY_OTLP_PATH=${WALLET_TELEMETRY_OTLP_PATH:-} + - WALLET_TELEMETRY_OTLP_HEADERS=${WALLET_TELEMETRY_OTLP_HEADERS:-} + - WALLET_TELEMETRY_RESOURCE_ATTRIBUTES=${WALLET_TELEMETRY_RESOURCE_ATTRIBUTES:-} + # Health check healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/api/health"] diff --git a/next.config.ts b/next.config.ts index c79dd750..e724a28c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -22,8 +22,21 @@ const nextConfig: NextConfig = { // Compress responses compress: true, - // Exclude mysql2 from client-side bundling (server-only native module) - serverExternalPackages: ['mysql2'], + // Exclude native / Node-only packages from the client bundle. OpenTelemetry + // must stay on the server so the SDK can patch http/undici for W3C propagation. + serverExternalPackages: [ + 'mysql2', + '@opentelemetry/api', + '@opentelemetry/context-async-hooks', + '@opentelemetry/core', + '@opentelemetry/exporter-trace-otlp-http', + '@opentelemetry/instrumentation', + '@opentelemetry/instrumentation-http', + '@opentelemetry/instrumentation-undici', + '@opentelemetry/resources', + '@opentelemetry/sdk-trace-node', + '@opentelemetry/semantic-conventions', + ], // Subresource Integrity: generate SHA-256 hashes for all JS bundles at // build time. Browsers verify file integrity via the `integrity` attribute, diff --git a/package.json b/package.json index 82941b97..664f92ba 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,16 @@ "e2e:ui": "playwright test --ui" }, "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.10.0", + "@opentelemetry/core": "^2.10.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.221.0", + "@opentelemetry/instrumentation": "^0.221.0", + "@opentelemetry/instrumentation-http": "^0.221.0", + "@opentelemetry/instrumentation-undici": "^0.31.0", + "@opentelemetry/resources": "^2.10.0", + "@opentelemetry/sdk-trace-node": "^2.10.0", + "@opentelemetry/semantic-conventions": "^1.43.0", "@reduxjs/toolkit": "^2.11.2", "@steemit/steem-js": "^1.2.0", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30064624..b6288f63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,36 @@ importers: .: dependencies: + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 + '@opentelemetry/context-async-hooks': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.221.0 + version: 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': + specifier: ^0.221.0 + version: 0.221.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0) + '@opentelemetry/instrumentation-http': + specifier: ^0.221.0 + version: 0.221.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0) + '@opentelemetry/instrumentation-undici': + specifier: ^0.31.0 + version: 0.31.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0) + '@opentelemetry/resources': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': + specifier: ^1.43.0 + version: 1.43.0 '@reduxjs/toolkit': specifier: ^2.11.2 version: 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5) @@ -22,7 +52,7 @@ importers: version: 2.1.1 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(mysql2@3.22.4(@types/node@25.6.0)) + version: 0.45.2(@opentelemetry/api@1.9.1)(mysql2@3.22.4(@types/node@25.6.0)) ioredis: specifier: ^5.10.1 version: 5.10.1(supports-color@7.2.0) @@ -34,10 +64,10 @@ importers: version: 3.22.4(@types/node@25.6.0) next: specifier: 16.2.11 - version: 16.2.11(@babel/core@7.29.0(supports-color@7.2.0))(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.11(@babel/core@7.29.0(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) next-intl: specifier: ^4.9.2 - version: 4.9.2(next@16.2.11(@babel/core@7.29.0(supports-color@7.2.0))(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + version: 4.9.2(next@16.2.11(@babel/core@7.29.0(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@6.0.3) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -128,7 +158,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.4 - version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@24.1.3(supports-color@7.2.0))(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.1(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.22.3)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@24.1.3(supports-color@7.2.0))(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.1(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.22.3)) packages: @@ -1210,6 +1240,102 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opentelemetry/api-logs@0.221.0': + resolution: {integrity: sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==} + 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/context-async-hooks@2.10.0': + resolution: {integrity: sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.10.0': + resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-trace-otlp-http@0.221.0': + resolution: {integrity: sha512-AySXiKoC+meiWm6zdVj5T2LnPDZuatveBby1cMOeQteIWsYXAUxs8Sru13G2pVSPrUXz6vF+og7QVBX6GdC/oQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.221.0': + resolution: {integrity: sha512-oIP91CPIANuYr09tGFElPFKAh6JUar+awJf1kBRYlaeo9b0gDwZHEB2zBfFlvdNFHm0wAVutMZODVi5smKT30g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.31.0': + resolution: {integrity: sha512-qunCfgSFV+bjRdAYkWIjVX38jIN/Xj80CiERXXdzYAmdigCFvJPB3AY3j43bjJlkhgbJJSCGdbvQUSut8QIiyQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation@0.221.0': + resolution: {integrity: sha512-cCk80Z/iRDf/5gfsKMB4f74LqVA5yKETB/9ojPzVW/6/f70iu89nJvGxsFCxx4XfSohaOofkU19kiYm84AiAlw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.221.0': + resolution: {integrity: sha512-UFPIq80OH3Ns/oPFHRj14d4DTOxUo+MUFU8hUiCq5jTqFhdeJnfVSANHT+xp92409cA+oxzvlZCe6NM1wvCuBA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.221.0': + resolution: {integrity: sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.10.0': + resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.221.0': + resolution: {integrity: sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.10.0': + resolution: {integrity: sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.10.0': + resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.10.0': + resolution: {integrity: sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.10.0': + resolution: {integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==} + 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'} + '@oxc-project/types@0.144.0': resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} @@ -2882,6 +3008,9 @@ packages: resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==} engines: {node: '>= 0.10'} + cjs-module-lexer@2.2.1: + resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -3341,6 +3470,9 @@ packages: es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -3646,6 +3778,9 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -3892,6 +4027,10 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-in-the-middle@3.3.3: + resolution: {integrity: sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==} + engines: {node: '>=18'} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -4636,6 +4775,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + 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==} @@ -5151,6 +5293,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} 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'} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -6791,6 +6937,116 @@ snapshots: '@open-draft/until@2.1.0': {} + '@opentelemetry/api-logs@0.221.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/context-async-hooks@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-trace-otlp-http@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/instrumentation-http@0.221.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0) + '@opentelemetry/semantic-conventions': 1.43.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.31.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.221.0(@opentelemetry/api@1.9.1)(supports-color@7.2.0)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + import-in-the-middle: 3.3.3 + require-in-the-middle: 8.0.1(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-logs@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace-node@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/semantic-conventions@1.43.0': {} + '@oxc-project/types@0.144.0': {} '@parcel/watcher-android-arm64@2.6.0': @@ -8110,7 +8366,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@24.1.3(supports-color@7.2.0))(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.1(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.22.3)) + vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@24.1.3(supports-color@7.2.0))(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.1(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.22.3)) '@vitest/expect@4.1.4': dependencies: @@ -8411,6 +8667,8 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.2 + cjs-module-lexer@2.2.1: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -8668,8 +8926,9 @@ snapshots: esbuild: 0.25.12 tsx: 4.22.3 - drizzle-orm@0.45.2(mysql2@3.22.4(@types/node@25.6.0)): + drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(mysql2@3.22.4(@types/node@25.6.0)): optionalDependencies: + '@opentelemetry/api': 1.9.1 mysql2: 3.22.4(@types/node@25.6.0) dunder-proto@1.0.1: @@ -8792,6 +9051,8 @@ snapshots: es-module-lexer@2.0.0: {} + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -9300,6 +9561,8 @@ snapshots: dependencies: fetch-blob: 3.2.0 + forwarded-parse@2.1.2: {} + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -9587,6 +9850,12 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@3.3.3: + dependencies: + cjs-module-lexer: 2.2.1 + es-module-lexer: 2.3.2 + module-details-from-path: 1.0.4 + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -10459,6 +10728,8 @@ snapshots: minimist@1.2.8: {} + module-details-from-path@1.0.4: {} + ms@2.1.3: {} msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3): @@ -10522,14 +10793,14 @@ snapshots: next-intl-swc-plugin-extractor@4.13.7: {} - next-intl@4.9.2(next@16.2.11(@babel/core@7.29.0(supports-color@7.2.0))(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@6.0.3): + next-intl@4.9.2(next@16.2.11(@babel/core@7.29.0(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@6.0.3): dependencies: '@formatjs/intl-localematcher': 0.8.13 '@parcel/watcher': 2.6.0 '@swc/core': 1.16.1 icu-minify: 4.13.7 negotiator: 1.1.0 - next: 16.2.11(@babel/core@7.29.0(supports-color@7.2.0))(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.11(@babel/core@7.29.0(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) next-intl-swc-plugin-extractor: 4.13.7 po-parser: 2.2.0 react: 19.2.5 @@ -10539,7 +10810,7 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - next@16.2.11(@babel/core@7.29.0(supports-color@7.2.0))(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + next@16.2.11(@babel/core@7.29.0(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@next/env': 16.2.11 '@swc/helpers': 0.5.15 @@ -10558,6 +10829,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.11 '@next/swc-win32-arm64-msvc': 16.2.11 '@next/swc-win32-x64-msvc': 16.2.11 + '@opentelemetry/api': 1.9.1 '@playwright/test': 1.59.1 sharp: 0.34.5 transitivePeerDependencies: @@ -11109,6 +11381,13 @@ snapshots: require-from-string@2.0.2: {} + require-in-the-middle@8.0.1(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + requires-port@1.0.0: {} reselect@5.1.1: {} @@ -11873,7 +12152,7 @@ snapshots: jiti: 2.6.1 tsx: 4.22.3 - vitest@4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@24.1.3(supports-color@7.2.0))(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.1(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.22.3)): + vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@24.1.3(supports-color@7.2.0))(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.1(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.22.3)): dependencies: '@vitest/expect': 4.1.4 '@vitest/mocker': 4.1.4(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.1(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.22.3)) @@ -11896,6 +12175,7 @@ snapshots: vite: 8.2.1(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.22.3) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 25.6.0 '@vitest/coverage-v8': 4.1.4(vitest@4.1.4) jsdom: 24.1.3(supports-color@7.2.0) diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 00000000..0c9d41a1 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,20 @@ +/** + * Next.js instrumentation hook (Node runtime only). + * + * Edge (`src/proxy.ts`) cannot load the Node OTel SDK. Tracing is initialized + * here so HTTP + undici (fetch) are patched before API routes and SteemService + * run. See src/lib/telemetry/. + */ +export async function register(): Promise { + if (process.env.NEXT_RUNTIME !== 'nodejs') return; + + try { + const { registerTelemetry } = await import('./lib/telemetry/register'); + await registerTelemetry(); + } catch (err) { + console.error( + '[telemetry] failed to load OpenTelemetry module; continuing without tracing:', + err instanceof Error ? err.message : err + ); + } +} diff --git a/src/lib/telemetry/config.ts b/src/lib/telemetry/config.ts new file mode 100644 index 00000000..8b28997d --- /dev/null +++ b/src/lib/telemetry/config.ts @@ -0,0 +1,138 @@ +/** + * OpenTelemetry configuration for the wallet Node runtime. + * + * Tracing is opt-in: with no OTLP endpoint the SDK is never initialized, so + * local/dev without a collector does not flood logs. Explicit + * WALLET_TELEMETRY_ENABLED=false or OTEL_SDK_DISABLED=true always wins. + * + * Env names follow jussi/conveyor (service-prefixed) and also honor the + * standard OTEL_* variables as fallbacks. + */ + +export const DEFAULT_SERVICE_NAME = 'wallet'; +export const DEFAULT_OTLP_HTTP_PATH = '/v1/traces'; +export const DEFAULT_OTLP_HTTP_PORT = '4318'; + +export type TelemetryConfig = { + serviceName: string; + exporterUrl: string; + headers: Record; + resourceAttributes: Record; +}; + +function trimEnv(value: string | undefined): string { + return value?.trim() ?? ''; +} + +function firstNonEmpty(...values: Array): string { + for (const value of values) { + const trimmed = trimEnv(value); + if (trimmed) return trimmed; + } + return ''; +} + +/** + * Parse `true`/`false`/`1`/`0`/`on`/`off`/`yes`/`no`. Empty or unknown → undefined. + */ +export function parseEnvFlag(value: string | undefined): boolean | undefined { + const trimmed = trimEnv(value).toLowerCase(); + if (!trimmed) return undefined; + if (trimmed === '1' || trimmed === 'true' || trimmed === 'yes' || trimmed === 'on') { + return true; + } + if (trimmed === '0' || trimmed === 'false' || trimmed === 'no' || trimmed === 'off') { + return false; + } + return undefined; +} + +/** + * Parse `Key=Value,Key2=Value2`. Values may contain `=` (e.g. Basic auth). + */ +export function parseKeyValueList(raw: string): Record { + const out: Record = {}; + if (!raw.trim()) return out; + for (const pair of raw.split(',')) { + const trimmed = pair.trim(); + if (!trimmed) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (key) out[key] = value; + } + return out; +} + +/** + * Build a full OTLP/HTTP traces URL. + * + * `endpoint` may be `host:port`, `http(s)://host[:port]`, or a URL that already + * includes a path. `explicitPath` (WALLET_TELEMETRY_OTLP_PATH) wins over a path + * on the endpoint; otherwise a URL path is kept; otherwise `/v1/traces`. + */ +export function buildExporterUrl(endpoint: string, explicitPath: string): string { + const trimmed = endpoint.trim(); + if (!trimmed) return ''; + const withScheme = trimmed.includes('://') ? trimmed : `http://${trimmed}`; + let parsed: URL; + try { + parsed = new URL(withScheme); + } catch { + return ''; + } + if (!parsed.port) { + parsed.port = DEFAULT_OTLP_HTTP_PORT; + } + const fromUrl = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname : ''; + const path = explicitPath.trim() || fromUrl || DEFAULT_OTLP_HTTP_PATH; + parsed.pathname = path.startsWith('/') ? path : `/${path}`; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); +} + +export function loadTelemetryConfig( + env: NodeJS.Dict = process.env +): TelemetryConfig | null { + if (parseEnvFlag(env.OTEL_SDK_DISABLED) === true) return null; + if (parseEnvFlag(env.WALLET_TELEMETRY_ENABLED) === false) return null; + + const endpoint = firstNonEmpty( + env.WALLET_TELEMETRY_OTLP_ENDPOINT, + env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + env.OTEL_EXPORTER_OTLP_ENDPOINT + ); + if (!endpoint) return null; + + const explicitPath = firstNonEmpty(env.WALLET_TELEMETRY_OTLP_PATH); + const exporterUrl = buildExporterUrl(endpoint, explicitPath); + if (!exporterUrl) return null; + + const serviceName = + firstNonEmpty(env.WALLET_TELEMETRY_SERVICE_NAME, env.OTEL_SERVICE_NAME) || + DEFAULT_SERVICE_NAME; + + const headers = parseKeyValueList( + firstNonEmpty(env.WALLET_TELEMETRY_OTLP_HEADERS, env.OTEL_EXPORTER_OTLP_HEADERS) + ); + const resourceAttributes = parseKeyValueList( + firstNonEmpty( + env.WALLET_TELEMETRY_RESOURCE_ATTRIBUTES, + env.OTEL_RESOURCE_ATTRIBUTES + ) + ); + + return { serviceName, exporterUrl, headers, resourceAttributes }; +} + +/** Hostname (no credentials) for logs — never print OTLP headers. */ +export function exporterHostForLog(exporterUrl: string): string { + try { + const parsed = new URL(exporterUrl); + return `${parsed.host}${parsed.pathname}`; + } catch { + return '(invalid exporter url)'; + } +} diff --git a/src/lib/telemetry/ignore.ts b/src/lib/telemetry/ignore.ts new file mode 100644 index 00000000..bcbdc6e8 --- /dev/null +++ b/src/lib/telemetry/ignore.ts @@ -0,0 +1,120 @@ +/** + * Paths and destinations that must not create spans. + * + * Health probes (ELB / OpenResty / Docker HEALTHCHECK) would otherwise dominate + * the trace stream. Outgoing calls to the OTLP collector must be ignored to + * prevent an export-loop of traces about traces. + */ + +export const IGNORED_INCOMING_PATHS = [ + '/api/health', + '/.well-known/healthcheck.json', +] as const; + +export function incomingPathname(url: string | undefined): string { + if (!url) return ''; + try { + if (url.includes('://')) { + return new URL(url).pathname; + } + } catch { + // Node IncomingMessage.url is path+query, not a full URL. + } + const q = url.indexOf('?'); + return q === -1 ? url : url.slice(0, q); +} + +export function shouldIgnoreIncomingPath(url: string | undefined): boolean { + const path = incomingPathname(url); + return IGNORED_INCOMING_PATHS.some((ignored) => path === ignored); +} + +function defaultPort(protocol: string, port: string): string { + if (port) return port; + return protocol === 'https:' ? '443' : '80'; +} + +function requestPort( + protocol: string | undefined, + port: string | number | undefined +): string { + if (port !== undefined && port !== '') return String(port); + return defaultPort(protocol ?? 'http:', ''); +} + +/** + * Split `hostname` / `host` (which may be `host:port`) from Node RequestOptions. + */ +export function splitHostPort( + hostname: string | undefined, + host: string | undefined, + port: string | number | undefined +): { hostname: string | undefined; port: string | number | undefined } { + if (hostname) return { hostname, port }; + if (!host) return { hostname: undefined, port }; + const colon = host.lastIndexOf(':'); + if (colon > 0 && host.indexOf(':') === colon) { + const parsedPort = host.slice(colon + 1); + return { + hostname: host.slice(0, colon), + port: port !== undefined && port !== '' ? port : parsedPort, + }; + } + return { hostname: host, port }; +} + +/** + * True when an outgoing Node `http` request is the OTLP exporter itself. + */ +export function shouldIgnoreOutgoingHttp( + hostname: string | undefined, + port: string | number | undefined, + path: string | undefined, + protocol: string | undefined, + exporterUrl: string, + host: string | undefined +): boolean { + const split = splitHostPort(hostname, host, port); + if (!exporterUrl || !split.hostname) return false; + try { + const exporter = new URL(exporterUrl); + if (split.hostname !== exporter.hostname) return false; + const reqPort = requestPort(protocol, split.port); + const expPort = defaultPort(exporter.protocol, exporter.port); + if (reqPort !== expPort) return false; + return pathMatchesExporter(path ?? '/', exporter.pathname); + } catch { + return false; + } +} + +/** + * True when an outgoing undici/`fetch` request is the OTLP exporter itself. + */ +export function shouldIgnoreOutgoingFetch( + origin: string, + path: string, + exporterUrl: string +): boolean { + if (!exporterUrl || !origin) return false; + try { + const exporter = new URL(exporterUrl); + const request = new URL(path, origin); + if (request.hostname !== exporter.hostname) return false; + const reqPort = defaultPort(request.protocol, request.port); + const expPort = defaultPort(exporter.protocol, exporter.port); + if (reqPort !== expPort) return false; + return pathMatchesExporter(request.pathname, exporter.pathname); + } catch { + return false; + } +} + +function pathMatchesExporter(requestPath: string, exporterPath: string): boolean { + const normalizedExporter = exporterPath.replace(/\/$/, '') || '/'; + const normalizedRequest = requestPath || '/'; + return ( + normalizedRequest === normalizedExporter || + normalizedRequest.startsWith(`${normalizedExporter}/`) + ); +} diff --git a/src/lib/telemetry/register.ts b/src/lib/telemetry/register.ts new file mode 100644 index 00000000..e88a1248 --- /dev/null +++ b/src/lib/telemetry/register.ts @@ -0,0 +1,182 @@ +/** + * Node-runtime OpenTelemetry SDK setup. + * + * Fail-open: any initialization error is logged once and the process continues + * without tracing. Export failures after startup are also rate-limited so a + * missing collector cannot flood stdout the way jussi did in empty environments. + * + * Do not import `http`/`https` in this module — the HTTP instrumentation must + * patch those modules before the OTLP exporter loads them. + */ + +import { diag, DiagLogLevel, type DiagLogger } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { + CompositePropagator, + W3CBaggagePropagator, + W3CTraceContextPropagator, +} from '@opentelemetry/core'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; +import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { + BatchSpanProcessor, + NodeTracerProvider, +} from '@opentelemetry/sdk-trace-node'; +import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; + +import { + exporterHostForLog, + loadTelemetryConfig, + type TelemetryConfig, +} from './config'; +import { + shouldIgnoreIncomingPath, + shouldIgnoreOutgoingFetch, + shouldIgnoreOutgoingHttp, +} from './ignore'; + +let started = false; +let provider: NodeTracerProvider | null = null; +let exportErrorLogged = false; + +function createQuietDiagLogger(): DiagLogger { + const report = (level: 'error' | 'warn', message: string): void => { + const isExport = + message.includes('OTLP') || + message.toLowerCase().includes('export') || + message.includes('ECONNREFUSED'); + if (isExport) { + if (exportErrorLogged) return; + exportErrorLogged = true; + console.error( + '[telemetry] trace export failed (further export errors suppressed):', + message + ); + return; + } + const log = level === 'error' ? console.error : console.warn; + log(`[telemetry] ${message}`); + }; + + return { + error: (message: string) => { + report('error', message); + }, + warn: (message: string) => { + report('warn', message); + }, + info: () => {}, + debug: () => {}, + verbose: () => {}, + }; +} + +function buildResource(config: TelemetryConfig) { + return resourceFromAttributes({ + [ATTR_SERVICE_NAME]: config.serviceName, + ...config.resourceAttributes, + }); +} + +function startSdk(config: TelemetryConfig): void { + diag.setLogger(createQuietDiagLogger(), DiagLogLevel.ERROR); + + registerInstrumentations({ + instrumentations: [ + new HttpInstrumentation({ + ignoreIncomingRequestHook: (request) => + shouldIgnoreIncomingPath(request.url), + ignoreOutgoingRequestHook: (options) => + shouldIgnoreOutgoingHttp( + options.hostname ?? undefined, + options.port ?? undefined, + options.path ?? undefined, + options.protocol ?? undefined, + config.exporterUrl, + typeof options.host === 'string' ? options.host : undefined + ), + }), + new UndiciInstrumentation({ + ignoreRequestHook: (request) => + shouldIgnoreOutgoingFetch( + request.origin, + request.path, + config.exporterUrl + ), + }), + ], + }); + + const exporterOptions: { url: string; headers?: Record } = { + url: config.exporterUrl, + }; + if (Object.keys(config.headers).length > 0) { + exporterOptions.headers = config.headers; + } + + const nextProvider = new NodeTracerProvider({ + resource: buildResource(config), + spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter(exporterOptions))], + }); + + nextProvider.register({ + contextManager: new AsyncLocalStorageContextManager(), + propagator: new CompositePropagator({ + propagators: [ + new W3CTraceContextPropagator(), + new W3CBaggagePropagator(), + ], + }), + }); + + provider = nextProvider; + + const flush = (): void => { + void nextProvider.shutdown().catch(() => { + // Best-effort flush on process exit. + }); + }; + process.once('SIGTERM', flush); + process.once('SIGINT', flush); +} + +/** + * Initialize tracing if configured. Returns true when the SDK is running. + * Safe to call more than once. + */ +export async function registerTelemetry(): Promise { + if (started) return provider !== null; + + const config = loadTelemetryConfig(); + if (!config) return false; + + try { + startSdk(config); + started = true; + console.info( + '[telemetry] OpenTelemetry tracing initialized', + JSON.stringify({ + service: config.serviceName, + endpoint: exporterHostForLog(config.exporterUrl), + }) + ); + return true; + } catch (err) { + started = true; + console.error( + '[telemetry] OpenTelemetry initialization failed; continuing without tracing:', + err instanceof Error ? err.message : err + ); + return false; + } +} + +/** Test-only: reset module state between cases. */ +export function resetTelemetryState(): void { + started = false; + provider = null; + exportErrorLogged = false; +} diff --git a/tests/unit/instrumentation.test.ts b/tests/unit/instrumentation.test.ts new file mode 100644 index 00000000..f2643519 --- /dev/null +++ b/tests/unit/instrumentation.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('instrumentation register()', () => { + beforeEach(() => { + vi.resetModules(); + delete process.env.NEXT_RUNTIME; + }); + + afterEach(() => { + delete process.env.NEXT_RUNTIME; + vi.restoreAllMocks(); + }); + + it('skips setup outside the Node runtime', async () => { + process.env.NEXT_RUNTIME = 'edge'; + const registerTelemetry = vi.fn(); + vi.doMock('@/lib/telemetry/register', () => ({ registerTelemetry })); + const { register } = await import('@/instrumentation'); + await register(); + expect(registerTelemetry).not.toHaveBeenCalled(); + }); + + it('loads telemetry on the Node runtime', async () => { + process.env.NEXT_RUNTIME = 'nodejs'; + const registerTelemetry = vi.fn().mockResolvedValue(true); + vi.doMock('@/lib/telemetry/register', () => ({ registerTelemetry })); + const { register } = await import('@/instrumentation'); + await register(); + expect(registerTelemetry).toHaveBeenCalledOnce(); + }); + + it('does not throw when the telemetry module fails to load', async () => { + process.env.NEXT_RUNTIME = 'nodejs'; + vi.doMock('@/lib/telemetry/register', () => { + throw new Error('cannot load sdk'); + }); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { register } = await import('@/instrumentation'); + await expect(register()).resolves.toBeUndefined(); + expect(error).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/telemetry-config.test.ts b/tests/unit/telemetry-config.test.ts new file mode 100644 index 00000000..00c59ce6 --- /dev/null +++ b/tests/unit/telemetry-config.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + buildExporterUrl, + DEFAULT_OTLP_HTTP_PATH, + DEFAULT_SERVICE_NAME, + exporterHostForLog, + loadTelemetryConfig, + parseEnvFlag, + parseKeyValueList, +} from '@/lib/telemetry/config'; + +const TELEMETRY_ENV_KEYS = [ + 'OTEL_SDK_DISABLED', + 'WALLET_TELEMETRY_ENABLED', + 'WALLET_TELEMETRY_OTLP_ENDPOINT', + 'WALLET_TELEMETRY_OTLP_PATH', + 'WALLET_TELEMETRY_OTLP_HEADERS', + 'WALLET_TELEMETRY_SERVICE_NAME', + 'WALLET_TELEMETRY_RESOURCE_ATTRIBUTES', + 'OTEL_EXPORTER_OTLP_ENDPOINT', + 'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', + 'OTEL_EXPORTER_OTLP_HEADERS', + 'OTEL_SERVICE_NAME', + 'OTEL_RESOURCE_ATTRIBUTES', +] as const; + +function snapshotEnv(): Record { + const snap: Record = {}; + for (const key of TELEMETRY_ENV_KEYS) { + snap[key] = process.env[key]; + delete process.env[key]; + } + return snap; +} + +function restoreEnv(snap: Record): void { + for (const key of TELEMETRY_ENV_KEYS) { + const value = snap[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +describe('parseEnvFlag', () => { + it('parses common truthy and falsy strings', () => { + expect(parseEnvFlag('true')).toBe(true); + expect(parseEnvFlag('1')).toBe(true); + expect(parseEnvFlag('YES')).toBe(true); + expect(parseEnvFlag('on')).toBe(true); + expect(parseEnvFlag('false')).toBe(false); + expect(parseEnvFlag('0')).toBe(false); + expect(parseEnvFlag('OFF')).toBe(false); + expect(parseEnvFlag('no')).toBe(false); + }); + + it('returns undefined for empty or unknown values', () => { + expect(parseEnvFlag(undefined)).toBeUndefined(); + expect(parseEnvFlag('')).toBeUndefined(); + expect(parseEnvFlag('maybe')).toBeUndefined(); + }); +}); + +describe('parseKeyValueList', () => { + it('parses comma-separated pairs and keeps equals in values', () => { + expect( + parseKeyValueList('Authorization=Basic abc==,X-Foo=bar') + ).toEqual({ + Authorization: 'Basic abc==', + 'X-Foo': 'bar', + }); + }); + + it('skips empty and malformed pairs', () => { + expect(parseKeyValueList('')).toEqual({}); + expect(parseKeyValueList('=novalue,ok=yes,')).toEqual({ ok: 'yes' }); + }); +}); + +describe('buildExporterUrl', () => { + it('adds the default traces path and port when given host:port', () => { + expect(buildExporterUrl('jaeger:4318', '')).toBe( + `http://jaeger:4318${DEFAULT_OTLP_HTTP_PATH}` + ); + }); + + it('uses an explicit OpenObserve path', () => { + expect( + buildExporterUrl('http://10.188.1.50:5080', '/api/default/v1/traces') + ).toBe('http://10.188.1.50:5080/api/default/v1/traces'); + }); + + it('keeps a path already on the endpoint when no explicit path is set', () => { + expect( + buildExporterUrl('http://collector:5080/api/default/v1/traces', '') + ).toBe('http://collector:5080/api/default/v1/traces'); + }); + + it('prefers explicitPath over a path on the endpoint', () => { + expect( + buildExporterUrl('http://collector:5080/old', '/api/default/v1/traces') + ).toBe('http://collector:5080/api/default/v1/traces'); + }); + + it('defaults the port to 4318 when omitted', () => { + expect(buildExporterUrl('http://collector', '')).toBe( + 'http://collector:4318/v1/traces' + ); + }); + + it('returns empty string for an invalid URL', () => { + expect(buildExporterUrl('http://', '')).toBe(''); + }); +}); + +describe('loadTelemetryConfig', () => { + let snap: Record; + + afterEach(() => { + restoreEnv(snap); + }); + + it('returns null when no endpoint is configured', () => { + snap = snapshotEnv(); + expect(loadTelemetryConfig()).toBeNull(); + }); + + it('returns null when OTEL_SDK_DISABLED is true even with an endpoint', () => { + snap = snapshotEnv(); + process.env.WALLET_TELEMETRY_OTLP_ENDPOINT = 'http://collector:4318'; + process.env.OTEL_SDK_DISABLED = 'true'; + expect(loadTelemetryConfig()).toBeNull(); + }); + + it('returns null when WALLET_TELEMETRY_ENABLED is false', () => { + snap = snapshotEnv(); + process.env.WALLET_TELEMETRY_OTLP_ENDPOINT = 'http://collector:4318'; + process.env.WALLET_TELEMETRY_ENABLED = 'false'; + expect(loadTelemetryConfig()).toBeNull(); + }); + + it('enables when only an endpoint is set (default off without endpoint)', () => { + snap = snapshotEnv(); + process.env.WALLET_TELEMETRY_OTLP_ENDPOINT = 'http://10.188.1.50:5080'; + process.env.WALLET_TELEMETRY_OTLP_PATH = '/api/default/v1/traces'; + process.env.WALLET_TELEMETRY_OTLP_HEADERS = + 'Authorization=Basic dGVzdA=='; + process.env.WALLET_TELEMETRY_RESOURCE_ATTRIBUTES = + 'deployment.environment=dev'; + + expect(loadTelemetryConfig()).toEqual({ + serviceName: DEFAULT_SERVICE_NAME, + exporterUrl: 'http://10.188.1.50:5080/api/default/v1/traces', + headers: { Authorization: 'Basic dGVzdA==' }, + resourceAttributes: { 'deployment.environment': 'dev' }, + }); + }); + + it('prefers WALLET_TELEMETRY_* over standard OTEL_*', () => { + snap = snapshotEnv(); + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://otel:4318'; + process.env.OTEL_SERVICE_NAME = 'from-otel'; + process.env.WALLET_TELEMETRY_OTLP_ENDPOINT = 'http://wallet-collector:5080'; + process.env.WALLET_TELEMETRY_SERVICE_NAME = 'wallet-dev'; + + const config = loadTelemetryConfig(); + expect(config?.serviceName).toBe('wallet-dev'); + expect(config?.exporterUrl).toBe( + 'http://wallet-collector:5080/v1/traces' + ); + }); + + it('falls back to OTEL_EXPORTER_OTLP_TRACES_ENDPOINT including path', () => { + snap = snapshotEnv(); + process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = + 'http://oo:5080/api/default/v1/traces'; + const config = loadTelemetryConfig(); + expect(config?.exporterUrl).toBe( + 'http://oo:5080/api/default/v1/traces' + ); + }); +}); + +describe('exporterHostForLog', () => { + it('returns host and path without credentials', () => { + expect( + exporterHostForLog('http://10.188.1.50:5080/api/default/v1/traces') + ).toBe('10.188.1.50:5080/api/default/v1/traces'); + }); + + it('handles an invalid URL', () => { + expect(exporterHostForLog('not a url')).toBe('(invalid exporter url)'); + }); +}); diff --git a/tests/unit/telemetry-ignore.test.ts b/tests/unit/telemetry-ignore.test.ts new file mode 100644 index 00000000..30b71620 --- /dev/null +++ b/tests/unit/telemetry-ignore.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { + incomingPathname, + shouldIgnoreIncomingPath, + shouldIgnoreOutgoingFetch, + shouldIgnoreOutgoingHttp, + splitHostPort, +} from '@/lib/telemetry/ignore'; + +const EXPORTER = 'http://10.188.1.50:5080/api/default/v1/traces'; + +describe('shouldIgnoreIncomingPath', () => { + it('ignores health probes with or without query strings', () => { + expect(shouldIgnoreIncomingPath('/api/health')).toBe(true); + expect(shouldIgnoreIncomingPath('/api/health?fresh=1')).toBe(true); + expect(shouldIgnoreIncomingPath('/.well-known/healthcheck.json')).toBe(true); + }); + + it('does not ignore wallet API routes', () => { + expect(shouldIgnoreIncomingPath('/api/broadcast/transfer')).toBe(false); + expect(shouldIgnoreIncomingPath('/api/query/accounts')).toBe(false); + expect(shouldIgnoreIncomingPath('/api/healthz')).toBe(false); + }); + + it('accepts a full URL from some runtimes', () => { + expect(shouldIgnoreIncomingPath('http://127.0.0.1:8080/api/health')).toBe( + true + ); + }); + + it('treats missing url as not ignored', () => { + expect(shouldIgnoreIncomingPath(undefined)).toBe(false); + expect(incomingPathname(undefined)).toBe(''); + }); +}); + +describe('shouldIgnoreOutgoingHttp', () => { + it('ignores the OTLP exporter host/path/port', () => { + expect( + shouldIgnoreOutgoingHttp( + '10.188.1.50', + 5080, + '/api/default/v1/traces', + 'http:', + EXPORTER, + undefined + ) + ).toBe(true); + }); + + it('parses host:port when hostname is unset', () => { + expect( + shouldIgnoreOutgoingHttp( + undefined, + undefined, + '/api/default/v1/traces', + 'http:', + EXPORTER, + '10.188.1.50:5080' + ) + ).toBe(true); + }); + + it('does not ignore Steem RPC calls', () => { + expect( + shouldIgnoreOutgoingHttp( + 'api.steemitdev.com', + 443, + '/', + 'https:', + EXPORTER, + undefined + ) + ).toBe(false); + }); + + it('does not ignore a different port on the same host', () => { + expect( + shouldIgnoreOutgoingHttp( + '10.188.1.50', + 4318, + '/v1/traces', + 'http:', + EXPORTER, + undefined + ) + ).toBe(false); + }); +}); + +describe('shouldIgnoreOutgoingFetch', () => { + it('ignores undici origin+path aimed at the collector', () => { + expect( + shouldIgnoreOutgoingFetch( + 'http://10.188.1.50:5080', + '/api/default/v1/traces', + EXPORTER + ) + ).toBe(true); + }); + + it('does not ignore jussi / steemd fetch', () => { + expect( + shouldIgnoreOutgoingFetch( + 'https://api.steemitdev.com', + '/', + EXPORTER + ) + ).toBe(false); + }); +}); + +describe('splitHostPort', () => { + it('prefers hostname over host', () => { + expect(splitHostPort('example.com', 'ignored:9', 443)).toEqual({ + hostname: 'example.com', + port: 443, + }); + }); + + it('splits host:port when hostname is missing', () => { + expect(splitHostPort(undefined, '10.0.0.1:5080', undefined)).toEqual({ + hostname: '10.0.0.1', + port: '5080', + }); + }); +}); diff --git a/tests/unit/telemetry-register.test.ts b/tests/unit/telemetry-register.test.ts new file mode 100644 index 00000000..d4c8fa5e --- /dev/null +++ b/tests/unit/telemetry-register.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const registerMock = vi.fn(); +const shutdownMock = vi.fn().mockResolvedValue(undefined); +const setLoggerMock = vi.fn(); +const registerInstrumentationsMock = vi.fn(); +const resourceFromAttributesMock = vi.fn().mockReturnValue({ attributes: {} }); + +vi.mock('@opentelemetry/api', () => ({ + diag: { setLogger: (...args: unknown[]) => setLoggerMock(...args) }, + DiagLogLevel: { ERROR: 1 }, +})); + +vi.mock('@opentelemetry/context-async-hooks', () => ({ + AsyncLocalStorageContextManager: class {}, +})); + +vi.mock('@opentelemetry/core', () => ({ + CompositePropagator: class {}, + W3CBaggagePropagator: class {}, + W3CTraceContextPropagator: class {}, +})); + +vi.mock('@opentelemetry/exporter-trace-otlp-http', () => ({ + OTLPTraceExporter: class { + url: string; + constructor(opts: { url: string }) { + this.url = opts.url; + } + }, +})); + +vi.mock('@opentelemetry/instrumentation', () => ({ + registerInstrumentations: (...args: unknown[]) => + registerInstrumentationsMock(...args), +})); + +vi.mock('@opentelemetry/instrumentation-http', () => ({ + HttpInstrumentation: class { + config: unknown; + constructor(config: unknown) { + this.config = config; + } + }, +})); + +vi.mock('@opentelemetry/instrumentation-undici', () => ({ + UndiciInstrumentation: class { + config: unknown; + constructor(config: unknown) { + this.config = config; + } + }, +})); + +vi.mock('@opentelemetry/resources', () => ({ + resourceFromAttributes: (...args: unknown[]) => + resourceFromAttributesMock(...args), +})); + +vi.mock('@opentelemetry/sdk-trace-node', () => ({ + BatchSpanProcessor: class {}, + NodeTracerProvider: class { + constructor(public readonly config: unknown) {} + register(...args: unknown[]) { + registerMock(...args); + } + shutdown() { + return shutdownMock(); + } + }, +})); + +vi.mock('@opentelemetry/semantic-conventions', () => ({ + ATTR_SERVICE_NAME: 'service.name', +})); + +const TELEMETRY_ENV_KEYS = [ + 'OTEL_SDK_DISABLED', + 'WALLET_TELEMETRY_ENABLED', + 'WALLET_TELEMETRY_OTLP_ENDPOINT', + 'WALLET_TELEMETRY_OTLP_PATH', + 'WALLET_TELEMETRY_OTLP_HEADERS', + 'WALLET_TELEMETRY_SERVICE_NAME', + 'WALLET_TELEMETRY_RESOURCE_ATTRIBUTES', + 'OTEL_EXPORTER_OTLP_ENDPOINT', + 'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', + 'OTEL_EXPORTER_OTLP_HEADERS', + 'OTEL_SERVICE_NAME', + 'OTEL_RESOURCE_ATTRIBUTES', +] as const; + +describe('registerTelemetry', () => { + const original: Record = {}; + + beforeEach(() => { + vi.resetModules(); + registerMock.mockClear(); + registerInstrumentationsMock.mockClear(); + setLoggerMock.mockClear(); + for (const key of TELEMETRY_ENV_KEYS) { + original[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of TELEMETRY_ENV_KEYS) { + const value = original[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it('is a no-op when telemetry is not configured', async () => { + const { registerTelemetry } = await import('@/lib/telemetry/register'); + await expect(registerTelemetry()).resolves.toBe(false); + expect(registerMock).not.toHaveBeenCalled(); + expect(registerInstrumentationsMock).not.toHaveBeenCalled(); + }); + + it('registers the SDK when an OTLP endpoint is set', async () => { + process.env.WALLET_TELEMETRY_OTLP_ENDPOINT = 'http://10.188.1.50:5080'; + process.env.WALLET_TELEMETRY_OTLP_PATH = '/api/default/v1/traces'; + process.env.WALLET_TELEMETRY_SERVICE_NAME = 'wallet'; + + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + const { registerTelemetry } = await import('@/lib/telemetry/register'); + await expect(registerTelemetry()).resolves.toBe(true); + expect(registerInstrumentationsMock).toHaveBeenCalledOnce(); + expect(registerMock).toHaveBeenCalledOnce(); + expect(info).toHaveBeenCalled(); + info.mockRestore(); + }); + + it('does not initialize twice', async () => { + process.env.WALLET_TELEMETRY_OTLP_ENDPOINT = 'http://collector:4318'; + vi.spyOn(console, 'info').mockImplementation(() => {}); + const { registerTelemetry } = await import('@/lib/telemetry/register'); + await expect(registerTelemetry()).resolves.toBe(true); + await expect(registerTelemetry()).resolves.toBe(true); + expect(registerMock).toHaveBeenCalledOnce(); + }); + + it('continues without tracing when SDK construction throws', async () => { + process.env.WALLET_TELEMETRY_OTLP_ENDPOINT = 'http://collector:4318'; + registerInstrumentationsMock.mockImplementationOnce(() => { + throw new Error('boom'); + }); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { registerTelemetry } = await import('@/lib/telemetry/register'); + await expect(registerTelemetry()).resolves.toBe(false); + expect(error).toHaveBeenCalled(); + error.mockRestore(); + }); + + it('wires health-check and collector ignore hooks', async () => { + process.env.WALLET_TELEMETRY_OTLP_ENDPOINT = 'http://10.188.1.50:5080'; + process.env.WALLET_TELEMETRY_OTLP_PATH = '/api/default/v1/traces'; + vi.spyOn(console, 'info').mockImplementation(() => {}); + + const { registerTelemetry } = await import('@/lib/telemetry/register'); + await registerTelemetry(); + + const call = registerInstrumentationsMock.mock.calls[0]?.[0] as { + instrumentations: Array<{ config: Record }>; + }; + const httpConfig = call.instrumentations[0]?.config as { + ignoreIncomingRequestHook: (req: { url?: string }) => boolean; + ignoreOutgoingRequestHook: (opts: { + hostname?: string; + port?: number; + path?: string; + protocol?: string; + }) => boolean; + }; + const undiciConfig = call.instrumentations[1]?.config as { + ignoreRequestHook: (req: { origin: string; path: string }) => boolean; + }; + + expect(httpConfig.ignoreIncomingRequestHook({ url: '/api/health' })).toBe( + true + ); + expect( + httpConfig.ignoreIncomingRequestHook({ url: '/api/broadcast/transfer' }) + ).toBe(false); + expect( + httpConfig.ignoreOutgoingRequestHook({ + hostname: '10.188.1.50', + port: 5080, + path: '/api/default/v1/traces', + protocol: 'http:', + }) + ).toBe(true); + expect( + undiciConfig.ignoreRequestHook({ + origin: 'https://api.steemitdev.com', + path: '/', + }) + ).toBe(false); + }); + + it('suppresses repeated OTLP export errors', async () => { + process.env.WALLET_TELEMETRY_OTLP_ENDPOINT = 'http://collector:4318'; + vi.spyOn(console, 'info').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { registerTelemetry } = await import('@/lib/telemetry/register'); + await registerTelemetry(); + + const logger = setLoggerMock.mock.calls[0]?.[0] as { + error: (message: string) => void; + warn: (message: string) => void; + info: () => void; + }; + logger.error('OTLP exporter failed: connect ECONNREFUSED'); + logger.error('OTLP exporter failed: connect ECONNREFUSED'); + logger.warn('unrelated warning'); + logger.info(); + + const exportLogs = error.mock.calls.filter((c) => + String(c[0]).includes('further export errors suppressed') + ); + expect(exportLogs).toHaveLength(1); + expect(warn).toHaveBeenCalled(); + error.mockRestore(); + warn.mockRestore(); + }); +});