diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index b9760d31..908a1a8f 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -197,6 +197,9 @@ jobs: context: ${{ matrix.service == 'tracking' && './tracking' || '.' }} file: ${{ matrix.service == 'tracking' && './tracking/Dockerfile' || format('deploy/docker/{0}.Dockerfile', matrix.service) }} platforms: ${{ matrix.platform }} + build-args: | + VERSION=dev-${{ github.sha }} + COMMIT=${{ github.sha }} cache-from: type=gha,scope=${{ matrix.service }}-${{ steps.prep.outputs.pair }} cache-to: type=gha,mode=max,scope=${{ matrix.service }}-${{ steps.prep.outputs.pair }} outputs: type=image,name=${{ env.IMAGE_PREFIX }}/${{ matrix.service }},push-by-digest=true,name-canonical=true,push=true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38692006..b8a7cd2f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,6 +106,16 @@ jobs: context: ./${{ matrix.service }} file: ./${{ matrix.service }}/Dockerfile push: true + build-args: | + VERSION=${{ github.ref_name }} + COMMIT=${{ github.sha }} + SENTRY_ORG=${{ vars.SENTRY_ORG }} + SENTRY_PROJECT=${{ matrix.service == 'admin' && vars.SENTRY_PROJECT_ADMIN || vars.SENTRY_PROJECT_WEB }} + # Source maps are uploaded only when all three are configured on this + # repository. A fork has none of them, the plugin is not in the build, + # and the image is byte-identical to one built with no Sentry account. + secrets: | + sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }} tags: | ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:${{ github.ref_name }} ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.minor }} @@ -157,6 +167,9 @@ jobs: context: ${{ matrix.service == 'tracking' && './tracking' || '.' }} file: ${{ matrix.service == 'tracking' && './tracking/Dockerfile' || format('deploy/docker/{0}.Dockerfile', matrix.service) }} platforms: ${{ matrix.platform }} + build-args: | + VERSION=${{ github.ref_name }} + COMMIT=${{ github.sha }} cache-from: type=gha,scope=${{ matrix.service }}-${{ steps.prep.outputs.pair }} cache-to: type=gha,mode=max,scope=${{ matrix.service }}-${{ steps.prep.outputs.pair }} outputs: type=image,name=${{ env.IMAGE_PREFIX }}/${{ matrix.service }},push-by-digest=true,name-canonical=true,push=true diff --git a/.gitignore b/.gitignore index 60c36b64..96d6b73c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,6 @@ /worker /backend /seed -/forms /migrate /updater /warmblyctl diff --git a/admin/Dockerfile b/admin/Dockerfile index 318d61ea..501b72f0 100644 --- a/admin/Dockerfile +++ b/admin/Dockerfile @@ -11,9 +11,25 @@ RUN corepack enable && corepack prepare pnpm@11.9.0 --activate COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile COPY . . -# No VITE_* is baked here on purpose: URLs come from the runtime config below, -# so this one image works for any deployment. -RUN pnpm build +# The build identity, the same VERSION/COMMIT the Go images take. This is the +# one VITE_* that must be baked: it tags every error event with the build, and +# it has to match the release the source maps were uploaded under, which a +# container variable set after the bundle was built could not. Every other +# setting comes from the runtime config below, so one image still serves any +# deployment. +ARG VERSION="" +ARG COMMIT="" +# Source-map upload is optional and off unless CI passes all three. A fork or a +# self-host build sets none of them, needs no Sentry account, and ships no +# source maps. +ARG SENTRY_ORG="" +ARG SENTRY_PROJECT="" +RUN --mount=type=secret,id=sentry_auth_token,required=false \ + VITE_SENTRY_RELEASE="${VERSION:-$COMMIT}" \ + SENTRY_ORG="$SENTRY_ORG" \ + SENTRY_PROJECT="$SENTRY_PROJECT" \ + SENTRY_AUTH_TOKEN="$(cat /run/secrets/sentry_auth_token 2>/dev/null || true)" \ + pnpm build # Stage 2: serve the built SPA from nginx with a history fallback. The # entrypoint renders /config.js from container env at startup. diff --git a/admin/docker-entrypoint.sh b/admin/docker-entrypoint.sh index d600e368..dd0c3a7a 100644 --- a/admin/docker-entrypoint.sh +++ b/admin/docker-entrypoint.sh @@ -3,12 +3,22 @@ # any deployment. Runs before nginx starts (nginx /docker-entrypoint.d hook). set -eu +# Values are written into JavaScript string literals, so a double quote, a +# backslash or a line break in one would end the literal early and take the +# whole config with it, leaving the app with no API_URL at all. Escape rather +# than trust whatever ended up in .env. +js() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\r\n' +} + cat > /usr/share/nginx/html/config.js <=18'} + + '@sentry/browser@10.73.0': + resolution: {integrity: sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==} + engines: {node: '>=18'} + + '@sentry/bundler-plugins@10.73.0': + resolution: {integrity: sha512-4X5m2hoqgKO6SxHR7MF9QnvxPNk0hwTJFixDJ/pzQGVM+C34j/rSabouBqd0M+ZdxFeAt/9kA5X0TyeceNo9YQ==} + engines: {node: '>= 18'} + peerDependencies: + rollup: '>=3.2.0' + webpack: '>=5.0.0' + peerDependenciesMeta: + rollup: + optional: true + webpack: + optional: true + + '@sentry/cli-darwin@2.58.6': + resolution: {integrity: sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==} + engines: {node: '>=10'} + os: [darwin] + + '@sentry/cli-linux-arm64@2.58.6': + resolution: {integrity: sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux, freebsd, android] + + '@sentry/cli-linux-arm@2.58.6': + resolution: {integrity: sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux, freebsd, android] + + '@sentry/cli-linux-i686@2.58.6': + resolution: {integrity: sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [linux, freebsd, android] + + '@sentry/cli-linux-x64@2.58.6': + resolution: {integrity: sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux, freebsd, android] + + '@sentry/cli-win32-arm64@2.58.6': + resolution: {integrity: sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@sentry/cli-win32-i686@2.58.6': + resolution: {integrity: sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [win32] + + '@sentry/cli-win32-x64@2.58.6': + resolution: {integrity: sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@sentry/cli@2.58.6': + resolution: {integrity: sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==} + engines: {node: '>= 10'} + hasBin: true + + '@sentry/conventions@0.16.0': + resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==} + engines: {node: '>=14'} + + '@sentry/core@10.73.0': + resolution: {integrity: sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==} + engines: {node: '>=18'} + + '@sentry/feedback@10.73.0': + resolution: {integrity: sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==} + engines: {node: '>=18'} + + '@sentry/react@10.73.0': + resolution: {integrity: sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.14.0 || 17.x || 18.x || 19.x + + '@sentry/replay-canvas@10.73.0': + resolution: {integrity: sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==} + engines: {node: '>=18'} + + '@sentry/replay@10.73.0': + resolution: {integrity: sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==} + engines: {node: '>=18'} + + '@sentry/vite-plugin@5.4.0': + resolution: {integrity: sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA==} + engines: {node: '>= 18'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1642,6 +1750,10 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1842,6 +1954,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2094,6 +2210,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + motion-dom@12.40.0: resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} @@ -2125,6 +2245,15 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-releases@2.0.46: resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} engines: {node: '>=18'} @@ -2160,6 +2289,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2178,6 +2311,13 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -2371,6 +2511,9 @@ packages: resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} @@ -2525,6 +2668,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -2541,6 +2687,9 @@ packages: resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} engines: {node: '>=20'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3521,6 +3670,115 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true + '@sentry/browser-utils@10.73.0': + dependencies: + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.73.0 + + '@sentry/browser@10.73.0': + dependencies: + '@sentry/browser-utils': 10.73.0 + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.73.0 + '@sentry/feedback': 10.73.0 + '@sentry/replay': 10.73.0 + '@sentry/replay-canvas': 10.73.0 + + '@sentry/bundler-plugins@10.73.0(rollup@4.60.4)': + dependencies: + '@babel/core': 7.29.7 + '@sentry/cli': 2.58.6 + '@sentry/core': 10.73.0 + dotenv: 17.4.2 + find-up: 5.0.0 + glob: 13.0.6 + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.60.4 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/cli-darwin@2.58.6': + optional: true + + '@sentry/cli-linux-arm64@2.58.6': + optional: true + + '@sentry/cli-linux-arm@2.58.6': + optional: true + + '@sentry/cli-linux-i686@2.58.6': + optional: true + + '@sentry/cli-linux-x64@2.58.6': + optional: true + + '@sentry/cli-win32-arm64@2.58.6': + optional: true + + '@sentry/cli-win32-i686@2.58.6': + optional: true + + '@sentry/cli-win32-x64@2.58.6': + optional: true + + '@sentry/cli@2.58.6': + dependencies: + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + progress: 2.0.3 + proxy-from-env: 1.1.0 + which: 2.0.2 + optionalDependencies: + '@sentry/cli-darwin': 2.58.6 + '@sentry/cli-linux-arm': 2.58.6 + '@sentry/cli-linux-arm64': 2.58.6 + '@sentry/cli-linux-i686': 2.58.6 + '@sentry/cli-linux-x64': 2.58.6 + '@sentry/cli-win32-arm64': 2.58.6 + '@sentry/cli-win32-i686': 2.58.6 + '@sentry/cli-win32-x64': 2.58.6 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/conventions@0.16.0': {} + + '@sentry/core@10.73.0': + dependencies: + '@sentry/conventions': 0.16.0 + + '@sentry/feedback@10.73.0': + dependencies: + '@sentry/core': 10.73.0 + + '@sentry/react@10.73.0(react@19.2.6)': + dependencies: + '@sentry/browser': 10.73.0 + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.73.0 + react: 19.2.6 + + '@sentry/replay-canvas@10.73.0': + dependencies: + '@sentry/core': 10.73.0 + '@sentry/replay': 10.73.0 + + '@sentry/replay@10.73.0': + dependencies: + '@sentry/browser-utils': 10.73.0 + '@sentry/core': 10.73.0 + + '@sentry/vite-plugin@5.4.0(rollup@4.60.4)': + dependencies: + '@sentry/bundler-plugins': 10.73.0(rollup@4.60.4) + transitivePeerDependencies: + - encoding + - rollup + - supports-color + - webpack + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -3949,6 +4207,8 @@ snapshots: detect-node-es@1.1.0: {} + dotenv@17.4.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4174,6 +4434,12 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + globals@14.0.0: {} globals@16.5.0: {} @@ -4393,6 +4659,8 @@ snapshots: dependencies: brace-expansion: 1.1.15 + minipass@7.1.3: {} + motion-dom@12.40.0: dependencies: motion-utils: 12.39.0 @@ -4413,6 +4681,10 @@ snapshots: natural-compare@1.4.0: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-releases@2.0.46: {} obug@2.1.4: {} @@ -4446,6 +4718,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -4460,6 +4737,10 @@ snapshots: prelude-ls@1.2.1: {} + progress@2.0.3: {} + + proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} punycode@2.3.1: {} @@ -4631,6 +4912,8 @@ snapshots: dependencies: tldts: 7.4.11 + tr46@0.0.3: {} + tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -4737,6 +5020,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} whatwg-mimetype@4.0.0: {} @@ -4748,6 +5033,11 @@ snapshots: tr46: 6.0.0 webidl-conversions: 8.0.1 + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/admin/pnpm-workspace.yaml b/admin/pnpm-workspace.yaml index c87da7e8..ccce316b 100644 --- a/admin/pnpm-workspace.yaml +++ b/admin/pnpm-workspace.yaml @@ -4,6 +4,10 @@ # refuse to run them. esbuild is pulled in transitively by vite and # needs to compile its native binary at install time. allowBuilds: + # The Sentry Vite plugin shells out to @sentry/cli, so its install script + # (which fetches the binary) has to run or the optional source-map upload + # has nothing to invoke. + '@sentry/cli': true esbuild: true # Match the dashboard's overrides so transitive deps don't drift. diff --git a/admin/src/lib/env.ts b/admin/src/lib/env.ts index bece8fcb..2a005346 100644 --- a/admin/src/lib/env.ts +++ b/admin/src/lib/env.ts @@ -11,6 +11,15 @@ export const DASHBOARD_URL: string = runtimeEnv("DASHBOARD_URL", import.meta.env export const TURNSTILE_KEY: string = runtimeEnv("TURNSTILE_KEY", import.meta.env.VITE_TURNSTILE_KEY); +// Browser error reporting. The admin panel ships to self-hosters like every +// other image, so the DSN is the operator's and an empty one means the SDK is +// never initialised. See lib/observability. +export const SENTRY_DSN: string = runtimeEnv("SENTRY_DSN", import.meta.env.VITE_SENTRY_DSN); +export const SENTRY_ENVIRONMENT: string = runtimeEnv("SENTRY_ENVIRONMENT", import.meta.env.VITE_SENTRY_ENVIRONMENT, import.meta.env.MODE); +// Build-time on purpose: it has to match the release the source maps were +// uploaded under, which a container variable set afterwards could not. +export const SENTRY_RELEASE: string = import.meta.env.VITE_SENTRY_RELEASE ?? ""; + export type EnvLabel = "production" | "staging" | "development"; const RAW_ENV_LABEL = runtimeEnv("ENV_LABEL", import.meta.env.VITE_ENV_LABEL as string | undefined).toLowerCase(); diff --git a/admin/src/lib/observability.ts b/admin/src/lib/observability.ts new file mode 100644 index 00000000..207fbd95 --- /dev/null +++ b/admin/src/lib/observability.ts @@ -0,0 +1,33 @@ +// Browser error reporting for the operator panel. +// +// Same rule as the dashboard: the admin image ships to self-hosters too, so a +// literal DSN here would make every self-hosted panel report its operator's +// errors and URLs to somebody else. The DSN comes from the container-injected +// runtime config, and an unset DSN means the SDK is never initialised, so +// nothing is ever sent anywhere. +import * as Sentry from "@sentry/react"; +import { SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "./env"; + +let reporting = false; + +// initErrorReporting is called once, before the app renders. +export function initErrorReporting(): void { + if (!SENTRY_DSN) return; + + Sentry.init({ + dsn: SENTRY_DSN, + sendDefaultPii: true, + environment: SENTRY_ENVIRONMENT, + // Empty is omitted rather than sent: an event tagged with the empty + // release matches no uploaded source map and reads as a real release. + release: SENTRY_RELEASE || undefined, + }); + reporting = true; +} + +// captureException reports an error the app handled itself. A no-op when no DSN +// is configured. +export function captureException(error: unknown): void { + if (!reporting) return; + Sentry.captureException(error); +} diff --git a/admin/src/main.tsx b/admin/src/main.tsx index 6f55c85a..bbd0458b 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -15,6 +15,8 @@ import "@fontsource/inter/600.css"; import "@fontsource/poppins/600.css"; import "@fontsource/poppins/700.css"; +import { initErrorReporting } from "@/lib/observability"; + import { Toaster } from "@/components/ui/sonner"; import { AppShell } from "@/components/layout/AppShell"; import { RequireAdmin } from "@/components/layout/RequireAdmin"; @@ -209,6 +211,9 @@ function AppShellWithKey() { // we need a passthrough. export { Outlet }; +// Before the first render, so a boot failure is reported too. +initErrorReporting(); + createRoot(document.getElementById("root")!).render( diff --git a/admin/vite.config.ts b/admin/vite.config.ts index c18b0661..51be4160 100644 --- a/admin/vite.config.ts +++ b/admin/vite.config.ts @@ -2,12 +2,47 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import path from "path"; import tailwindcss from "@tailwindcss/vite"; +import { sentryVitePlugin } from "@sentry/vite-plugin"; + +// Source-map upload, and nothing else, is what the Sentry plugin does here. +// +// It is never a required build step: a fork, a self-host build or a local +// `pnpm build` sets none of these and the plugin is simply not in the plugin +// list, so nothing needs a Sentry account and nothing is uploaded. CI passes +// the token as a build secret only for the hosted release. +// +// Source maps are emitted only in that case too, so the shipped bundle is +// unchanged for everybody else, and `filesToDeleteAfterUpload` keeps the .map +// files out of the image once they have been sent. +const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN; +const sentryOrg = process.env.SENTRY_ORG; +const sentryProject = process.env.SENTRY_PROJECT; +const uploadSourceMaps = Boolean(sentryAuthToken && sentryOrg && sentryProject); + +const sentryPlugins = uploadSourceMaps + ? [ + sentryVitePlugin({ + authToken: sentryAuthToken, + org: sentryOrg, + project: sentryProject, + release: { name: process.env.VITE_SENTRY_RELEASE }, + sourcemaps: { filesToDeleteAfterUpload: ["dist/**/*.map"] }, + telemetry: false, + }), + ] + : []; export default defineConfig({ plugins: [ react(), tailwindcss(), + ...sentryPlugins, ], + build: { + // Only when they are going to be uploaded: shipping them otherwise + // would hand every visitor the dashboard's original sources. + sourcemap: uploadSourceMaps, + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 3bbc594a..1127d76a 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -17,7 +17,6 @@ import ( "github.com/MicahParks/keyfunc/v3" "github.com/aws/aws-sdk-go-v2/aws" awsconf "github.com/aws/aws-sdk-go-v2/config" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/meszmate/apple-go" "github.com/warmbly/warmbly/internal/api" @@ -27,6 +26,7 @@ import ( "github.com/warmbly/warmbly/internal/app/adminoutreach" "github.com/warmbly/warmbly/internal/app/advanced" "github.com/warmbly/warmbly/internal/app/unsublink" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/jackc/pgx/v5/pgxpool" "github.com/warmbly/warmbly/internal/app/advisor" @@ -328,7 +328,7 @@ func main() { if config.TasksProvider() == "gcloud" { serviceAccount, err = cfg.LoadGoogleServiceAccount(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } @@ -337,7 +337,7 @@ func main() { if cfg.Env == "dev" { log.Printf("Warning: Failed to fetch Google OIDC keys: %v", err) } else { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } } @@ -345,7 +345,7 @@ func main() { apiCfg, err := cfg.LoadApiConfig(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } @@ -357,7 +357,7 @@ func main() { if config.AWSNeeded() { awscfg, err = awsconf.LoadDefaultConfig(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } } @@ -369,13 +369,13 @@ func main() { kms, err := kms.FromEnv(ctx, awscfg, masterKey) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } geoPath, err := cfg.LoadGeoDBPath(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } @@ -394,40 +394,40 @@ func main() { s3, err := storage.NewFromEnv(ctx, awscfg, "main") if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } s3ForHandler = s3 primaryDBEndpoint, err := cfg.LoadPrimaryDBEndpoint(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } primaryDB, err := db.New(ctx, primaryDBEndpoint) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } // Run database migrations log.Println("Running database migrations...") if err := db.RunMigrations(primaryDBEndpoint); err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal("Failed to run migrations: ", err) } log.Println("Database migrations completed") primaryRedis, err := cfg.LoadPrimaryRedisEndpoint(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } cache, err := cache.New(primaryRedis) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } @@ -444,13 +444,13 @@ func main() { } pubsubClient, err := pubsub.NewClient(ctx, gcpProjectID) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal("Failed to initialize Pub/Sub client: ", err) } // Create the realtime topics + "-sub" subscriptions if missing, // so the Elixir Broadway consumers always have a subscription to read. if err := pubsubClient.EnsureRealtimeTopology(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal("Failed to provision Pub/Sub topics/subscriptions: ", err) } streamingPublisher = pubsub.NewStreamingPublisher(pubsubClient) @@ -463,7 +463,7 @@ func main() { emailCfg, err := cfg.LoadEmailConfig(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } @@ -478,7 +478,7 @@ func main() { mailTransport, err = notify.NewTransport(ctx, cfg, emailCfg.EmailName, emailCfg.EmailAddress) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } emailNotificationService = mailTransport @@ -497,7 +497,7 @@ func main() { authCfg, err := cfg.LoadAuthConfig(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } @@ -529,25 +529,25 @@ func main() { if config.EventBusProvider() == "kafka" { kafkaBootstrapServers, err = cfg.LoadKafkaBootstrapServers(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } kafkaSaslConfig, err = cfg.LoadKafkaConfigSasl(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } } codecImpl, err := codec.FromEnv() if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } bus, err := eventbus.FromEnv(kafkaBootstrapServers, kafkaSaslConfig) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } @@ -580,7 +580,7 @@ func main() { webauthnRepository := repository.NewWebAuthnRepository(primaryDB) credEncrypter, cerr := encrypt.FromEnv() if cerr != nil { - sentry.CaptureException(cerr) + errs.CaptureFatal(cerr) log.Fatal("Invalid CREDENTIALS_ENCRYPTION_KEY: ", cerr) } emailRepostory := repository.NewEmailRepostory(primaryDB, credEncrypter) @@ -599,7 +599,7 @@ func main() { instanceSettings = instancesettings.NewService(instancesettings.NewStore(primaryDB.Pool)) bootstrapInstanceSettings(ctx, instanceSettings) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } @@ -754,7 +754,7 @@ func main() { if config.BillingProvider() == "stripe" { stripeCfg, err := cfg.LoadStripeConfig(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } stripeService = stripe.NewService(stripeCfg, subscriptionRepository, planRepository, workerAssignmentService, discountService) @@ -961,7 +961,7 @@ func main() { RPOrigins: authCfg.WebAuthnRPOrigins, }) if passkeyErr != nil { - sentry.CaptureException(passkeyErr) + errs.CaptureFatal(passkeyErr) log.Fatal(passkeyErr) } passkeysUsable = passkeysUsableFor(os.Getenv("APP_URL")) @@ -979,7 +979,7 @@ func main() { cache, ) if berr := bootstrapService.Run(ctx); berr != nil { - sentry.CaptureException(berr) + errs.CaptureException(berr) log.Printf("Warning: bootstrap failed: %v", berr) } @@ -1008,7 +1008,7 @@ func main() { {Kind: "blob", Provider: s3.Name(), Display: s3.Name(), ReadOnly: true}, {Kind: "eventbus", Provider: "kafka", Display: "kafka", ReadOnly: true}, }); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) log.Printf("storage_backends registrar: %v", err) } @@ -1313,12 +1313,12 @@ func main() { if config.TasksProvider() == "gcloud" { cloudTasksCfg, err := cfg.LoadCloudTasksConfig(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } gclient, err := gtasks.NewClient(ctx, cloudTasksCfg.QueueName, cloudTasksCfg.WebhookURL, serviceAccount, cloudTasksCfg.EmulatorHost) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } tasksClient = gclient @@ -2111,7 +2111,7 @@ func main() { AppEnv: os.Getenv("APP_ENV"), } - sentry.CaptureMessage("Starting the backend on " + addr) + errs.CaptureMessage("Starting the backend on " + addr) router := api.Run(h, m, oidcH, addr, ginMode, allowedOrigins) diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index 88d6398f..9b8dfff8 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -15,7 +15,6 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" awsconf "github.com/aws/aws-sdk-go-v2/config" - "github.com/getsentry/sentry-go" "github.com/warmbly/warmbly/internal/app/advanced" "github.com/warmbly/warmbly/internal/app/cipher" jobs "github.com/warmbly/warmbly/internal/app/consumer" @@ -48,6 +47,7 @@ import ( "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/notify" "github.com/warmbly/warmbly/internal/observability" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/encrypt" "github.com/warmbly/warmbly/internal/pkg/generation" "github.com/warmbly/warmbly/internal/pkg/geo" @@ -167,14 +167,14 @@ func main() { } pubsubClient, err := pubsub.NewClient(ctx, gcpProjectID) if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal(err) } defer pubsubClient.Close() // Idempotently ensure the realtime topics + subscriptions exist (safe to // run from both backend and consumer; AlreadyExists is treated as success). if err := pubsubClient.EnsureRealtimeTopology(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal("Failed to provision Pub/Sub topics/subscriptions: ", err) } streamingPublisher = pubsub.NewStreamingPublisher(pubsubClient) @@ -187,7 +187,7 @@ func main() { // Repositories credEncrypter, err := encrypt.FromEnv() if err != nil { - sentry.CaptureException(err) + errs.CaptureFatal(err) log.Fatal("Invalid CREDENTIALS_ENCRYPTION_KEY: ", err) } emailRepo := repository.NewEmailRepostory(primaryDB, credEncrypter) diff --git a/cmd/forms/main.go b/cmd/forms/main.go index b73184b8..693269aa 100644 --- a/cmd/forms/main.go +++ b/cmd/forms/main.go @@ -11,11 +11,22 @@ import ( "os" "strconv" "strings" + "time" "github.com/warmbly/warmbly/internal/formserver" + "github.com/warmbly/warmbly/internal/observability" + "github.com/warmbly/warmbly/internal/observability/errs" ) func main() { + // Error reporting, before anything that can fail. Optional here as + // everywhere: no SENTRY_DSN means nothing is initialised and nothing is + // sent. A failure to configure it must not stop the service serving forms. + if err := observability.InitSentryEnv("forms"); err != nil { + log.Printf("error reporting not configured: %v", err) + } + defer errs.Flush(2 * time.Second) + backendURL := strings.TrimSpace(os.Getenv("BACKEND_INTERNAL_URL")) if backendURL == "" { log.Fatal("BACKEND_INTERNAL_URL is required (the backend's internal API base, e.g. http://localhost:8080)") @@ -46,6 +57,13 @@ func main() { InternalToken: token, StaticDir: staticDir, SubmitLimit: submitLimit, + // The browser half of error reporting, separate from this process's + // own SENTRY_DSN: form pages are public and their errors belong in a + // frontend project, not the service's. Empty means the page loads no + // reporting SDK, which is the self-host default. + BrowserSentryDSN: strings.TrimSpace(os.Getenv("WARMBLY_SENTRY_DSN")), + Release: observability.Release(), + Environment: appEnv(), }) if err != nil { log.Fatal(err) @@ -62,10 +80,23 @@ func main() { } log.Printf("forms service listening on :%s (backend %s)", port, backendURL) if err := r.Run(":" + port); err != nil { + // The listener dying is the one failure here worth reporting: the + // boot-time checks above are operator configuration, not a bug. + errs.CaptureException(err) + errs.Flush(2 * time.Second) log.Fatal(err) } } +// appEnv is the deployment label, matching what InitSentryEnv reports for this +// process so the browser and the server halves agree. +func appEnv() string { + if env := strings.TrimSpace(os.Getenv("APP_ENV")); env != "" { + return env + } + return "dev" +} + func splitCSV(v string) []string { var out []string for _, part := range strings.Split(v, ",") { diff --git a/deploy/docker/realtime.Dockerfile b/deploy/docker/realtime.Dockerfile index 4b563449..aa1bc314 100644 --- a/deploy/docker/realtime.Dockerfile +++ b/deploy/docker/realtime.Dockerfile @@ -40,6 +40,12 @@ EXPOSE 4000 ENV PHX_SERVER=true +# Build identity, the same VERSION/COMMIT the Go images take. Read at runtime +# by config/runtime.exs and used only to tag error events. +ARG VERSION="" +ARG COMMIT="" +ENV WARMBLY_RELEASE=${VERSION:-${COMMIT}} + # 127.0.0.1, not localhost: busybox wget tries ::1 first but the server binds IPv4. HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:4000/health || exit 1 diff --git a/docker-compose.yml b/docker-compose.yml index f5007fc2..e09e763c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -406,6 +406,12 @@ services: # container via host.docker.internal, mirroring the tracking service. BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL:-http://backend:8080} INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-local-dev-internal-token} + APP_ENV: ${APP_ENV:-dev} + # Two DSNs, two audiences: SENTRY_DSN is this Go process, and + # WARMBLY_SENTRY_DSN is stamped into the public form page for the browser + # app. Both unset means neither reports and no host is contacted. + SENTRY_DSN: ${SENTRY_DSN:-} + WARMBLY_SENTRY_DSN: ${WARMBLY_SENTRY_DSN:-} extra_hosts: - "host.docker.internal:host-gateway" depends_on: @@ -533,6 +539,9 @@ services: # Behind a reverse proxy set CHECK_ORIGIN=true once PHX_HOST matches the # public websocket hostname. CHECK_ORIGIN: ${CHECK_ORIGIN:-false} + # The environment label events are tagged with, matching every other + # service in this file. + APP_ENV: ${APP_ENV:-dev} # SENTRY_DSN deliberately arrives via env_file, not the mapping above: the # Elixir Sentry library reads the variable itself and refuses to start on an # empty string, so it has to be genuinely absent rather than "". @@ -579,6 +588,9 @@ services: WARMBLY_DASHBOARD_URL: ${APP_URL:-http://${PUBLIC_HOST:-localhost}:5173} WARMBLY_ENV_LABEL: ${ENV_LABEL:-development} WARMBLY_TURNSTILE_KEY: ${WARMBLY_TURNSTILE_KEY:-1x00000000000000000000AA} + # Unset means the panel initialises no error reporting and contacts no + # Sentry host, exactly like the dashboard above. + WARMBLY_SENTRY_DSN: ${WARMBLY_SENTRY_DSN:-} depends_on: backend: { condition: service_healthy } diff --git a/docs/content/docs/development/configuration.mdx b/docs/content/docs/development/configuration.mdx index 0b8d4841..cf04bdb3 100644 --- a/docs/content/docs/development/configuration.mdx +++ b/docs/content/docs/development/configuration.mdx @@ -377,14 +377,32 @@ Delayed sends run through the local poller, so the backend must be running for s ## Observability +### Error reporting + +Every runtime in Warmbly can report its errors, and none of them do unless you say where. A DSN is the operator's choice, not a requirement of the software, so the default install reports nowhere and contacts no host. Each variable is read by one process, so pointing the backend at a project does not make the dashboard report too. + +| Variable | Read by | What it does | Default | +|---|---|---|---| +| `SENTRY_DSN` | backend, consumer, worker | Server-side error reporting. Optional in every environment, including `prod` | unset | +| `SENTRY_DSN` | forms service | The forms service's own errors. It reads its own environment, like tracking | unset | +| `SENTRY_DSN` | tracking service | Rust errors and panics. An invalid DSN disables reporting with a log line rather than stopping the service | unset | +| `SENTRY_DSN` | realtime service | An empty string is treated as unset on purpose, because the library rejects `""` hard enough to take the node down | unset | +| `WARMBLY_SENTRY_DSN` | web, admin containers | Browser error reporting, read at container start like the other `WARMBLY_*` values. Unset means the SDK is never initialised and no host is contacted | unset | +| `WARMBLY_SENTRY_DSN` | forms service | Stamped into the public form page for the form app's browser errors, along with the service's `APP_ENV` as their environment. Separate from the forms service's own `SENTRY_DSN`: one is a Go process, the other is a page a stranger loads | unset | +| `WARMBLY_SENTRY_ENVIRONMENT` | web, admin containers | The environment label those browser events carry. Defaults to the build mode. Form pages do not read it: the forms service stamps its own `APP_ENV` into the page instead | unset | +| `APP_ENV` | every server-side service | Doubles as the environment label on the events that service reports. Browser events take theirs from `WARMBLY_SENTRY_ENVIRONMENT` instead, because the container serving the bundle is not the process reporting the error | `dev` | +| `WARMBLY_RELEASE` | tracking, realtime | The build events are tagged with. The published images set it from the release tag; the Go services and the frontends read the same value from their build stamp instead | `dev` | + +An unset DSN means nothing leaves the process and no Sentry host is contacted. The dashboard, the admin panel and form pages go further and never load the SDK at all, so there is not even a script; the Go, Rust and Elixir services still record errors, to their own logs. A set DSN can point at Sentry Cloud, a self-hosted Sentry, or any Sentry-compatible server such as GlitchTip; nothing in Warmbly assumes sentry.io. + +The `admin` panel and the dashboard also tag events with the build they were served from. That value is baked at image build time, because it has to match the release the source maps were uploaded under, so it cannot be changed by a container variable afterwards. + +### Push + | Variable | What it does | Default | |---|---|---| -| `SENTRY_DSN` | Error reporting. Optional in every environment, including `prod` | unset | -| `WARMBLY_SENTRY_DSN` | Browser error reporting for the dashboard container, read at container start like the other `WARMBLY_*` values. Unset means the dashboard initialises no reporting SDK and contacts no Sentry host | unset | | `APNS_KEY` or `APNS_KEY_PATH`, `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_TOPIC` | Mobile push on backend and consumer. Partial configuration disables push with a warning, never a crash | unset | -Error reporting is off by default everywhere. A DSN is the operator's choice, and each of these is read by one process, so pointing the backend at a project does not make the dashboard report too. Both accept Sentry Cloud, a self-hosted Sentry or any Sentry-compatible server. - ## Updates | Variable | What it does | Default | Restart needed | @@ -410,6 +428,8 @@ The public face of hosted forms (`cmd/forms`): it serves the React (TanStack) fo | `BACKEND_INTERNAL_URL` | Where the service resolves forms and forwards submissions, the same variable the tracking service uses. **Required**: the service exits at boot without it | none | | `INTERNAL_API_TOKEN` | Bearer token for those calls, matching the backend's. **Required**: the service exits at boot on an empty value | none | | `FORM_IP_RATE_LIMIT` | Public form submissions allowed per source IP per 10 minutes, per forms-service instance | `30` | +| `SENTRY_DSN` | The service's own error reporting. Unset means none | unset | +| `WARMBLY_SENTRY_DSN` | Stamped into the form page so the browser app reports too. Unset means the page loads no reporting SDK at all | unset | | `TRUSTED_PROXIES` | CIDRs whose `X-Forwarded-For` the service believes, same convention as the backend. Empty trusts nothing and uses the socket peer; set it behind a reverse proxy or the submit limiter throttles the proxy's address instead of the visitor's | empty | ## Tracking service @@ -432,7 +452,9 @@ The Rust open and click service. It reads its own environment, so these have to | `KAFKA_BOOTSTRAP_SERVERS`, `KAFKA_SASL_USERNAME`, `KAFKA_SASL_PASSWORD` | Broker transport when `EVENTBUS_PROVIDER=kafka` | unset | | `SCHEMA_REGISTRY_URL`, `SCHEMA_REGISTRY_KEY`, `SCHEMA_REGISTRY_SECRET` | Registry for the Avro codec | unset | | `AWS_CONFIG_ENABLED` | `true` falls back to AWS SSM and Secrets Manager for any value missing from the environment | `false` | -| `APP_ENV` | Environment label used in logs | `dev` | +| `APP_ENV` | Environment label used in logs, and on reported errors | `dev` | +| `SENTRY_DSN` | Error and panic reporting. Unset means none; an invalid value logs and disables it rather than stopping the service | unset | +| `WARMBLY_RELEASE` | The build reported errors are tagged with | `dev` | ## Realtime service @@ -457,6 +479,7 @@ The Elixir websocket service. Its runtime configuration is read only when the re | `RATE_LIMIT_WS_JOIN` | Channel joins per minute, counted per `phx_join` on an open socket | `30` | | `RATE_LIMIT_WS_EVENT` | Client events per minute, which is what bounds presence updates | `60` | | `SENTRY_DSN` | Error reporting. An empty string is treated as unset on purpose, because the library rejects `""` hard enough to take the node down | unset | +| `WARMBLY_RELEASE` | The build reported errors are tagged with | `dev` | The shipped default accepts a websocket upgrade from **any** origin. A token is still required to join a channel, so an attacker needs a valid JWT either way, but on a deployment reachable from the internet set `PHX_HOST` to the public websocket hostname and `CHECK_ORIGIN=true` so only your own dashboard can open a socket. diff --git a/docs/content/docs/development/data-control.mdx b/docs/content/docs/development/data-control.mdx index 7d1811eb..e58c62a0 100644 --- a/docs/content/docs/development/data-control.mdx +++ b/docs/content/docs/development/data-control.mdx @@ -213,10 +213,15 @@ An instance reports errors nowhere unless you point it somewhere. Every service | Service | Variable | |---|---| | Backend, consumer, worker | `SENTRY_DSN` | -| Dashboard container | `WARMBLY_SENTRY_DSN` | -| Realtime | `SENTRY_DSN` | +| Forms service | `SENTRY_DSN` | +| Tracking service | `SENTRY_DSN` | +| Realtime service | `SENTRY_DSN` | +| Dashboard and admin containers | `WARMBLY_SENTRY_DSN` | +| Public form pages | `WARMBLY_SENTRY_DSN` on the forms service | -Set one and that service reports to whatever Sentry Cloud project, self-hosted Sentry or Sentry-compatible server you name. Leave it unset, which is the default the installer writes, and the SDK is never initialised: there is no host to contact and nothing to opt out of. +Set one and that service reports to whatever Sentry Cloud project, self-hosted Sentry or Sentry-compatible server you name. Leave it unset, which is the default the installer writes, and nothing leaves the process: there is no host to contact and nothing to opt out of. Errors still reach that service's own log, as they always did. The dashboard, the admin panel and form pages go further and load no reporting code at all, so there is not even a script to block. + +The `warmbly` CLI runs on your own machine and reports nowhere, ever. It has no DSN to set. ## See also diff --git a/docs/content/docs/development/troubleshooting.mdx b/docs/content/docs/development/troubleshooting.mdx index eddb1f8d..b5419ea4 100644 --- a/docs/content/docs/development/troubleshooting.mdx +++ b/docs/content/docs/development/troubleshooting.mdx @@ -109,6 +109,27 @@ The volumes hold the per-organization data keys. Every sealed mailbox credential `redis-cli FLUSHDB` also destroys `bootstrap:setup_token`, every pending auth session and every login attempt counter. On an unclaimed instance that throws away the only way in. Delete the specific key, or wait out the window. +## Collecting errors somewhere you can read them + +`make logs` shows you what a service printed. It does not tell you that a handler threw at 3am, in which build, for which organization. Every runtime in Warmbly can send that to Sentry instead, and none of them do until you set a DSN. + +Point each service at a project by setting one variable on it: + +```bash +# backend, consumer, worker, forms, tracking, realtime +SENTRY_DSN=https://@/ +# the dashboard, the admin panel, and public form pages (browser errors) +WARMBLY_SENTRY_DSN=https://@/ +``` + +Under the installer's compose file both go in `.env`: every service inherits it through `env_file`, so one line and a `docker compose up -d` covers the whole instance. + +Every event is tagged with the service that raised it, the environment and the build, so one project can hold the whole instance and still be filterable. Server-side services take the environment from `APP_ENV`. The dashboard and admin panel take theirs from `WARMBLY_SENTRY_ENVIRONMENT`, since the container serving the bundle is not the process reporting the error. Hosted form pages are the exception among the browser apps: their environment is stamped into the page by the forms service, so it comes from that service's `APP_ENV`. The frontends are separate projects' worth of events in practice; give the browser ones their own project if you want the noise apart. + +The server behind the DSN is your choice. Sentry Cloud's free Developer plan covers 5k errors a month, and Warmbly assumes nothing about the host, so a self-hosted Sentry or a Sentry-compatible server such as GlitchTip works with the same variable. + +Leaving every DSN unset is a supported configuration, not a degraded one: no host is contacted and errors go to the log as before. See [data control](/development/data-control/#error-reporting). + ## Still stuck ```bash diff --git a/forms/index.html b/forms/index.html index bb7cb8c9..48c6d57c 100644 --- a/forms/index.html +++ b/forms/index.html @@ -6,6 +6,12 @@ + + + + Form diff --git a/forms/package.json b/forms/package.json index 07dc9023..57dee884 100644 --- a/forms/package.json +++ b/forms/package.json @@ -12,6 +12,7 @@ "preview": "vite preview" }, "dependencies": { + "@sentry/browser": "^10.73.0", "@tanstack/react-form": "^1.23.8", "@tanstack/react-query": "^5.90.5", "@tanstack/react-router": "^1.132.0", diff --git a/forms/pnpm-lock.yaml b/forms/pnpm-lock.yaml index 440acebc..d0785906 100644 --- a/forms/pnpm-lock.yaml +++ b/forms/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@sentry/browser': + specifier: ^10.73.0 + version: 10.73.0 '@tanstack/react-form': specifier: ^1.23.8 version: 1.33.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -524,6 +527,34 @@ packages: cpu: [x64] os: [win32] + '@sentry/browser-utils@10.73.0': + resolution: {integrity: sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==} + engines: {node: '>=18'} + + '@sentry/browser@10.73.0': + resolution: {integrity: sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==} + engines: {node: '>=18'} + + '@sentry/conventions@0.16.0': + resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==} + engines: {node: '>=14'} + + '@sentry/core@10.73.0': + resolution: {integrity: sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==} + engines: {node: '>=18'} + + '@sentry/feedback@10.73.0': + resolution: {integrity: sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==} + engines: {node: '>=18'} + + '@sentry/replay-canvas@10.73.0': + resolution: {integrity: sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==} + engines: {node: '>=18'} + + '@sentry/replay@10.73.0': + resolution: {integrity: sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==} + engines: {node: '>=18'} + '@tanstack/devtools-event-client@0.4.4': resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==} engines: {node: '>=18'} @@ -1557,6 +1588,40 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.63.1': optional: true + '@sentry/browser-utils@10.73.0': + dependencies: + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.73.0 + + '@sentry/browser@10.73.0': + dependencies: + '@sentry/browser-utils': 10.73.0 + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.73.0 + '@sentry/feedback': 10.73.0 + '@sentry/replay': 10.73.0 + '@sentry/replay-canvas': 10.73.0 + + '@sentry/conventions@0.16.0': {} + + '@sentry/core@10.73.0': + dependencies: + '@sentry/conventions': 0.16.0 + + '@sentry/feedback@10.73.0': + dependencies: + '@sentry/core': 10.73.0 + + '@sentry/replay-canvas@10.73.0': + dependencies: + '@sentry/core': 10.73.0 + '@sentry/replay': 10.73.0 + + '@sentry/replay@10.73.0': + dependencies: + '@sentry/browser-utils': 10.73.0 + '@sentry/core': 10.73.0 + '@tanstack/devtools-event-client@0.4.4': {} '@tanstack/form-core@1.33.5': diff --git a/forms/src/main.tsx b/forms/src/main.tsx index 1d153acb..40e4d02b 100644 --- a/forms/src/main.tsx +++ b/forms/src/main.tsx @@ -8,6 +8,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRootRoute, createRoute, createRouter, Outlet, RouterProvider } from "@tanstack/react-router"; import { FormPage } from "./FormPage"; +import { initErrorReporting } from "./observability"; import { NotFound } from "./NotFound"; import "./styles.css"; @@ -32,6 +33,8 @@ declare module "@tanstack/react-router" { const queryClient = new QueryClient(); +initErrorReporting(); + createRoot(document.getElementById("root")!).render( diff --git a/forms/src/observability.ts b/forms/src/observability.ts new file mode 100644 index 00000000..adf0feef --- /dev/null +++ b/forms/src/observability.ts @@ -0,0 +1,34 @@ +// Browser error reporting for the hosted form page. +// +// Public pages have to stay light, so unlike the dashboard this loads the SDK +// as its own chunk and only when the shell stamped a DSN: with none, which is +// every self-host and every install that has not configured one, nothing is +// fetched and nothing is sent. The cost is that an error thrown in the first +// few milliseconds is missed, which is the right trade on a page whose whole +// job is to render one form for a stranger. + +function meta(name: string): string { + return document.querySelector(`meta[name="${name}"]`)?.content?.trim() ?? ""; +} + +export function initErrorReporting(): void { + const dsn = meta("wf-sentry-dsn"); + if (!dsn) return; + + void import("@sentry/browser").then((Sentry) => { + Sentry.init({ + dsn, + release: meta("wf-release") || undefined, + environment: meta("wf-environment") || undefined, + // Named so form-page errors are separable from the dashboard's in + // a shared project, the same way the Go services set ServerName. + initialScope: { tags: { service: "forms" } }, + // A form page carries a stranger's answers. Default PII (their IP, + // their headers) is not ours to collect, and the dashboard's + // reasons for sending it do not apply here. + sendDefaultPii: false, + }); + }).catch(() => { + // A blocked or failed SDK load must never stop the form rendering. + }); +} diff --git a/internal/api/handler/attachment.go b/internal/api/handler/attachment.go index 20152d86..603619ce 100644 --- a/internal/api/handler/attachment.go +++ b/internal/api/handler/attachment.go @@ -15,9 +15,9 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/api/middleware" "github.com/warmbly/warmbly/internal/errx" @@ -80,7 +80,7 @@ func (h *Handler) deleteObjectDetached(ctx context.Context, key string) { cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) defer cancel() if err := h.Storage.Delete(cleanup, key); err != nil { - sentry.CaptureException(fmt.Errorf("attachment %s: cleanup after refused reservation: %w", key, err)) + errs.CaptureException(fmt.Errorf("attachment %s: cleanup after refused reservation: %w", key, err)) } } diff --git a/internal/api/middleware/ratelimit.go b/internal/api/middleware/ratelimit.go index b659e836..53253221 100644 --- a/internal/api/middleware/ratelimit.go +++ b/internal/api/middleware/ratelimit.go @@ -4,10 +4,10 @@ import ( "fmt" "net/http" - "github.com/getsentry/sentry-go" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) // RateLimitMiddleware checks rate limits for a given category @@ -35,7 +35,7 @@ func (h *Handler) RateLimitMiddleware(category models.RateLimitCategory) gin.Han status, err := h.RateLimitService.CheckAndRecord(c.Request.Context(), userID, category) if err != nil { // Log error but allow request (fail open) - sentry.CaptureException(err) + errs.CaptureException(err) c.Next() return } diff --git a/internal/app/admin/service.go b/internal/app/admin/service.go index e57a35ba..f4a98ea6 100644 --- a/internal/app/admin/service.go +++ b/internal/app/admin/service.go @@ -4,10 +4,10 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/repository" ) @@ -114,7 +114,7 @@ func (s *adminService) logAction(ctx context.Context, adminID uuid.UUID, action, CreatedAt: time.Now(), } if err := s.repo.CreateAuditLog(ctx, log); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } @@ -139,7 +139,7 @@ func (s *adminService) LogAdminAction(ctx context.Context, adminID uuid.UUID, ac func (s *adminService) SearchMailboxes(ctx context.Context, search *models.AdminMailboxSearch) (*models.AdminMailboxesResult, *errx.Error) { result, err := s.repo.SearchMailboxesForAdmin(ctx, search) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to search mailboxes") } return result, nil @@ -148,7 +148,7 @@ func (s *adminService) SearchMailboxes(ctx context.Context, search *models.Admin func (s *adminService) SearchUsers(ctx context.Context, search *models.AdminUserSearch) (*models.AdminUsersResult, *errx.Error) { result, err := s.repo.SearchUsers(ctx, search) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to search users") } return result, nil @@ -157,7 +157,7 @@ func (s *adminService) SearchUsers(ctx context.Context, search *models.AdminUser func (s *adminService) GetUserDetail(ctx context.Context, userID uuid.UUID) (*models.AdminUserDetail, *errx.Error) { user, err := s.repo.GetUserDetail(ctx, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get user detail") } if user == nil { @@ -169,7 +169,7 @@ func (s *adminService) GetUserDetail(ctx context.Context, userID uuid.UUID) (*mo func (s *adminService) GetUserPreview(ctx context.Context, userID uuid.UUID) (*models.AdminUserPreview, *errx.Error) { preview, err := s.repo.GetUserPreview(ctx, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get user preview") } if preview == nil { @@ -186,7 +186,7 @@ func (s *adminService) BanUser(ctx context.Context, adminID, userID uuid.UUID, r // Check if user exists user, err := s.repo.GetUserDetail(ctx, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to get user") } if user == nil { @@ -210,7 +210,7 @@ func (s *adminService) BanUser(ctx context.Context, adminID, userID uuid.UUID, r } if err := s.repo.BanUser(ctx, userID, adminID, reason, uint32(scope)); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to ban user") } @@ -222,7 +222,7 @@ func (s *adminService) UnbanUser(ctx context.Context, adminID, userID uuid.UUID, // Check if user exists user, err := s.repo.GetUserDetail(ctx, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to get user") } if user == nil { @@ -235,7 +235,7 @@ func (s *adminService) UnbanUser(ctx context.Context, adminID, userID uuid.UUID, } if err := s.repo.UnbanUser(ctx, userID, adminID, reason); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to unban user") } @@ -246,7 +246,7 @@ func (s *adminService) UnbanUser(ctx context.Context, adminID, userID uuid.UUID, func (s *adminService) GetUserBans(ctx context.Context, userID uuid.UUID) ([]models.UserBan, *errx.Error) { bans, err := s.repo.GetUserBans(ctx, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get user bans") } return bans, nil @@ -260,7 +260,7 @@ func (s *adminService) GetUserCampaigns(ctx context.Context, userID uuid.UUID, c } result, err := s.repo.SearchCampaigns(ctx, search) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get user campaigns") } return result, nil @@ -269,7 +269,7 @@ func (s *adminService) GetUserCampaigns(ctx context.Context, userID uuid.UUID, c func (s *adminService) GetUserEmails(ctx context.Context, userID uuid.UUID, cursor *uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, *errx.Error) { emails, pagination, err := s.repo.GetUserEmails(ctx, userID, cursor, limit) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, nil, errx.New(errx.Internal, "failed to get user emails") } return emails, pagination, nil @@ -278,7 +278,7 @@ func (s *adminService) GetUserEmails(ctx context.Context, userID uuid.UUID, curs func (s *adminService) GetUserRateLimits(ctx context.Context, userID uuid.UUID) (*models.AdminUserRateLimits, *errx.Error) { limits, err := s.repo.GetUserRateLimits(ctx, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get rate limits") } // No row means the enforcement path falls back to the product defaults. @@ -290,7 +290,7 @@ func (s *adminService) GetUserRateLimits(ctx context.Context, userID uuid.UUID) func (s *adminService) UpdateUserRateLimits(ctx context.Context, adminID, userID uuid.UUID, update *models.UpdateUserRateLimitsRequest, ipAddress, userAgent string) (*models.AdminUserRateLimits, *errx.Error) { if err := s.repo.UpdateUserRateLimits(ctx, userID, adminID, update); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to update rate limits") } @@ -303,7 +303,7 @@ func (s *adminService) UpdateUserRateLimits(ctx context.Context, adminID, userID func (s *adminService) ListWorkers(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminWorkersResult, *errx.Error) { result, err := s.repo.ListWorkers(ctx, cursor, limit) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to list workers") } return result, nil @@ -312,7 +312,7 @@ func (s *adminService) ListWorkers(ctx context.Context, cursor *uuid.UUID, limit func (s *adminService) GetWorkerDetail(ctx context.Context, workerID uuid.UUID) (*models.AdminWorkerDetail, *errx.Error) { worker, err := s.repo.GetWorkerDetail(ctx, workerID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get worker detail") } if worker == nil { @@ -323,7 +323,7 @@ func (s *adminService) GetWorkerDetail(ctx context.Context, workerID uuid.UUID) func (s *adminService) UpdateWorker(ctx context.Context, adminID, workerID uuid.UUID, update *models.AdminUpdateWorker, ipAddress, userAgent string) *errx.Error { if err := s.repo.UpdateWorker(ctx, workerID, update); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to update worker") } @@ -334,7 +334,7 @@ func (s *adminService) UpdateWorker(ctx context.Context, adminID, workerID uuid. func (s *adminService) GetWorkerEmails(ctx context.Context, workerID uuid.UUID, cursor *uuid.UUID, limit int) ([]models.AdminWorkerEmail, *models.Pagination, *errx.Error) { emails, pagination, err := s.repo.GetWorkerEmails(ctx, workerID, cursor, limit) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, nil, errx.New(errx.Internal, "failed to get worker emails") } return emails, pagination, nil @@ -343,7 +343,7 @@ func (s *adminService) GetWorkerEmails(ctx context.Context, workerID uuid.UUID, func (s *adminService) GetWorkerStats(ctx context.Context, workerID uuid.UUID) (*models.WorkerStats, *errx.Error) { stats, err := s.repo.GetWorkerStats(ctx, workerID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get worker stats") } return stats, nil @@ -351,7 +351,7 @@ func (s *adminService) GetWorkerStats(ctx context.Context, workerID uuid.UUID) ( func (s *adminService) ReassignEmails(ctx context.Context, adminID uuid.UUID, emailIDs []uuid.UUID, newWorkerID uuid.UUID, ipAddress, userAgent string) *errx.Error { if err := s.repo.ReassignEmails(ctx, emailIDs, newWorkerID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to reassign emails") } @@ -364,7 +364,7 @@ func (s *adminService) ReassignEmails(ctx context.Context, adminID uuid.UUID, em func (s *adminService) ListWarmupPools(ctx context.Context) ([]models.WarmupPoolInfo, *errx.Error) { pools, err := s.repo.ListWarmupPools(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to list warmup pools") } return pools, nil @@ -373,7 +373,7 @@ func (s *adminService) ListWarmupPools(ctx context.Context) ([]models.WarmupPool func (s *adminService) GetPoolParticipants(ctx context.Context, poolType string, cursor *uuid.UUID, limit int) (*models.WarmupPoolParticipantsResult, *errx.Error) { result, err := s.repo.GetPoolParticipants(ctx, poolType, cursor, limit) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get pool participants") } return result, nil @@ -382,7 +382,7 @@ func (s *adminService) GetPoolParticipants(ctx context.Context, poolType string, func (s *adminService) ListBlockedAccounts(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminBlockedAccountsResult, *errx.Error) { result, err := s.repo.ListBlockedAccounts(ctx, cursor, limit) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to list blocked accounts") } return result, nil @@ -390,7 +390,7 @@ func (s *adminService) ListBlockedAccounts(ctx context.Context, cursor *uuid.UUI func (s *adminService) BlockAccount(ctx context.Context, adminID, accountID uuid.UUID, reason string, ipAddress, userAgent string) *errx.Error { if err := s.repo.BlockAccount(ctx, accountID, adminID, reason); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to block account") } @@ -400,7 +400,7 @@ func (s *adminService) BlockAccount(ctx context.Context, adminID, accountID uuid func (s *adminService) UnblockAccount(ctx context.Context, adminID, accountID uuid.UUID, ipAddress, userAgent string) *errx.Error { if err := s.repo.UnblockAccount(ctx, accountID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to unblock account") } @@ -413,7 +413,7 @@ func (s *adminService) UnblockAccount(ctx context.Context, adminID, accountID uu func (s *adminService) ListAppeals(ctx context.Context, status string, cursor *uuid.UUID, limit int) (*models.WarmupAppealsResult, *errx.Error) { result, err := s.repo.ListAppeals(ctx, status, cursor, limit) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to list appeals") } return result, nil @@ -422,7 +422,7 @@ func (s *adminService) ListAppeals(ctx context.Context, status string, cursor *u func (s *adminService) GetAppeal(ctx context.Context, appealID uuid.UUID) (*models.WarmupAppeal, *errx.Error) { appeal, err := s.repo.GetAppeal(ctx, appealID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get appeal") } if appeal == nil { @@ -434,7 +434,7 @@ func (s *adminService) GetAppeal(ctx context.Context, appealID uuid.UUID) (*mode func (s *adminService) ReviewAppeal(ctx context.Context, adminID, appealID uuid.UUID, approved bool, notes string, ipAddress, userAgent string) *errx.Error { appeal, err := s.repo.GetAppeal(ctx, appealID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to get appeal") } if appeal == nil { @@ -446,7 +446,7 @@ func (s *adminService) ReviewAppeal(ctx context.Context, adminID, appealID uuid. } if err := s.repo.ReviewAppeal(ctx, appealID, adminID, approved, notes); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to review appeal") } @@ -463,7 +463,7 @@ func (s *adminService) ReviewAppeal(ctx context.Context, adminID, appealID uuid. func (s *adminService) SearchCampaigns(ctx context.Context, search *models.AdminCampaignSearch) (*models.AdminCampaignsResult, *errx.Error) { result, err := s.repo.SearchCampaigns(ctx, search) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to search campaigns") } return result, nil @@ -472,7 +472,7 @@ func (s *adminService) SearchCampaigns(ctx context.Context, search *models.Admin func (s *adminService) GetCampaignDetail(ctx context.Context, campaignID uuid.UUID) (*models.AdminCampaignDetail, *errx.Error) { campaign, err := s.repo.GetCampaignDetail(ctx, campaignID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get campaign detail") } if campaign == nil { @@ -484,7 +484,7 @@ func (s *adminService) GetCampaignDetail(ctx context.Context, campaignID uuid.UU func (s *adminService) StopCampaign(ctx context.Context, adminID, campaignID uuid.UUID, reason, ipAddress, userAgent string) *errx.Error { campaign, err := s.repo.GetCampaignDetail(ctx, campaignID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to get campaign") } if campaign == nil { @@ -495,7 +495,7 @@ func (s *adminService) StopCampaign(ctx context.Context, adminID, campaignID uui // never will, and either can become true between here and the UPDATE. stopped, err := s.repo.StopCampaign(ctx, campaignID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to stop campaign") } if !stopped { @@ -510,7 +510,7 @@ func (s *adminService) StopCampaign(ctx context.Context, adminID, campaignID uui Message: "Campaign force-stopped by platform staff: " + reason, Metadata: map[string]interface{}{"reason": reason}, }); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } @@ -526,7 +526,7 @@ func (s *adminService) StopCampaign(ctx context.Context, adminID, campaignID uui func (s *adminService) GetPlatformOverview(ctx context.Context) (*models.PlatformOverview, *errx.Error) { overview, err := s.repo.GetPlatformOverview(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get platform overview") } return overview, nil @@ -535,7 +535,7 @@ func (s *adminService) GetPlatformOverview(ctx context.Context) (*models.Platfor func (s *adminService) GetAnalyticsTrends(ctx context.Context) (*models.AnalyticsTrends, *errx.Error) { trends, err := s.repo.GetAnalyticsTrends(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get analytics trends") } return trends, nil @@ -544,7 +544,7 @@ func (s *adminService) GetAnalyticsTrends(ctx context.Context) (*models.Analytic func (s *adminService) GetDailyEmailStats(ctx context.Context, startDate, endDate time.Time) ([]models.DailyEmailStats, *errx.Error) { stats, err := s.repo.GetDailyEmailStats(ctx, startDate, endDate) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get daily email stats") } return stats, nil @@ -553,7 +553,7 @@ func (s *adminService) GetDailyEmailStats(ctx context.Context, startDate, endDat func (s *adminService) GetHourlyEmailStats(ctx context.Context, date time.Time) ([]models.HourlyEmailStats, *errx.Error) { stats, err := s.repo.GetHourlyEmailStats(ctx, date) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get hourly email stats") } return stats, nil @@ -562,7 +562,7 @@ func (s *adminService) GetHourlyEmailStats(ctx context.Context, date time.Time) func (s *adminService) GetWorkerLoadStats(ctx context.Context) ([]models.WorkerLoadStats, *errx.Error) { stats, err := s.repo.GetWorkerLoadStats(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get worker load stats") } return stats, nil @@ -571,7 +571,7 @@ func (s *adminService) GetWorkerLoadStats(ctx context.Context) ([]models.WorkerL func (s *adminService) GetEmailDistribution(ctx context.Context) ([]models.EmailDistribution, *errx.Error) { dist, err := s.repo.GetEmailDistribution(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get email distribution") } return dist, nil @@ -580,7 +580,7 @@ func (s *adminService) GetEmailDistribution(ctx context.Context) ([]models.Email func (s *adminService) GetUserGrowthStats(ctx context.Context, startDate, endDate time.Time) ([]models.UserGrowthStats, *errx.Error) { stats, err := s.repo.GetUserGrowthStats(ctx, startDate, endDate) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get user growth stats") } return stats, nil @@ -591,7 +591,7 @@ func (s *adminService) GetUserGrowthStats(ctx context.Context, startDate, endDat func (s *adminService) ListPlans(ctx context.Context, includePrivate bool) ([]models.Plan, *errx.Error) { plans, err := s.repo.ListPlans(ctx, includePrivate) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to list plans") } return plans, nil @@ -600,7 +600,7 @@ func (s *adminService) ListPlans(ctx context.Context, includePrivate bool) ([]mo func (s *adminService) SearchPlansForAdmin(ctx context.Context, search *models.AdminPlanSearch) (*models.AdminPlansResult, *errx.Error) { result, err := s.repo.SearchPlansForAdmin(ctx, search) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to search plans") } return result, nil @@ -614,7 +614,7 @@ func (s *adminService) resolveDuration(ctx context.Context, d models.Duration) ( } id, err := s.repo.DurationIDByTitle(ctx, string(d)) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to resolve plan duration") } if id == nil { @@ -649,7 +649,7 @@ func (s *adminService) CreatePlan(ctx context.Context, adminID uuid.UUID, req *m } if err := s.repo.CreatePlan(ctx, plan, *durationID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to create plan") } @@ -660,7 +660,7 @@ func (s *adminService) CreatePlan(ctx context.Context, adminID uuid.UUID, req *m func (s *adminService) GetPlan(ctx context.Context, planID uuid.UUID) (*models.Plan, *errx.Error) { plan, err := s.repo.GetPlan(ctx, planID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get plan") } if plan == nil { @@ -672,7 +672,7 @@ func (s *adminService) GetPlan(ctx context.Context, planID uuid.UUID) (*models.P func (s *adminService) UpdatePlan(ctx context.Context, adminID, planID uuid.UUID, req *models.UpdatePlanRequest, ipAddress, userAgent string) (*models.Plan, *errx.Error) { plan, err := s.repo.GetPlan(ctx, planID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get plan") } if plan == nil { @@ -732,7 +732,7 @@ func (s *adminService) UpdatePlan(ctx context.Context, adminID, planID uuid.UUID } if err := s.repo.UpdatePlan(ctx, plan, *durationID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to update plan") } @@ -744,7 +744,7 @@ func (s *adminService) DeletePlan(ctx context.Context, adminID, planID uuid.UUID // Check if plan is in use inUse, err := s.repo.IsPlanInUse(ctx, planID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to check plan usage") } if inUse { @@ -752,7 +752,7 @@ func (s *adminService) DeletePlan(ctx context.Context, adminID, planID uuid.UUID } if err := s.repo.DeletePlan(ctx, planID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to delete plan") } @@ -765,7 +765,7 @@ func (s *adminService) DeletePlan(ctx context.Context, adminID, planID uuid.UUID func (s *adminService) ListEnterpriseInquiries(ctx context.Context, search *models.AdminEnterpriseInquirySearch) (*models.AdminEnterpriseInquiriesResult, *errx.Error) { result, err := s.repo.ListEnterpriseInquiries(ctx, search) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to list enterprise inquiries") } return result, nil @@ -774,7 +774,7 @@ func (s *adminService) ListEnterpriseInquiries(ctx context.Context, search *mode func (s *adminService) GetEnterpriseInquiry(ctx context.Context, id uuid.UUID) (*models.AdminEnterpriseInquiry, *errx.Error) { inquiry, err := s.repo.GetEnterpriseInquiry(ctx, id) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get enterprise inquiry") } if inquiry == nil { @@ -785,7 +785,7 @@ func (s *adminService) GetEnterpriseInquiry(ctx context.Context, id uuid.UUID) ( func (s *adminService) UpdateEnterpriseInquiry(ctx context.Context, adminID, inquiryID uuid.UUID, update *models.UpdateEnterpriseInquiryRequest, ipAddress, userAgent string) *errx.Error { if err := s.repo.UpdateEnterpriseInquiry(ctx, inquiryID, update); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to update enterprise inquiry") } @@ -798,7 +798,7 @@ func (s *adminService) UpdateEnterpriseInquiry(ctx context.Context, adminID, inq func (s *adminService) ListAdmins(ctx context.Context, cursor *uuid.UUID, limit int) (*models.AdminsResult, *errx.Error) { result, err := s.repo.ListAdmins(ctx, cursor, limit) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to list admins") } return result, nil @@ -811,7 +811,7 @@ func (s *adminService) GrantAdminPermissions(ctx context.Context, adminID, targe } if err := s.repo.UpdateUserAdminPermissions(ctx, targetUserID, uint32(permissions), adminID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to grant admin permissions") } @@ -826,7 +826,7 @@ func (s *adminService) RevokeAdminPermissions(ctx context.Context, adminID, targ } if err := s.repo.UpdateUserAdminPermissions(ctx, targetUserID, 0, adminID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to revoke admin permissions") } @@ -839,7 +839,7 @@ func (s *adminService) RevokeAdminPermissions(ctx context.Context, adminID, targ func (s *adminService) SearchAuditLogs(ctx context.Context, search *models.AdminAuditLogSearch) (*models.AdminAuditLogsResult, *errx.Error) { result, err := s.repo.SearchAuditLogs(ctx, search) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to search audit logs") } return result, nil diff --git a/internal/app/adminoutreach/service.go b/internal/app/adminoutreach/service.go index 8ae539e5..6be28b35 100644 --- a/internal/app/adminoutreach/service.go +++ b/internal/app/adminoutreach/service.go @@ -9,11 +9,11 @@ package adminoutreach import ( "context" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/notify" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/repository" ) @@ -65,12 +65,12 @@ func (s *service) Send(ctx context.Context, adminID uuid.UUID, req *models.SendA } if err := s.repo.Insert(ctx, m); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to record outreach") } if err := s.mailer.SendOutreach(ctx, []string{to}, req.ReplyTo, req.Subject, req.Body); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) _ = s.repo.MarkFailed(ctx, m.ID, err.Error()) errStr := err.Error() m.Status = models.AdminOutreachStatusFailed @@ -81,7 +81,7 @@ func (s *service) Send(ctx context.Context, adminID uuid.UUID, req *models.SendA if err := s.repo.MarkSent(ctx, m.ID); err != nil { // Mail went out; audit row stuck in queued. Log and return // success so the admin isn't confused into re-sending. - sentry.CaptureException(err) + errs.CaptureException(err) } m.Status = models.AdminOutreachStatusSent return m, nil @@ -90,7 +90,7 @@ func (s *service) Send(ctx context.Context, adminID uuid.UUID, req *models.SendA func (s *service) Search(ctx context.Context, search *models.AdminOutreachSearch) (*models.AdminOutreachResult, *errx.Error) { result, err := s.repo.Search(ctx, search) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load outreach log") } return result, nil @@ -117,7 +117,7 @@ func (s *service) resolveRecipient(ctx context.Context, req *models.SendAdminOut if req.ToUserID != nil { u, err := s.userRepo.GetUser(ctx, *req.ToUserID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.New(errx.Internal, "failed to load user") } if u == nil { @@ -128,7 +128,7 @@ func (s *service) resolveRecipient(ctx context.Context, req *models.SendAdminOut // to_org_id → owner's email org, err := s.orgRepo.GetByID(ctx, *req.ToOrgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.New(errx.Internal, "failed to load organization") } if org == nil { @@ -136,7 +136,7 @@ func (s *service) resolveRecipient(ctx context.Context, req *models.SendAdminOut } u, err := s.userRepo.GetUser(ctx, org.OwnerUserID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.New(errx.Internal, "failed to load owner") } if u == nil { diff --git a/internal/app/audit/service.go b/internal/app/audit/service.go index cfc3f3a5..ad620310 100644 --- a/internal/app/audit/service.go +++ b/internal/app/audit/service.go @@ -4,11 +4,11 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/pubsub" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/repository" ) @@ -90,11 +90,11 @@ func (s *auditService) LogAction(ctx context.Context, orgID, actorID uuid.UUID, go func() { defer func() { if r := recover(); r != nil { - sentry.CurrentHub().Recover(r) + errs.Recover(r) } }() if err := s.repo.Log(context.Background(), log); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return } // Notify the org's dashboard to refetch the activity log live. Carries @@ -138,7 +138,7 @@ func (s *auditService) Search(ctx context.Context, params *models.AuditLogSearch result, err := s.repo.Search(ctx, params) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } diff --git a/internal/app/auth/cache.go b/internal/app/auth/cache.go index e22cb9d8..a66d1c12 100644 --- a/internal/app/auth/cache.go +++ b/internal/app/auth/cache.go @@ -7,11 +7,11 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/redis/go-redis/v9" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/crypt" ) @@ -47,12 +47,12 @@ func getResetPasswordSessionKey(sessionID uuid.UUID) string { func (s *authService) saveLoginSession(ctx context.Context, sessionID uuid.UUID, session *models.LoginSession, expiresAt time.Time) *errx.Error { data, err := json.Marshal(session) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if err := s.cache.Set(ctx, getLoginSessionKey(sessionID), data, time.Until(expiresAt)).Err(); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -65,13 +65,13 @@ func (s *authService) getLoginSession(ctx context.Context, sessionID uuid.UUID) if errors.Is(err, redis.Nil) { return nil, nil } - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } var session models.LoginSession if err := json.Unmarshal(data, &session); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -81,12 +81,12 @@ func (s *authService) getLoginSession(ctx context.Context, sessionID uuid.UUID) func (s *authService) saveRegistrationSession(ctx context.Context, sessionID uuid.UUID, session *models.RegistrationSession, expiresAt time.Time) *errx.Error { data, err := json.Marshal(session) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if err := s.cache.Set(ctx, getRegistrationSessionKey(sessionID), data, time.Until(expiresAt)).Err(); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -99,13 +99,13 @@ func (s *authService) getRegistrationSession(ctx context.Context, sessionID uuid if errors.Is(err, redis.Nil) { return nil, nil } - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } var session models.RegistrationSession if err := json.Unmarshal(data, &session); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -117,13 +117,13 @@ func (s *authService) canSendEmail(ctx context.Context, flow, email string) *err count, err := s.cache.Incr(ctx, key).Result() if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if count == 1 { if err := s.cache.Expire(ctx, key, AuthEmailTTL).Err(); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } } @@ -140,13 +140,13 @@ func (s *authService) passwordResetLimit(ctx context.Context, email string) *err count, err := s.cache.Incr(ctx, key).Result() if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if count == 1 { if err := s.cache.Expire(ctx, key, PasswordResetLimitTTL).Err(); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } } @@ -164,7 +164,7 @@ func (s *authService) passwordResetLimit(ctx context.Context, email string) *err // token and a mail that promised 4 hours. func (s *authService) saveResetPasswordSession(ctx context.Context, sessionID uuid.UUID, nonce string) *errx.Error { if err := s.cache.SetEx(ctx, getResetPasswordSessionKey(sessionID), nonce, PasswordResetTTL).Err(); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -178,7 +178,7 @@ func (s *authService) getResetPasswordSession(ctx context.Context, sessionID uui if errors.Is(err, redis.Nil) { return "", errx.ErrToken } - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.InternalError() } @@ -220,7 +220,7 @@ func deviceFingerprint(userAgent string) string { func (s *authService) deletePasswordResetSession(ctx context.Context, sessionID uuid.UUID) *errx.Error { val, err := s.cache.Del(ctx, getResetPasswordSessionKey(sessionID)).Result() if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } diff --git a/internal/app/auth/external_idtoken.go b/internal/app/auth/external_idtoken.go index 31db9aa1..42a9f993 100644 --- a/internal/app/auth/external_idtoken.go +++ b/internal/app/auth/external_idtoken.go @@ -5,11 +5,11 @@ import ( "errors" "net/mail" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/app/token" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/idtoken" ) @@ -90,7 +90,7 @@ func (s *authService) resolveFederatedUser(ctx context.Context, provider, issuer if s.identities != nil && issuer != "" && subject != "" { existing, ierr := s.identities.FindUserByIdentity(ctx, issuer, subject) if ierr != nil { - sentry.CaptureException(ierr) + errs.CaptureException(ierr) return uuid.Nil, errx.InternalError() } if existing != uuid.Nil { @@ -101,7 +101,7 @@ func (s *authService) resolveFederatedUser(ctx context.Context, provider, issuer u, uerr := s.userRepository.GetUserByEmail(ctx, email.Address) if uerr != nil && !errors.Is(uerr, errx.ErrUser) { - sentry.CaptureException(uerr) + errs.CaptureException(uerr) return uuid.Nil, errx.InternalError() } @@ -115,13 +115,13 @@ func (s *authService) resolveFederatedUser(ctx context.Context, provider, issuer var cerr error u, cerr = s.createExternalUser(ctx, email, firstName, lastName) if cerr != nil { - sentry.CaptureException(cerr) + errs.CaptureException(cerr) return uuid.Nil, errx.InternalError() } } else if s.identities != nil && issuer != "" { linked, herr := s.identities.HasIdentityForIssuer(ctx, u.ID, issuer) if herr != nil { - sentry.CaptureException(herr) + errs.CaptureException(herr) return uuid.Nil, errx.InternalError() } if linked { @@ -138,7 +138,7 @@ func (s *authService) resolveFederatedUser(ctx context.Context, provider, issuer }); lerr != nil { // A unique-index violation means another account already owns this // identity. Refuse rather than sign anyone in. - sentry.CaptureException(lerr) + errs.CaptureException(lerr) return uuid.Nil, errx.New(errx.Forbidden, "that identity is already linked to another account") } } @@ -183,14 +183,14 @@ func (s *authService) createExternalUser(ctx context.Context, email *mail.Addres var orgErr *errx.Error org, orgErr = s.organizationService.Create(ctx, u.ID, orgName) if orgErr != nil { - sentry.CaptureException(orgErr) + errs.CaptureException(orgErr) // Don't fail the sign-in if org creation fails. } } if s.trialService != nil && org != nil { if terr := s.trialService.StartFreeTrialWithOrg(ctx, u.ID, org.ID); terr != nil { - sentry.CaptureException(terr) + errs.CaptureException(terr) // Don't fail the sign-in if trial creation fails. } } diff --git a/internal/app/auth/gen.go b/internal/app/auth/gen.go index a2d854c1..0792f6cb 100644 --- a/internal/app/auth/gen.go +++ b/internal/app/auth/gen.go @@ -4,10 +4,10 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/argon2" ) @@ -19,7 +19,7 @@ func (s *authService) GenerateLoginSession(ctx context.Context, userID uuid.UUID codeHash, err := argon2.Hash(code) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.InternalError() } @@ -34,7 +34,7 @@ func (s *authService) GenerateLoginSession(ctx context.Context, userID uuid.UUID sessionToken, err := s.tokenService.GenerateToken(userID, sessID, "", "", issuedAt, expiresAt) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.InternalError() } diff --git a/internal/app/auth/login.go b/internal/app/auth/login.go index 439738f8..304c12bf 100644 --- a/internal/app/auth/login.go +++ b/internal/app/auth/login.go @@ -5,13 +5,13 @@ import ( "github.com/warmbly/warmbly/internal/app/authrisk" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/app/token" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/notify/templates" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/argon2" "github.com/warmbly/warmbly/internal/pkg/crypt" ) @@ -22,7 +22,7 @@ func (s *authService) LoginStart(ctx context.Context, data *AuthData, ipaddr, us } if xerr := s.captcha.Verify(ctx, data.Turnstile, ipaddr); xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, xerr } @@ -62,30 +62,30 @@ func (s *authService) LoginStart(ctx context.Context, data *AuthData, ipaddr, us sessionID := uuid.New() nonce, xerr := crypt.Nonce() if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } code, xerr := crypt.VerificationCode() if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } text, xerr := templates.GenerateLoginCodeHTML(code) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } if xerr := s.sendAuthEmail(ctx, data.Email, "Your Login Code", text); xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.ErrMailUndeliverable } codeHash, xerr := argon2.Hash(code) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } @@ -97,7 +97,7 @@ func (s *authService) LoginStart(ctx context.Context, data *AuthData, ipaddr, us sessionToken, xerr := s.tokenService.GenerateToken(uid, sessionID, "", nonce, issuedAt, expiresAt) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } @@ -154,7 +154,7 @@ func (s *authService) LoginConfirm(ctx context.Context, data *ConfirmData, sessi v, xerr := argon2.Verify(data.Code, sess.CodeHash) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } diff --git a/internal/app/auth/provision.go b/internal/app/auth/provision.go index a4561f47..92c41ffa 100644 --- a/internal/app/auth/provision.go +++ b/internal/app/auth/provision.go @@ -5,9 +5,9 @@ import ( "net/mail" "strings" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/rs/zerolog/log" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/app/orgrisk" "github.com/warmbly/warmbly/internal/config" @@ -43,7 +43,7 @@ func (s *authService) createAccount(ctx context.Context, address, passwordHash, u, xerr := s.userRepository.CreateUser(ctx, email, passwordHash) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } @@ -79,7 +79,7 @@ func (s *authService) createAccount(ctx context.Context, address, passwordHash, var orgErr *errx.Error org, orgErr = s.organizationService.Create(ctx, u.ID, orgName) if orgErr != nil { - sentry.CaptureException(orgErr) + errs.CaptureException(orgErr) // Don't fail registration if org creation fails } } @@ -95,7 +95,7 @@ func (s *authService) createAccount(ctx context.Context, address, passwordHash, // Start 2-week free trial for new user (linked to organization) if s.trialService != nil && org != nil { if err := s.trialService.StartFreeTrialWithOrg(ctx, u.ID, org.ID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) // Don't fail registration if trial creation fails } } @@ -104,7 +104,7 @@ func (s *authService) createAccount(ctx context.Context, address, passwordHash, // Best-effort: a bad or self-referral code never fails registration. if s.referral != nil && org != nil && referralCode != "" { if xerr := s.referral.AttributeSignup(ctx, referralCode, org.ID, u.ID); xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) } } @@ -158,7 +158,7 @@ func (s *authService) signupAllowed(ctx context.Context, address, invite string) if s.policy.Registration != config.RegistrationClosed { empty, err := s.userRepository.IsEmpty(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if empty { @@ -236,7 +236,7 @@ func (s *authService) federatedSignupAllowed(ctx context.Context, address string if s.policy.Registration != config.RegistrationClosed { empty, err := s.userRepository.IsEmpty(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if empty { diff --git a/internal/app/auth/registration.go b/internal/app/auth/registration.go index 0ce58383..62bc393f 100644 --- a/internal/app/auth/registration.go +++ b/internal/app/auth/registration.go @@ -4,11 +4,11 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/notify/templates" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/argon2" "github.com/warmbly/warmbly/internal/pkg/crypt" ) @@ -24,7 +24,7 @@ func (s *authService) RegistrationStart(ctx context.Context, data *AuthData, ori } if xerr := s.captcha.Verify(ctx, data.Turnstile, ipaddr); xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, xerr } @@ -34,7 +34,7 @@ func (s *authService) RegistrationStart(ctx context.Context, data *AuthData, ori passwordHash, xerr := argon2.Hash(data.Password) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } @@ -58,30 +58,30 @@ func (s *authService) RegistrationStart(ctx context.Context, data *AuthData, ori sessionID := uuid.New() nonce, xerr := crypt.Nonce() if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } code, xerr := crypt.VerificationCode() if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } text, xerr := templates.GenerateRegistrationCodeHTML(code) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } if xerr := s.sendAuthEmail(ctx, data.Email, "Your Verification Code", text); xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.ErrMailUndeliverable } codeHash, xerr := argon2.Hash(code) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } @@ -99,7 +99,7 @@ func (s *authService) RegistrationStart(ctx context.Context, data *AuthData, ori sessionToken, xerr := s.tokenService.GenerateToken(uuid.Nil, sessionID, data.Email, nonce, issuedAt, expiresAt) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } @@ -131,7 +131,7 @@ func (s *authService) RegistrationConfirm(ctx context.Context, data *ConfirmData v, xerr := argon2.Verify(data.Code, sess.CodeHash) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } diff --git a/internal/app/auth/reset_password.go b/internal/app/auth/reset_password.go index 039aa476..4c8f623b 100644 --- a/internal/app/auth/reset_password.go +++ b/internal/app/auth/reset_password.go @@ -4,18 +4,18 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/notify/templates" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/argon2" "github.com/warmbly/warmbly/internal/pkg/crypt" ) func (s *authService) ResetPasswordStart(ctx context.Context, data *ResetPasswordStart, ipaddr string) *errx.Error { if err := s.captcha.Verify(ctx, data.Turnstile, ipaddr); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return err } @@ -41,7 +41,7 @@ func (s *authService) ResetPasswordStart(ctx context.Context, data *ResetPasswor sessionID := uuid.New() nonce, err := crypt.Nonce() if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -50,7 +50,7 @@ func (s *authService) ResetPasswordStart(ctx context.Context, data *ResetPasswor token, err := s.tokenService.GenerateToken(user.ID, sessionID, data.Email, nonce, issuedAt, expiresAt) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -62,12 +62,12 @@ func (s *authService) ResetPasswordStart(ctx context.Context, data *ResetPasswor text, err := templates.GenerateResetPasswordHTML(u.FirstName, url) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if err := s.sendAuthEmail(ctx, u.Email, "Password Reset Confirmation", text); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.ErrMailUndeliverable } @@ -76,7 +76,7 @@ func (s *authService) ResetPasswordStart(ctx context.Context, data *ResetPasswor func (s *authService) ResetPasswordConfirm(ctx context.Context, data *ResetPasswordConfirm, session, ipaddr string) *errx.Error { if err := s.captcha.Verify(ctx, data.Turnstile, ipaddr); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return err } @@ -108,7 +108,7 @@ func (s *authService) ResetPasswordConfirm(ctx context.Context, data *ResetPassw passwordHash, hashErr := argon2.Hash(data.Password) if hashErr != nil { - sentry.CaptureException(hashErr) + errs.CaptureException(hashErr) return errx.InternalError() } @@ -121,7 +121,7 @@ func (s *authService) ResetPasswordConfirm(ctx context.Context, data *ResetPassw // none, so all are revoked) so a reset always fully cuts off prior access. if s.tokenService != nil { if err := s.tokenService.RevokeOtherSessions(ctx, sess.UserID, uuid.Nil); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) // Non-fatal: the password is already reset. } } @@ -143,7 +143,7 @@ func (s *authService) ChangePassword(ctx context.Context, userID, currentSession ok, verr := argon2.Verify(data.CurrentPassword, hash) if verr != nil { - sentry.CaptureException(verr) + errs.CaptureException(verr) return errx.InternalError() } if !ok { @@ -159,7 +159,7 @@ func (s *authService) ChangePassword(ctx context.Context, userID, currentSession newHash, hashErr := argon2.Hash(data.NewPassword) if hashErr != nil { - sentry.CaptureException(hashErr) + errs.CaptureException(hashErr) return errx.InternalError() } if err := s.authRepository.ResetPassword(ctx, userID, newHash); err != nil { @@ -172,7 +172,7 @@ func (s *authService) ChangePassword(ctx context.Context, userID, currentSession // they just performed. if s.tokenService != nil && currentSessionID != uuid.Nil { if err := s.tokenService.RevokeOtherSessions(ctx, userID, currentSessionID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) // Non-fatal: the password is already changed. } } diff --git a/internal/app/auth/sso.go b/internal/app/auth/sso.go index fe98fb44..098db026 100644 --- a/internal/app/auth/sso.go +++ b/internal/app/auth/sso.go @@ -9,10 +9,10 @@ import ( "net/mail" "time" - "github.com/getsentry/sentry-go" "github.com/warmbly/warmbly/internal/app/token" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/idtoken" ) @@ -140,16 +140,16 @@ func (s *authService) FederatedProviderLabels() map[string]string { func (s *authService) mintHandoff(ctx context.Context, result *models.LoginResult, binding string) (string, *errx.Error) { code, err := randomHex(32) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.InternalError() } payload, err := json.Marshal(ssoHandoff{Binding: binding, Result: *result}) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.InternalError() } if err := s.cache.SetEx(ctx, ssoHandoffKey(code), payload, ssoHandoffTTL).Err(); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.InternalError() } return code, nil @@ -173,7 +173,7 @@ func (s *authService) SSOExchange(ctx context.Context, code, binding string) (*m } var handoff ssoHandoff if err := json.Unmarshal(raw, &handoff); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } if subtle.ConstantTimeCompare([]byte(handoff.Binding), []byte(binding)) != 1 { @@ -191,32 +191,32 @@ func (s *authService) SSOBegin(ctx context.Context, provider string) (*SSORedire state, err := randomHex(32) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } nonce, err := randomHex(32) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } verifier, err := randomHex(32) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } binding, err := randomHex(32) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } payload, err := json.Marshal(ssoFlow{Provider: provider, Verifier: verifier, Nonce: nonce, Binding: binding}) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } if err := s.cache.SetEx(ctx, ssoStateKey(state), payload, ssoStateTTL).Err(); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -242,7 +242,7 @@ func (s *authService) SSOCallbackComplete(ctx context.Context, in SSOCallback) ( var flow ssoFlow if err := json.Unmarshal(raw, &flow); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.InternalError() } // A state minted for one provider presented at another's callback is a @@ -255,7 +255,7 @@ func (s *authService) SSOCallbackComplete(ctx context.Context, in SSOCallback) ( if err != nil { // Reported, not buried: these are configuration problems more often // than attacks. - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.ErrExternalCode } diff --git a/internal/app/bootstrap/bootstrap.go b/internal/app/bootstrap/bootstrap.go index d97fa221..a5dd9ae7 100644 --- a/internal/app/bootstrap/bootstrap.go +++ b/internal/app/bootstrap/bootstrap.go @@ -26,7 +26,6 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/warmbly/warmbly/internal/app/organization" "github.com/warmbly/warmbly/internal/app/trial" "github.com/warmbly/warmbly/internal/app/user" @@ -34,6 +33,7 @@ import ( "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/cache" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/argon2" "github.com/warmbly/warmbly/internal/pkg/crypt" "github.com/warmbly/warmbly/internal/repository" @@ -269,7 +269,7 @@ func (s *Service) Claim(ctx context.Context, token, address, password, firstName // token: a stale link out of an old log must never mint a second owner. empty, eerr := s.users.IsEmpty(ctx) if eerr != nil { - sentry.CaptureException(eerr) + errs.CaptureException(eerr) return nil, errx.InternalError() } if !empty { @@ -295,13 +295,13 @@ func (s *Service) Claim(ctx context.Context, token, address, password, firstName hash, herr := argon2.Hash(password) if herr != nil { - sentry.CaptureException(herr) + errs.CaptureException(herr) return nil, errx.InternalError() } u, uerr := s.users.CreateUser(ctx, parsed, hash) if uerr != nil { - sentry.CaptureException(uerr) + errs.CaptureException(uerr) return nil, errx.InternalError() } if firstName != "" { @@ -319,7 +319,7 @@ func (s *Service) Claim(ctx context.Context, token, address, password, firstName } org, orgErr := s.orgSvc.Create(ctx, u.ID, orgName) if orgErr != nil { - sentry.CaptureException(orgErr) + errs.CaptureException(orgErr) return nil, errx.InternalError() } if s.trialSvc != nil { @@ -327,7 +327,7 @@ func (s *Service) Claim(ctx context.Context, token, address, password, firstName } if err := s.adminRepo.GrantBootstrapAdmin(ctx, u.ID, uint32(models.AllAdminPermissions)); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } diff --git a/internal/app/campaign/handlers.go b/internal/app/campaign/handlers.go index e12fb460..280320cb 100644 --- a/internal/app/campaign/handlers.go +++ b/internal/app/campaign/handlers.go @@ -11,7 +11,6 @@ import ( "time" "unicode/utf8" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/app/dailythrottle" "github.com/warmbly/warmbly/internal/app/listgate" @@ -21,6 +20,7 @@ import ( "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/pubsub" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/trackdns" "github.com/warmbly/warmbly/internal/repository" "github.com/warmbly/warmbly/internal/scheduler" @@ -167,7 +167,7 @@ func (s *campaignService) Delete(ctx context.Context, orgID uuid.UUID, campaignI if s.attachmentRepo != nil { var err error if attachments, err = s.attachmentRepo.ListByCampaign(ctx, cID); err != nil { - sentry.CaptureException(fmt.Errorf("campaign %s delete: list attachments: %w", cID, err)) + errs.CaptureException(fmt.Errorf("campaign %s delete: list attachments: %w", cID, err)) } } @@ -181,7 +181,7 @@ func (s *campaignService) Delete(ctx context.Context, orgID uuid.UUID, campaignI if s.storage != nil { for _, att := range attachments { if err := s.storage.Delete(ctx, att.S3Key); err != nil { - sentry.CaptureException(fmt.Errorf("campaign %s delete: object %s: %w", cID, att.S3Key, err)) + errs.CaptureException(fmt.Errorf("campaign %s delete: object %s: %w", cID, att.S3Key, err)) } } } @@ -333,14 +333,14 @@ func (s *campaignService) copyAttachments(ctx context.Context, orgID, src, dst u for _, att := range sources { body, err := s.storage.Get(ctx, att.S3Key) if err != nil { - sentry.CaptureException(fmt.Errorf("campaign %s duplicate: read %s: %w", src, att.S3Key, err)) + errs.CaptureException(fmt.Errorf("campaign %s duplicate: read %s: %w", src, att.S3Key, err)) continue } key := models.AttachmentObjectKey(dst, att.Filename) err = s.storage.Put(ctx, key, body, att.MimeType) body.Close() if err != nil { - sentry.CaptureException(fmt.Errorf("campaign %s duplicate: write %s: %w", src, key, err)) + errs.CaptureException(fmt.Errorf("campaign %s duplicate: write %s: %w", src, key, err)) continue } att.S3Key = key @@ -353,7 +353,7 @@ func (s *campaignService) copyAttachments(ctx context.Context, orgID, src, dst u defer cancel() for _, att := range copied { if err := s.storage.Delete(cleanup, att.S3Key); err != nil { - sentry.CaptureException(fmt.Errorf("campaign %s duplicate undo: object %s: %w", src, att.S3Key, err)) + errs.CaptureException(fmt.Errorf("campaign %s duplicate undo: object %s: %w", src, att.S3Key, err)) } } }, nil @@ -664,7 +664,7 @@ func (s *campaignService) KeepRunning(ctx context.Context, orgID, campaignID uui if errors.Is(err, errx.ErrResourceNotFound) { return errx.ErrNotFound } - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if !transitioned { @@ -775,7 +775,7 @@ func (s *campaignService) enqueueCampaignWakeup(ctx context.Context, campaignID _ = s.campaignRepository.UpdateStatusWithLock(ctx, campaignID, "completed") return errx.New(errx.BadRequest, "campaign is past its end date; extend or clear the end date to keep sending") default: - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } } @@ -795,7 +795,7 @@ func (s *campaignService) enqueueCampaignWakeup(ctx context.Context, campaignID created, err := s.taskRepo.CreateTaskWithLock(ctx, task, campaignTask) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if !created { @@ -807,11 +807,11 @@ func (s *campaignService) enqueueCampaignWakeup(ctx context.Context, campaignID if err != nil { _ = s.taskRepo.DeleteTask(ctx, taskID) _ = s.campaignRepository.StopCampaign(ctx, campaignID) - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.ServiceUnavailable, "could not schedule campaign right now") } if err := s.taskRepo.UpdateTaskScheduledAt(ctx, taskID, nextTime, cloudTaskName); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } diff --git a/internal/app/cipher/cipher.go b/internal/app/cipher/cipher.go index 565ffa2b..9317cb42 100644 --- a/internal/app/cipher/cipher.go +++ b/internal/app/cipher/cipher.go @@ -3,8 +3,8 @@ package cipher import ( "context" - "github.com/getsentry/sentry-go" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/observability/errs" ) type Cipher struct { @@ -44,7 +44,7 @@ func (s *cipherService) Cipher(ctx context.Context, orgID uuid.UUID) (*Cipher, e } if err := s.saveDecryptedKey(ctx, orgID, key); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } return &Cipher{ diff --git a/internal/app/consumer/err.go b/internal/app/consumer/err.go index c819f73c..ee0b4d02 100644 --- a/internal/app/consumer/err.go +++ b/internal/app/consumer/err.go @@ -1,14 +1,13 @@ package jobs import ( - "github.com/getsentry/sentry-go" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/observability/errs" ) func CaptureError(userID, emailID uuid.UUID, err error) { - sentry.WithScope(func(scope *sentry.Scope) { - scope.SetTag("user_id", userID.String()) - scope.SetTag("email_id", emailID.String()) - sentry.CaptureException(err) - }) + errs.CaptureException(err, + errs.Tag("user_id", userID.String()), + errs.Tag("email_id", emailID.String()), + ) } diff --git a/internal/app/dailythrottle/service.go b/internal/app/dailythrottle/service.go index dda10650..29b2a740 100644 --- a/internal/app/dailythrottle/service.go +++ b/internal/app/dailythrottle/service.go @@ -16,10 +16,10 @@ import ( "fmt" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/cache" + "github.com/warmbly/warmbly/internal/observability/errs" ) // Resource enumerates the actions the throttle bounds. Keeping the @@ -66,7 +66,7 @@ func (s *service) CheckAndIncrement(ctx context.Context, scope uuid.UUID, res Re count, err := s.cache.Incr(ctx, key).Result() if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil // fail-open } // On the very first hit the TTL is unset; set a 25h floor so the diff --git a/internal/app/dangerzone/service.go b/internal/app/dangerzone/service.go index a5e80727..d5455a66 100644 --- a/internal/app/dangerzone/service.go +++ b/internal/app/dangerzone/service.go @@ -15,8 +15,8 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" @@ -90,7 +90,7 @@ func (s *service) ScheduleOrganizationDeletion(ctx context.Context, orgID, reque org, err := s.orgRepo.GetByID(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load organization") } if org == nil { @@ -127,7 +127,7 @@ func (s *service) ScheduleOrganizationDeletion(ctx context.Context, orgID, reque if errors.Is(err, repository.ErrPendingDeletionExists) { return nil, errx.New(errx.Conflict, "organization is already scheduled for deletion") } - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to schedule organization deletion") } @@ -140,7 +140,7 @@ func (s *service) ScheduleOrganizationDeletion(ctx context.Context, orgID, reque func (s *service) CancelOrganizationDeletion(ctx context.Context, orgID, requesterUserID uuid.UUID, req *models.CancelDeletionRequest) *errx.Error { org, err := s.orgRepo.GetByID(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to load organization") } if org == nil { @@ -152,7 +152,7 @@ func (s *service) CancelOrganizationDeletion(ctx context.Context, orgID, request d, err := s.repo.GetActive(ctx, models.DeletionResourceOrganization, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to load pending deletion") } if d == nil { @@ -165,7 +165,7 @@ func (s *service) CancelOrganizationDeletion(ctx context.Context, orgID, request } if err := s.repo.Cancel(ctx, d.ID, requesterUserID, reason); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to cancel deletion") } @@ -176,7 +176,7 @@ func (s *service) CancelOrganizationDeletion(ctx context.Context, orgID, request func (s *service) GetOrganizationStatus(ctx context.Context, orgID uuid.UUID) (*models.DangerZoneStatus, *errx.Error) { org, err := s.orgRepo.GetByID(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load organization") } if org == nil { @@ -193,7 +193,7 @@ func (s *service) GetOrganizationStatus(ctx context.Context, orgID uuid.UUID) (* d, err := s.repo.GetActive(ctx, models.DeletionResourceOrganization, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load pending deletion") } status.PendingDeletion = d @@ -209,7 +209,7 @@ func (s *service) ScheduleUserDeletion(ctx context.Context, userID uuid.UUID, re user, err := s.userRepo.GetUser(ctx, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load user") } if user == nil { @@ -241,7 +241,7 @@ func (s *service) ScheduleUserDeletion(ctx context.Context, userID uuid.UUID, re if errors.Is(err, repository.ErrPendingDeletionExists) { return nil, errx.New(errx.Conflict, "account is already scheduled for deletion") } - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to schedule account deletion") } @@ -254,7 +254,7 @@ func (s *service) ScheduleUserDeletion(ctx context.Context, userID uuid.UUID, re func (s *service) CancelUserDeletion(ctx context.Context, userID uuid.UUID, req *models.CancelDeletionRequest) *errx.Error { user, err := s.userRepo.GetUser(ctx, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to load user") } if user == nil { @@ -263,7 +263,7 @@ func (s *service) CancelUserDeletion(ctx context.Context, userID uuid.UUID, req d, err := s.repo.GetActive(ctx, models.DeletionResourceUser, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to load pending deletion") } if d == nil { @@ -276,7 +276,7 @@ func (s *service) CancelUserDeletion(ctx context.Context, userID uuid.UUID, req } if err := s.repo.Cancel(ctx, d.ID, userID, reason); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to cancel deletion") } @@ -287,7 +287,7 @@ func (s *service) CancelUserDeletion(ctx context.Context, userID uuid.UUID, req func (s *service) GetUserStatus(ctx context.Context, userID uuid.UUID) (*models.DangerZoneStatus, *errx.Error) { user, err := s.userRepo.GetUser(ctx, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load user") } if user == nil { @@ -304,7 +304,7 @@ func (s *service) GetUserStatus(ctx context.Context, userID uuid.UUID) (*models. d, err := s.repo.GetActive(ctx, models.DeletionResourceUser, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load pending deletion") } status.PendingDeletion = d @@ -334,7 +334,7 @@ func (s *service) ExecuteDuePendingDeletions(ctx context.Context) (int, int, err // one transitions pending -> executing. claimed, err := s.repo.MarkExecuting(ctx, d.ID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) failed++ continue } @@ -343,14 +343,14 @@ func (s *service) ExecuteDuePendingDeletions(ctx context.Context) (int, int, err } if err := s.runHardDelete(ctx, &d); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) _ = s.repo.MarkFailed(ctx, d.ID, err.Error()) failed++ continue } if err := s.repo.MarkCompleted(ctx, d.ID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) failed++ continue } @@ -396,7 +396,7 @@ func (s *service) DispatchReminders(ctx context.Context) error { d := batch[i] s.sendReminderEmail(ctx, &d, t.bit) if err := s.repo.SetNotifBit(ctx, d.ID, t.bit); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } } @@ -447,7 +447,7 @@ func (s *service) sendUserScheduledEmail(ctx context.Context, user *models.User, return } if err := s.notifier.Send(ctx, []string{user.Email}, nil, nil, subject, body); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } @@ -461,7 +461,7 @@ func (s *service) sendUserCancelledEmail(ctx context.Context, user *models.User, return } if err := s.notifier.Send(ctx, []string{user.Email}, nil, nil, subject, body); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } @@ -550,7 +550,7 @@ func (s *service) sendCompletionEmail(ctx context.Context, d *models.ScheduledDe return } if err := s.notifier.Send(ctx, []string{requester.Email}, nil, nil, subject, body); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } @@ -600,7 +600,7 @@ func (s *service) orgRecipients(ctx context.Context, org *models.Organization) [ members, err := s.orgRepo.GetMembers(ctx, org.ID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return out } for i := range members { @@ -624,7 +624,7 @@ func (s *service) orgRecipients(ctx context.Context, org *models.Organization) [ func (s *service) sendToEach(ctx context.Context, recipients []string, subject, body string) { for _, to := range recipients { if err := s.notifier.Send(ctx, []string{to}, nil, nil, subject, body); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } } diff --git a/internal/app/discount/service.go b/internal/app/discount/service.go index 748711ab..776b0c81 100644 --- a/internal/app/discount/service.go +++ b/internal/app/discount/service.go @@ -6,10 +6,10 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/repository" ) @@ -83,7 +83,7 @@ func NormalizeCode(code string) string { func (s *service) List(ctx context.Context, search *models.AdminDiscountSearch) (*models.AdminDiscountsResult, *errx.Error) { result, err := s.codeRepo.List(ctx, search) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to list discount codes") } return result, nil @@ -92,7 +92,7 @@ func (s *service) List(ctx context.Context, search *models.AdminDiscountSearch) func (s *service) Get(ctx context.Context, id uuid.UUID) (*models.DiscountCode, *errx.Error) { code, err := s.codeRepo.GetByID(ctx, id) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get discount code") } if code == nil { @@ -156,7 +156,7 @@ func (s *service) Create(ctx context.Context, adminID uuid.UUID, req *models.Cre existing, err := s.codeRepo.GetByCode(ctx, code) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to check existing code") } if existing != nil { @@ -164,7 +164,7 @@ func (s *service) Create(ctx context.Context, adminID uuid.UUID, req *models.Cre } if err := s.codeRepo.Create(ctx, dc); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to create discount code") } @@ -175,7 +175,7 @@ func (s *service) Create(ctx context.Context, adminID uuid.UUID, req *models.Cre func (s *service) Update(ctx context.Context, adminID, id uuid.UUID, req *models.UpdateDiscountCodeRequest, ipAddress, userAgent string) (*models.DiscountCode, *errx.Error) { dc, err := s.codeRepo.GetByID(ctx, id) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get discount code") } if dc == nil { @@ -239,7 +239,7 @@ func (s *service) Update(ctx context.Context, adminID, id uuid.UUID, req *models } if err := s.codeRepo.Update(ctx, dc); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to update discount code") } @@ -250,14 +250,14 @@ func (s *service) Update(ctx context.Context, adminID, id uuid.UUID, req *models func (s *service) Delete(ctx context.Context, adminID, id uuid.UUID, ipAddress, userAgent string) *errx.Error { dc, err := s.codeRepo.GetByID(ctx, id) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to get discount code") } if dc == nil { return errx.ErrNotFound } if err := s.codeRepo.Delete(ctx, id); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to delete discount code") } s.log(ctx, adminID, "delete_discount_code", id, map[string]any{"code": dc.Code}, ipAddress, userAgent) @@ -267,7 +267,7 @@ func (s *service) Delete(ctx context.Context, adminID, id uuid.UUID, ipAddress, func (s *service) ListRedemptions(ctx context.Context, codeID uuid.UUID, offset, limit int) (*models.AdminDiscountRedemptionsResult, *errx.Error) { result, err := s.redRepo.ListByCode(ctx, codeID, offset, limit) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to list redemptions") } return result, nil @@ -276,7 +276,7 @@ func (s *service) ListRedemptions(ctx context.Context, codeID uuid.UUID, offset, func (s *service) ListOrganizationRedemptions(ctx context.Context, orgID uuid.UUID, limit int) ([]models.DiscountRedemption, *errx.Error) { rows, err := s.redRepo.ListByOrganization(ctx, orgID, limit) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to list redemptions") } return rows, nil @@ -342,7 +342,7 @@ func (s *service) resolve(ctx context.Context, orgID uuid.UUID, code string, pla dc, err := s.codeRepo.GetByCode(ctx, normalized) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, "", errx.New(errx.Internal, "failed to validate discount code") } if dc == nil { @@ -380,7 +380,7 @@ func (s *service) resolve(ctx context.Context, orgID uuid.UUID, code string, pla orgCount, err := s.redRepo.CountActiveByCodeAndOrg(ctx, dc.ID, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, "", errx.New(errx.Internal, "failed to validate discount code") } if dc.PerAccountLimit > 0 && orgCount >= dc.PerAccountLimit { @@ -424,7 +424,7 @@ func (s *service) reserve(ctx context.Context, code *models.DiscountCode, orgID case errors.Is(err, repository.ErrDiscountAlreadyRedeemed): return uuid.Nil, errx.New(errx.BadRequest, "You've already used this discount code.") case err != nil: - sentry.CaptureException(err) + errs.CaptureException(err) return uuid.Nil, errx.New(errx.Internal, "failed to record discount redemption") } return red.ID, nil @@ -432,7 +432,7 @@ func (s *service) reserve(ctx context.Context, code *models.DiscountCode, orgID func (s *service) AttachRedemptionStripe(ctx context.Context, redemptionID uuid.UUID, sessionID, couponID *string) *errx.Error { if err := s.redRepo.AttachStripeRefs(ctx, redemptionID, sessionID, couponID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to attach redemption references") } return nil @@ -440,7 +440,7 @@ func (s *service) AttachRedemptionStripe(ctx context.Context, redemptionID uuid. func (s *service) CancelRedemptionByID(ctx context.Context, redemptionID uuid.UUID) *errx.Error { if err := s.redRepo.CancelByID(ctx, redemptionID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to cancel redemption") } return nil @@ -448,7 +448,7 @@ func (s *service) CancelRedemptionByID(ctx context.Context, redemptionID uuid.UU func (s *service) MarkRedemptionApplied(ctx context.Context, sessionID string, subscriptionID *uuid.UUID) *errx.Error { if err := s.redRepo.MarkAppliedBySession(ctx, sessionID, subscriptionID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to mark redemption applied") } return nil @@ -456,7 +456,7 @@ func (s *service) MarkRedemptionApplied(ctx context.Context, sessionID string, s func (s *service) CancelRedemption(ctx context.Context, sessionID string) *errx.Error { if err := s.redRepo.CancelBySession(ctx, sessionID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to cancel redemption") } return nil diff --git a/internal/app/email/cache.go b/internal/app/email/cache.go index 051ddae9..51e1ac15 100644 --- a/internal/app/email/cache.go +++ b/internal/app/email/cache.go @@ -6,10 +6,10 @@ import ( "errors" "time" - "github.com/getsentry/sentry-go" "github.com/redis/go-redis/v9" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) // OnboardingStateTTL bounds the time a user has to complete an OAuth round trip. @@ -25,11 +25,11 @@ func (s *emailService) saveOnboardingState(ctx context.Context, state string, da } raw, err := json.Marshal(data) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if err := s.r.Set(ctx, onboardingStateKey(state), raw, OnboardingStateTTL).Err(); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } return nil @@ -44,16 +44,16 @@ func (s *emailService) takeOnboardingState(ctx context.Context, state string) (* if errors.Is(err, redis.Nil) { return nil, errx.ErrEmailOnboardState } - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } // Single-use: remove immediately to prevent replay even on later errors. if err := s.r.Del(ctx, onboardingStateKey(state)).Err(); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } var out models.EmailOnboardingState if err := json.Unmarshal(raw, &out); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } return &out, nil diff --git a/internal/app/email/onboarding.go b/internal/app/email/onboarding.go index 54b11aeb..3808175c 100644 --- a/internal/app/email/onboarding.go +++ b/internal/app/email/onboarding.go @@ -9,11 +9,11 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/pubsub" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/crypt" "golang.org/x/oauth2" ) @@ -34,7 +34,7 @@ func (s *emailService) OAuthStart(ctx context.Context, userID string, orgID *uui state, err := crypt.Nonce() if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -228,7 +228,7 @@ func (s *emailService) OnboardSMTPIMAP(ctx context.Context, userID string, orgID // the scheduler will pick the account up on its next pass. if orgID != nil { if _, err := s.workerAssignment.AssignWorkerToEmail(ctx, acc.ID, *orgID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } @@ -256,7 +256,7 @@ func (s *emailService) dispatchAccountConnected(ctx context.Context, orgID *uuid "created_at": acc.CreatedAt, } if _, err := s.webhookService.Dispatch(ctx, *orgID, models.WebhookEventEmailAccountConnected, payload); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } diff --git a/internal/app/email/reauth.go b/internal/app/email/reauth.go index 5aff590f..620ab331 100644 --- a/internal/app/email/reauth.go +++ b/internal/app/email/reauth.go @@ -10,11 +10,11 @@ import ( "context" "strings" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/crypt" "golang.org/x/oauth2" ) @@ -54,7 +54,7 @@ func (s *emailService) OAuthReauth(ctx context.Context, userID string, orgID *uu state, err := crypt.Nonce() if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } diff --git a/internal/app/email/validate_credentials.go b/internal/app/email/validate_credentials.go index 00347b17..f46140b3 100644 --- a/internal/app/email/validate_credentials.go +++ b/internal/app/email/validate_credentials.go @@ -5,10 +5,10 @@ import ( "errors" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) // ValidateCredentials seals a copy of the credentials with the org DEK and asks @@ -24,21 +24,21 @@ func (s *emailService) ValidateCredentials(ctx context.Context, orgID uuid.UUID, cipher, err := s.cipherService.Cipher(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } sealedIMAP := *credentials.IMAP sealedIMAP.Password, err = cipher.Encrypt(ctx, credentials.IMAP.Password) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } sealedSMTP := *credentials.SMTP sealedSMTP.Password, err = cipher.Encrypt(ctx, credentials.SMTP.Password) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -47,7 +47,7 @@ func (s *emailService) ValidateCredentials(ctx context.Context, orgID uuid.UUID, ProcessID: processID, Credentials: &models.SmtpImap{SMTP: &sealedSMTP, IMAP: &sealedIMAP}, }); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -63,7 +63,7 @@ func (s *emailService) ValidateCredentials(ctx context.Context, orgID uuid.UUID, if errors.Is(err, context.DeadlineExceeded) { return errx.ErrEmailValidation } - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go index 7a14b3c7..50590f1b 100644 --- a/internal/app/organization/service.go +++ b/internal/app/organization/service.go @@ -8,12 +8,12 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/app/dailythrottle" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/crypt" "github.com/warmbly/warmbly/internal/repository" ) @@ -246,7 +246,7 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name // Check organization limit user, userErr := s.userRepo.GetUser(ctx, userID) if userErr != nil { - sentry.CaptureException(userErr) + errs.CaptureException(userErr) return nil, errx.New(errx.Internal, "failed to get user") } if user == nil { @@ -255,7 +255,7 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name ownedCount, countErr := s.orgRepo.GetUserOwnedOrganizationCount(ctx, userID) if countErr != nil { - sentry.CaptureException(countErr) + errs.CaptureException(countErr) return nil, errx.New(errx.Internal, "failed to get organization count") } if ownedCount >= user.MaxOrganizations { @@ -275,7 +275,7 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name org.Slug = &slug if err := s.orgRepo.Create(ctx, org); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to create organization") } @@ -292,7 +292,7 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name } if err := s.orgRepo.AddMember(ctx, member); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) // Rollback org creation _ = s.orgRepo.Delete(ctx, org.ID) return nil, errx.New(errx.Internal, "failed to add owner member") @@ -310,7 +310,7 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name Color: seed.Color, Permissions: seed.Permissions, }); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } @@ -331,7 +331,7 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name func (s *organizationService) Get(ctx context.Context, orgID uuid.UUID) (*models.Organization, *errx.Error) { org, err := s.orgRepo.GetByID(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get organization") } if org == nil { @@ -344,7 +344,7 @@ func (s *organizationService) Get(ctx context.Context, orgID uuid.UUID) (*models func (s *organizationService) GetBySlug(ctx context.Context, slug string) (*models.Organization, *errx.Error) { org, err := s.orgRepo.GetBySlug(ctx, slug) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get organization") } if org == nil { @@ -357,7 +357,7 @@ func (s *organizationService) GetBySlug(ctx context.Context, slug string) (*mode func (s *organizationService) Update(ctx context.Context, orgID uuid.UUID, req *models.UpdateOrganizationRequest) (*models.Organization, *errx.Error) { org, err := s.orgRepo.GetByID(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get organization") } if org == nil { @@ -400,7 +400,7 @@ func (s *organizationService) Update(ctx context.Context, orgID uuid.UUID, req * org.UpdatedAt = time.Now() if err := s.orgRepo.Update(ctx, org); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to update organization") } @@ -410,7 +410,7 @@ func (s *organizationService) Update(ctx context.Context, orgID uuid.UUID, req * // Delete deletes an organization func (s *organizationService) Delete(ctx context.Context, orgID uuid.UUID) *errx.Error { if err := s.orgRepo.Delete(ctx, orgID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to delete organization") } return nil @@ -420,7 +420,7 @@ func (s *organizationService) Delete(ctx context.Context, orgID uuid.UUID) *errx func (s *organizationService) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]models.OrganizationMember, *errx.Error) { members, err := s.orgRepo.GetUserOrganizations(ctx, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get user organizations") } return members, nil @@ -430,7 +430,7 @@ func (s *organizationService) GetUserOrganizations(ctx context.Context, userID u func (s *organizationService) GetUserDefaultOrganization(ctx context.Context, userID uuid.UUID) (*models.Organization, *errx.Error) { org, err := s.orgRepo.GetUserDefaultOrganization(ctx, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get default organization") } return org, nil @@ -440,11 +440,11 @@ func (s *organizationService) GetUserDefaultOrganization(ctx context.Context, us func (s *organizationService) GetMembers(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationMember, *errx.Error) { members, err := s.orgRepo.GetMembers(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get members") } if err := s.orgRepo.HydrateMemberRoles(ctx, orgID, members); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } return members, nil } @@ -453,7 +453,7 @@ func (s *organizationService) GetMembers(ctx context.Context, orgID uuid.UUID) ( func (s *organizationService) GetMembership(ctx context.Context, orgID, userID uuid.UUID) (*models.OrganizationMember, *errx.Error) { member, err := s.orgRepo.GetMember(ctx, orgID, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get membership") } return member, nil @@ -499,7 +499,7 @@ func (s *organizationService) InviteMember(ctx context.Context, orgID uuid.UUID, // Generate invitation token token, tokErr := generateInvitationToken() if tokErr != nil { - sentry.CaptureException(tokErr) + errs.CaptureException(tokErr) return nil, errx.New(errx.Internal, "failed to generate invitation token") } @@ -517,11 +517,11 @@ func (s *organizationService) InviteMember(ctx context.Context, orgID uuid.UUID, } if err := s.orgRepo.CreateInvitation(ctx, inv); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to create invitation") } if err := s.orgRepo.SetInvitationRoles(ctx, inv.ID, roleIDs); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to attach roles") } inv.Roles = toMemberRoles(roles) @@ -541,7 +541,7 @@ func (s *organizationService) resolveRoleSet(ctx context.Context, orgID uuid.UUI for _, id := range ids { role, err := s.orgRepo.GetRoleByID(ctx, orgID, id) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, nil, 0, errx.New(errx.Internal, "failed to load role") } if role == nil { @@ -565,7 +565,7 @@ func toMemberRoles(roles []models.OrganizationRole) []models.MemberRole { func (s *organizationService) AcceptInvitation(ctx context.Context, token string, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error) { inv, err := s.orgRepo.GetInvitationByToken(ctx, token) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get invitation") } if inv == nil { @@ -579,7 +579,7 @@ func (s *organizationService) AcceptInvitation(ctx context.Context, token string func (s *organizationService) AcceptInvitationByID(ctx context.Context, invitationID, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error) { inv, err := s.orgRepo.GetInvitationByID(ctx, invitationID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get invitation") } if inv == nil { @@ -592,7 +592,7 @@ func (s *organizationService) AcceptInvitationByID(ctx context.Context, invitati func (s *organizationService) PreviewInvitation(ctx context.Context, token string) (*models.InvitationPreview, *errx.Error) { inv, err := s.orgRepo.GetInvitationByToken(ctx, token) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get invitation") } if inv == nil { @@ -629,7 +629,7 @@ func (s *organizationService) GetInvitationToken(ctx context.Context, orgID, inv inv, err := s.orgRepo.GetInvitationByID(ctx, invitationID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.New(errx.Internal, "failed to get invitation") } if inv == nil || inv.OrganizationID != orgID { @@ -665,7 +665,7 @@ func (s *organizationService) acceptResolved(ctx context.Context, inv *models.Or // deleted in the meantime are dropped; at least one must survive. invRoleIDs, ierr := s.orgRepo.GetInvitationRoles(ctx, inv.ID) if ierr != nil { - sentry.CaptureException(ierr) + errs.CaptureException(ierr) return nil, errx.New(errx.Internal, "failed to load invitation roles") } var liveRoleIDs []uuid.UUID @@ -673,7 +673,7 @@ func (s *organizationService) acceptResolved(ctx context.Context, inv *models.Or for _, id := range invRoleIDs { role, rerr := s.orgRepo.GetRoleByID(ctx, inv.OrganizationID, id) if rerr != nil { - sentry.CaptureException(rerr) + errs.CaptureException(rerr) return nil, errx.New(errx.Internal, "failed to load role") } if role == nil { @@ -704,7 +704,7 @@ func (s *organizationService) acceptResolved(ctx context.Context, inv *models.Or AcceptedAt: &now, } if err := s.orgRepo.AddMemberWithRoles(ctx, member, liveRoleIDs); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to add member") } @@ -721,7 +721,7 @@ func (s *organizationService) acceptResolved(ctx context.Context, inv *models.Or func (s *organizationService) UpdateMemberRole(ctx context.Context, orgID, actorID, memberUserID uuid.UUID, req *models.UpdateMemberRequest) (*models.OrganizationMember, *errx.Error) { member, err := s.orgRepo.GetMember(ctx, orgID, memberUserID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get member") } if member == nil { @@ -747,13 +747,13 @@ func (s *organizationService) UpdateMemberRole(ctx context.Context, orgID, actor } if err := s.orgRepo.SetMemberRoles(ctx, orgID, memberUserID, roleIDs); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to update roles") } updated, gerr := s.orgRepo.GetMember(ctx, orgID, memberUserID) if gerr != nil { - sentry.CaptureException(gerr) + errs.CaptureException(gerr) return nil, errx.New(errx.Internal, "failed to load member") } if updated != nil { @@ -766,7 +766,7 @@ func (s *organizationService) UpdateMemberRole(ctx context.Context, orgID, actor func (s *organizationService) RemoveMember(ctx context.Context, orgID, memberUserID uuid.UUID) *errx.Error { member, err := s.orgRepo.GetMember(ctx, orgID, memberUserID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to get member") } if member == nil { @@ -779,7 +779,7 @@ func (s *organizationService) RemoveMember(ctx context.Context, orgID, memberUse } if err := s.orgRepo.RemoveMember(ctx, orgID, memberUserID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to remove member") } @@ -790,11 +790,11 @@ func (s *organizationService) RemoveMember(ctx context.Context, orgID, memberUse func (s *organizationService) GetPendingInvitations(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationInvitation, *errx.Error) { invitations, err := s.orgRepo.GetPendingInvitations(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get invitations") } if err := s.orgRepo.HydrateInvitationRoles(ctx, invitations); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } return invitations, nil } @@ -803,7 +803,7 @@ func (s *organizationService) GetPendingInvitations(ctx context.Context, orgID u func (s *organizationService) GetUserPendingInvitations(ctx context.Context, email string) ([]models.OrganizationInvitation, *errx.Error) { invitations, err := s.orgRepo.GetUserPendingInvitations(ctx, email) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get invitations") } return invitations, nil @@ -812,7 +812,7 @@ func (s *organizationService) GetUserPendingInvitations(ctx context.Context, ema // CancelInvitation cancels a pending invitation func (s *organizationService) CancelInvitation(ctx context.Context, invitationID uuid.UUID) *errx.Error { if err := s.orgRepo.DeleteInvitation(ctx, invitationID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to cancel invitation") } return nil @@ -823,7 +823,7 @@ func (s *organizationService) TransferOwnership(ctx context.Context, orgID, newO // Verify new owner is a member member, err := s.orgRepo.GetMember(ctx, orgID, newOwnerUserID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to verify membership") } if member == nil { @@ -831,7 +831,7 @@ func (s *organizationService) TransferOwnership(ctx context.Context, orgID, newO } if err := s.orgRepo.TransferOwnership(ctx, orgID, newOwnerUserID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to transfer ownership") } @@ -842,7 +842,7 @@ func (s *organizationService) TransferOwnership(ctx context.Context, orgID, newO func (s *organizationService) HasPermission(ctx context.Context, orgID, userID uuid.UUID, perm models.OrganizationPermission) (bool, *errx.Error) { member, err := s.orgRepo.GetMember(ctx, orgID, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return false, errx.New(errx.Internal, "failed to check permission") } if member == nil { @@ -877,7 +877,7 @@ func (s *organizationService) CanAddMember(ctx context.Context, orgID uuid.UUID) count, xerr := s.orgRepo.GetMemberCount(ctx, orgID) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return false, errx.New(errx.Internal, "failed to get member count") } @@ -923,7 +923,7 @@ func (s *organizationService) CanAddCampaign(ctx context.Context, orgID uuid.UUI func (s *organizationService) MailboxAllowance(ctx context.Context, orgID uuid.UUID) (*models.MailboxAllowance, *errx.Error) { count, err := s.orgRepo.GetEmailAccountCount(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get email account count") } a := &models.MailboxAllowance{Used: count, SendsPerMailbox: config.FairUseSendsPerMailbox} @@ -936,7 +936,7 @@ func (s *organizationService) MailboxAllowance(ctx context.Context, orgID uuid.U sub, serr := s.subRepo.GetByOrganizationID(ctx, orgID) if serr != nil { - sentry.CaptureException(serr) + errs.CaptureException(serr) return nil, errx.New(errx.Internal, "failed to get subscription") } a.Paid = sub != nil && sub.HasPaidSubscription() @@ -983,7 +983,7 @@ func (s *organizationService) MailboxAllowance(ctx context.Context, orgID uuid.U if a.Allowance != nil { rows, rerr := s.orgRepo.ListLimitRequestsForOrg(ctx, orgID) if rerr != nil { - sentry.CaptureException(rerr) + errs.CaptureException(rerr) } for i := range rows { if rows[i].Field == "max_email_accounts" && rows[i].Status == models.LimitRequestStatusPending { @@ -999,7 +999,7 @@ func (s *organizationService) MailboxAllowance(ctx context.Context, orgID uuid.U func (s *organizationService) GetCampaignCounts(ctx context.Context, orgID uuid.UUID) (total int, active int, err *errx.Error) { t, a, xerr := s.orgRepo.GetCampaignCounts(ctx, orgID) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return 0, 0, errx.New(errx.Internal, "failed to get campaign counts") } return t, a, nil @@ -1009,7 +1009,7 @@ func (s *organizationService) GetCampaignCounts(ctx context.Context, orgID uuid. func (s *organizationService) GetOrganizationLimits(ctx context.Context, orgID uuid.UUID) (*models.OrganizationLimits, *errx.Error) { sub, err := s.subRepo.GetByOrganizationID(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get subscription") } if sub == nil || sub.Plan == nil { @@ -1029,25 +1029,25 @@ func (s *organizationService) GetOrganizationLimits(ctx context.Context, orgID u func (s *organizationService) GetOrganizationCounts(ctx context.Context, orgID uuid.UUID) (*models.OrganizationCounts, *errx.Error) { total, active, err := s.orgRepo.GetCampaignCounts(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get campaign counts") } members, err := s.orgRepo.GetMemberCount(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get member count") } emails, err := s.orgRepo.GetEmailAccountCount(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get email account count") } contacts, err := s.orgRepo.GetContactCount(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get contact count") } @@ -1070,7 +1070,7 @@ func (s *organizationService) CreateEnterpriseInquiry(ctx context.Context, inqui inquiry.CreatedAt = time.Now() if err := s.orgRepo.CreateEnterpriseInquiry(ctx, inquiry); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to create enterprise inquiry") } @@ -1125,7 +1125,7 @@ func (s *organizationService) GetUserAdminPermissions(ctx context.Context, userI func (s *organizationService) SearchOrganizationsForAdmin(ctx context.Context, search *models.AdminOrgSearch) (*models.AdminOrgsResult, *errx.Error) { result, err := s.orgRepo.SearchOrganizationsForAdmin(ctx, search) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to search organizations") } return result, nil @@ -1138,7 +1138,7 @@ func (s *organizationService) SearchOrganizationsForAdmin(ctx context.Context, s func (s *organizationService) GetOrganizationAdminDetail(ctx context.Context, orgID uuid.UUID) (*models.AdminOrgDetail, *errx.Error) { detail, err := s.orgRepo.GetOrganizationAdminDetail(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load organization") } if detail == nil { @@ -1166,7 +1166,7 @@ func (s *organizationService) GetOrganizationAdminDetail(ctx context.Context, or func (s *organizationService) GetOrganizationMembersForAdmin(ctx context.Context, orgID uuid.UUID) ([]models.AdminOrgMember, *errx.Error) { members, err := s.orgRepo.GetOrganizationMembersForAdmin(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load members") } return members, nil @@ -1177,7 +1177,7 @@ func (s *organizationService) GetOrganizationMembersForAdmin(ctx context.Context func (s *organizationService) GetLimitOverrides(ctx context.Context, orgID uuid.UUID) (*models.OrganizationLimitOverrides, *errx.Error) { o, err := s.orgRepo.GetOrganizationLimitOverrides(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load limit overrides") } return o, nil @@ -1190,7 +1190,7 @@ func (s *organizationService) GetLimitOverrides(ctx context.Context, orgID uuid. func (s *organizationService) SetLimitOverrides(ctx context.Context, orgID uuid.UUID, req *models.UpdateOrgOverridesRequest, grantedBy uuid.UUID) (*models.OrganizationLimitOverrides, *errx.Error) { o, err := s.orgRepo.UpsertOrganizationLimitOverrides(ctx, orgID, req, grantedBy) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to write limit overrides") } return o, nil @@ -1374,7 +1374,7 @@ func (s *organizationService) SubmitLimitIncreaseRequest(ctx context.Context, or if strings.Contains(err.Error(), "uq_limit_requests_one_pending_per_field") { return nil, errx.New(errx.Conflict, "a pending request already exists for this field") } - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to submit request") } @@ -1397,7 +1397,7 @@ func (s *organizationService) SubmitLimitIncreaseRequest(ctx context.Context, or func (s *organizationService) ListLimitRequestsForOrg(ctx context.Context, orgID uuid.UUID) ([]models.LimitIncreaseRequest, *errx.Error) { rows, err := s.orgRepo.ListLimitRequestsForOrg(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load requests") } return rows, nil @@ -1409,7 +1409,7 @@ func (s *organizationService) ListLimitRequestsForOrg(ctx context.Context, orgID func (s *organizationService) CancelLimitRequest(ctx context.Context, id, userID uuid.UUID) *errx.Error { lr, err := s.orgRepo.GetLimitRequest(ctx, id) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to load request") } if lr == nil { @@ -1422,7 +1422,7 @@ func (s *organizationService) CancelLimitRequest(ctx context.Context, id, userID return errx.New(errx.BadRequest, "only pending requests can be cancelled") } if err := s.orgRepo.UpdateLimitRequestStatus(ctx, id, models.LimitRequestStatusCancelled, userID, ""); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to cancel request") } return nil @@ -1448,7 +1448,7 @@ func (s *organizationService) AdminListLimitRequests(ctx context.Context, search result, err := s.orgRepo.ListLimitRequestsForAdmin(ctx, search) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load limit requests") } return result, nil @@ -1460,7 +1460,7 @@ func (s *organizationService) AdminListLimitRequests(ctx context.Context, search func (s *organizationService) ApproveLimitRequest(ctx context.Context, id, reviewerID uuid.UUID, notes string) (*models.LimitIncreaseRequest, *errx.Error) { lr, err := s.orgRepo.GetLimitRequest(ctx, id) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load request") } if lr == nil { @@ -1486,7 +1486,7 @@ func (s *organizationService) ApproveLimitRequest(ctx context.Context, id, revie } if err := s.orgRepo.UpdateLimitRequestStatus(ctx, id, models.LimitRequestStatusApproved, reviewerID, notes); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to mark request approved") } lr.Status = models.LimitRequestStatusApproved @@ -1498,7 +1498,7 @@ func (s *organizationService) ApproveLimitRequest(ctx context.Context, id, revie func (s *organizationService) RejectLimitRequest(ctx context.Context, id, reviewerID uuid.UUID, notes string) (*models.LimitIncreaseRequest, *errx.Error) { lr, err := s.orgRepo.GetLimitRequest(ctx, id) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load request") } if lr == nil { @@ -1508,7 +1508,7 @@ func (s *organizationService) RejectLimitRequest(ctx context.Context, id, review return nil, errx.New(errx.BadRequest, "only pending requests can be rejected") } if err := s.orgRepo.UpdateLimitRequestStatus(ctx, id, models.LimitRequestStatusRejected, reviewerID, notes); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to mark request rejected") } lr.Status = models.LimitRequestStatusRejected @@ -1536,7 +1536,7 @@ func (s *organizationService) validateRolePermissions(ctx context.Context, orgID func (s *organizationService) validateActorHoldsPermissions(ctx context.Context, orgID, actorID uuid.UUID, perms models.OrganizationPermission) *errx.Error { actor, err := s.orgRepo.GetMember(ctx, orgID, actorID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to load member") } if actor == nil { @@ -1562,7 +1562,7 @@ func validateRoleName(name string) (string, *errx.Error) { func (s *organizationService) ListRoles(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationRole, *errx.Error) { roles, err := s.orgRepo.ListRoles(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to list roles") } if roles == nil { @@ -1583,7 +1583,7 @@ func (s *organizationService) CreateRole(ctx context.Context, orgID, actorID uui count, err := s.orgRepo.CountRoles(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to count roles") } if count >= MaxCustomRolesPerOrg { @@ -1613,7 +1613,7 @@ func (s *organizationService) CreateRole(ctx context.Context, orgID, actorID uui func (s *organizationService) UpdateRole(ctx context.Context, orgID, actorID, roleID uuid.UUID, req *models.UpdateOrganizationRoleRequest) (*models.OrganizationRole, *errx.Error) { role, err := s.orgRepo.GetRoleByID(ctx, orgID, roleID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load role") } if role == nil { @@ -1648,7 +1648,7 @@ func (s *organizationService) UpdateRole(ctx context.Context, orgID, actorID, ro // Write-through: assigned members pick up the new name + permissions // atomically (their effective access changes live via the audit spine). if err := s.orgRepo.UpdateRole(ctx, role); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to update role") } return role, nil @@ -1661,7 +1661,7 @@ func (s *organizationService) DeleteRole(ctx context.Context, orgID, actorID, ro // the Admin role). role, rerr := s.orgRepo.GetRoleByID(ctx, orgID, roleID) if rerr != nil { - sentry.CaptureException(rerr) + errs.CaptureException(rerr) return errx.New(errx.Internal, "failed to load role") } if role == nil { @@ -1671,7 +1671,7 @@ func (s *organizationService) DeleteRole(ctx context.Context, orgID, actorID, ro return xerr } if err := s.orgRepo.DeleteRole(ctx, orgID, roleID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to delete role") } return nil diff --git a/internal/app/orgtransfer/jobs.go b/internal/app/orgtransfer/jobs.go index c169aad1..4e9e30c2 100644 --- a/internal/app/orgtransfer/jobs.go +++ b/internal/app/orgtransfer/jobs.go @@ -14,8 +14,8 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" @@ -63,7 +63,7 @@ func (s *service) RequestExport( active, err := s.repo.HasActiveExport(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } if active { @@ -77,7 +77,7 @@ func (s *service) RequestExport( IncludeSecrets: req.IncludeSecrets, } if err := s.repo.CreateExportJob(ctx, job); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -87,7 +87,7 @@ func (s *service) RequestExport( runCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), staleTransferDeadline) defer cancel() if err := s.runExport(runCtx, job, passphrase); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) _ = s.repo.FailExportJob(context.WithoutCancel(ctx), job.ID, err.Error()) } }() @@ -145,7 +145,7 @@ func (s *service) runExport(ctx context.Context, job *models.OrgExportJob, passp func (s *service) GetExport(ctx context.Context, orgID, id uuid.UUID) (*models.OrgExportJob, *errx.Error) { job, err := s.repo.GetExportJob(ctx, orgID, id) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } if job == nil { @@ -157,7 +157,7 @@ func (s *service) GetExport(ctx context.Context, orgID, id uuid.UUID) (*models.O func (s *service) ListExports(ctx context.Context, orgID uuid.UUID) ([]models.OrgExportJob, *errx.Error) { jobs, err := s.repo.ListExportJobs(ctx, orgID, jobHistoryLimit) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } return jobs, nil @@ -181,7 +181,7 @@ func (s *service) OpenExport(ctx context.Context, orgID, id uuid.UUID) (io.ReadC body, err := s.blobs.Get(ctx, *job.ArchiveKey) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, nil, errx.New(errx.NotFound, "This archive could not be read from storage.") } return body, job, nil @@ -190,12 +190,12 @@ func (s *service) OpenExport(ctx context.Context, orgID, id uuid.UUID) (io.ReadC func (s *service) DeleteExport(ctx context.Context, orgID, id uuid.UUID) *errx.Error { key, err := s.repo.DeleteExportJob(ctx, orgID, id) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if key != "" && s.blobs != nil { if err := s.blobs.Delete(ctx, key); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } return nil @@ -254,7 +254,7 @@ func (s *service) Preflight( } known, rerr := s.repo.ResolveUsersByEmail(ctx, emails) if rerr != nil { - sentry.CaptureException(rerr) + errs.CaptureException(rerr) return nil, errx.InternalError() } for _, m := range manifest.Members { @@ -271,7 +271,7 @@ func (s *service) Preflight( } destCols, err := s.repo.TableColumns(ctx, mt.Name) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } if len(destCols) == 0 { @@ -346,7 +346,7 @@ func (s *service) RequestImport( active, err := s.repo.HasActiveImport(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } if active { @@ -386,14 +386,14 @@ func (s *service) RequestImport( if s.blobs != nil { key := uploadObjectKey(orgID, uuid.New()) if err := s.blobs.Put(ctx, key, io.NewSectionReader(archive, 0, size), "application/zip"); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "The archive could not be stored for import.") } job.ArchiveKey = &key } if err := s.repo.CreateImportJob(ctx, job); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -425,7 +425,7 @@ func (s *service) RequestImport( _ = s.repo.UpdateImportProgress(runCtx, job.ID, percent, stage) }) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) _ = s.repo.FailImportJob(runCtx, job.ID, err.Error()) return } @@ -435,7 +435,7 @@ func (s *service) RequestImport( // not linger past the point it could still be useful. if job.ArchiveKey != nil && s.blobs != nil { if derr := s.blobs.Delete(runCtx, *job.ArchiveKey); derr != nil { - sentry.CaptureException(derr) + errs.CaptureException(derr) } } }() @@ -446,7 +446,7 @@ func (s *service) RequestImport( func (s *service) GetImport(ctx context.Context, orgID, id uuid.UUID) (*models.OrgImportJob, *errx.Error) { job, err := s.repo.GetImportJob(ctx, orgID, id) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } if job == nil { @@ -458,7 +458,7 @@ func (s *service) GetImport(ctx context.Context, orgID, id uuid.UUID) (*models.O func (s *service) ListImports(ctx context.Context, orgID uuid.UUID) ([]models.OrgImportJob, *errx.Error) { jobs, err := s.repo.ListImportJobs(ctx, orgID, jobHistoryLimit) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } return jobs, nil @@ -478,18 +478,18 @@ func (s *service) PurgeExpiredExports(ctx context.Context) (int, error) { job := &expired[i] if job.ArchiveKey != nil && s.blobs != nil { if derr := s.blobs.Delete(ctx, *job.ArchiveKey); derr != nil { - sentry.CaptureException(derr) + errs.CaptureException(derr) } } if err := s.repo.MarkExportExpired(ctx, job.ID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) continue } purged++ } if err := s.repo.FailStaleTransfers(ctx, time.Now().Add(-staleTransferDeadline)); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } return purged, nil } diff --git a/internal/app/passkey/cache.go b/internal/app/passkey/cache.go index 6a67db24..0af3e338 100644 --- a/internal/app/passkey/cache.go +++ b/internal/app/passkey/cache.go @@ -5,11 +5,11 @@ import ( "encoding/json" "errors" - "github.com/getsentry/sentry-go" "github.com/go-webauthn/webauthn/webauthn" "github.com/google/uuid" "github.com/redis/go-redis/v9" "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/observability/errs" ) // Challenge/session data lives in Redis between a Begin and Finish call, @@ -28,12 +28,12 @@ func loginKey(sessionID uuid.UUID) string { func (s *service) saveSession(ctx context.Context, key string, data *webauthn.SessionData) *errx.Error { raw, err := json.Marshal(data) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if err := s.cache.Set(ctx, key, raw, CeremonyTTL).Err(); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -49,13 +49,13 @@ func (s *service) takeSession(ctx context.Context, key string) (*webauthn.Sessio if errors.Is(err, redis.Nil) { return nil, errx.ErrPasskeySession } - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } var data webauthn.SessionData if err := json.Unmarshal(raw, &data); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } diff --git a/internal/app/passkey/register.go b/internal/app/passkey/register.go index f098c5f0..f690f6d7 100644 --- a/internal/app/passkey/register.go +++ b/internal/app/passkey/register.go @@ -5,11 +5,11 @@ import ( "strings" "unicode/utf8" - "github.com/getsentry/sentry-go" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/observability/errs" ) func (s *service) BeginRegistration(ctx context.Context, userID uuid.UUID) (*protocol.CredentialCreation, *errx.Error) { @@ -27,7 +27,7 @@ func (s *service) BeginRegistration(ctx context.Context, userID uuid.UUID) (*pro webauthn.WithExclusions(wuser.excludeList()), ) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.ErrPasskey } diff --git a/internal/app/referral/service.go b/internal/app/referral/service.go index 9ae9a742..798655dd 100644 --- a/internal/app/referral/service.go +++ b/internal/app/referral/service.go @@ -19,10 +19,10 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/repository" ) @@ -144,7 +144,7 @@ func genCode() string { func (s *service) EnsureCode(ctx context.Context, ownerUserID, ownerOrgID uuid.UUID) (*models.ReferralCode, *errx.Error) { existing, err := s.repo.GetCodeByOwner(ctx, ownerUserID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load referral code") } if existing != nil { @@ -159,7 +159,7 @@ func (s *service) EnsureCode(ctx context.Context, ownerUserID, ownerOrgID uuid.U taken, err := s.repo.GetCodeByCode(ctx, code) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to mint referral code") } if taken != nil { @@ -167,7 +167,7 @@ func (s *service) EnsureCode(ctx context.Context, ownerUserID, ownerOrgID uuid.U } clash, err := s.discountRepo.GetByCode(ctx, code) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to mint referral code") } if clash != nil { @@ -203,7 +203,7 @@ func (s *service) EnsureCode(ctx context.Context, ownerUserID, ownerOrgID uuid.U if again, gerr := s.repo.GetCodeByOwner(ctx, ownerUserID); gerr == nil && again != nil { return again, nil } - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to mint referral code") } return rc, nil @@ -223,12 +223,12 @@ func (s *service) Summary(ctx context.Context, ownerUserID, ownerOrgID uuid.UUID ledger, err := s.repo.GetLedger(ctx, ownerOrgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load referral earnings") } total, pending, qualified, rewarded, err := s.repo.AttributionStats(ctx, ownerOrgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to load referral stats") } @@ -254,7 +254,7 @@ func (s *service) Summary(ctx context.Context, ownerUserID, ownerOrgID uuid.UUID func (s *service) ListAttributions(ctx context.Context, referrerOrgID uuid.UUID, limit, offset int) ([]models.ReferralAttribution, *errx.Error) { rows, err := s.repo.ListAttributionsByReferrer(ctx, referrerOrgID, limit, offset) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to list referrals") } return rows, nil @@ -263,7 +263,7 @@ func (s *service) ListAttributions(ctx context.Context, referrerOrgID uuid.UUID, func (s *service) ListEarnings(ctx context.Context, orgID uuid.UUID, limit, offset int) ([]models.ReferralEarningsTransaction, *errx.Error) { rows, err := s.repo.ListEarnings(ctx, orgID, limit, offset) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.New(errx.Internal, "failed to list referral earnings") } return rows, nil @@ -278,7 +278,7 @@ func (s *service) AttributeSignup(ctx context.Context, code string, inviteeOrgID } rc, err := s.repo.GetCodeByCode(ctx, code) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil // never block signup on a lookup failure } if rc == nil { @@ -331,14 +331,14 @@ func (s *service) QualifyOnConversion(ctx context.Context, inviteeOrgID uuid.UUI return } if err := s.repo.MarkAttributionQualified(ctx, attr.ID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } func (s *service) RewardOnFirstInvoice(ctx context.Context, inviteeOrgID, planID uuid.UUID, eventID string) *errx.Error { attr, err := s.repo.GetAttributionByInviteeOrg(ctx, inviteeOrgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to load referral attribution") } if attr == nil { @@ -358,7 +358,7 @@ func (s *service) RewardOnFirstInvoice(ctx context.Context, inviteeOrgID, planID // surfaced (not silently treated as under-cap) but doesn't block the reward. since := time.Now().Add(-clawbackWindow) if n, cerr := s.repo.CountRewardedByReferrerSince(ctx, attr.ReferrerOrgID, since); cerr != nil { - sentry.CaptureException(cerr) + errs.CaptureException(cerr) } else if n >= MonthlyRewardCap { _ = s.repo.MarkAttributionVoid(ctx, attr.ID, "monthly_cap") return nil @@ -379,7 +379,7 @@ func (s *service) RewardOnFirstInvoice(ctx context.Context, inviteeOrgID, planID // replayed event, so nothing moved. applied, err := s.repo.ApplyReferralReward(ctx, attr, reward, DefaultCurrency, eventID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.Internal, "failed to record referral reward") } if !applied { @@ -412,7 +412,7 @@ func (s *service) ClawbackForInvitee(ctx context.Context, inviteeOrgID uuid.UUID // reverse the same reward. applied, err := s.repo.ApplyReferralClawback(ctx, attr, attr.RewardCents, attr.RewardCurrency, eventID+":clawback", reason) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return } if !applied { @@ -449,11 +449,11 @@ func (s *service) SyncStripeBalance(ctx context.Context, orgID uuid.UUID) { // so a balance that returns to a prior value still yields a distinct key. key := fmt.Sprintf("refbal:%s:%d:%d", orgID, ledger.LifetimeEarnedCents, ledger.BalanceCents) if _, xerr := s.balancer.ApplyCustomerCredit(ctx, sub.StripeCustomerID, delta, ledger.Currency, key); xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return } if err := s.repo.SetStripePushed(ctx, orgID, ledger.BalanceCents); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } diff --git a/internal/app/sequence/handler.go b/internal/app/sequence/handler.go index 547a6e91..b859f5ce 100644 --- a/internal/app/sequence/handler.go +++ b/internal/app/sequence/handler.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - "github.com/getsentry/sentry-go" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" @@ -46,7 +46,7 @@ func (s *sequenceService) Delete(ctx context.Context, userID, campaignID, sequen for _, key := range keys { if err := s.storage.Delete(ctx, key); err != nil { - sentry.CaptureException(fmt.Errorf("sequence %s delete: object %s: %w", sequenceID, key, err)) + errs.CaptureException(fmt.Errorf("sequence %s delete: object %s: %w", sequenceID, key, err)) } } return nil @@ -69,7 +69,7 @@ func (s *sequenceService) stepObjectKeys(ctx context.Context, campaignID, sequen } atts, err := s.attachmentRepo.ListForStep(ctx, cID, sID) if err != nil { - sentry.CaptureException(fmt.Errorf("sequence %s delete: list attachments: %w", sequenceID, err)) + errs.CaptureException(fmt.Errorf("sequence %s delete: list attachments: %w", sequenceID, err)) return nil } keys := make([]string, 0, len(atts)) diff --git a/internal/app/socket/cache.go b/internal/app/socket/cache.go index 51375a2c..9848857c 100644 --- a/internal/app/socket/cache.go +++ b/internal/app/socket/cache.go @@ -4,9 +4,9 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/observability/errs" ) func getTokenKey(id uuid.UUID) string { @@ -15,7 +15,7 @@ func getTokenKey(id uuid.UUID) string { func (s *socketService) saveToken(ctx context.Context, id uuid.UUID, nonce string, expiresAt time.Time) *errx.Error { if err := s.cache.SetEx(ctx, getTokenKey(id), nonce, time.Until(expiresAt)).Err(); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } return nil diff --git a/internal/app/socket/gen.go b/internal/app/socket/gen.go index 0b5339d9..b843017d 100644 --- a/internal/app/socket/gen.go +++ b/internal/app/socket/gen.go @@ -4,9 +4,9 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/crypt" ) @@ -16,13 +16,13 @@ func (s *socketService) GenerateWebsocketToken(ctx context.Context, userID uuid. id := uuid.New() nonce, err := crypt.Nonce() if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.InternalError() } wsToken, err := s.tokenService.GenerateToken(userID, id, "", nonce, issuedAt, expiresAt) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.InternalError() } diff --git a/internal/app/stripe/service.go b/internal/app/stripe/service.go index b7b6e5e8..09a2c027 100644 --- a/internal/app/stripe/service.go +++ b/internal/app/stripe/service.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/google/uuid" "github.com/stripe/stripe-go/v76" @@ -170,7 +170,7 @@ func (s *stripeService) CreateCustomer(ctx context.Context, userID uuid.UUID, em cust, err := customer.New(params) if err != nil { - sentry.CaptureException(fmt.Errorf("stripe customer creation failed: %w", err)) + errs.CaptureException(fmt.Errorf("stripe customer creation failed: %w", err)) return "", errx.New(errx.Internal, "failed to create billing account") } @@ -204,7 +204,7 @@ func (s *stripeService) ApplyCustomerCredit(ctx context.Context, customerID stri } txn, err := balancetxn.New(params) if err != nil { - sentry.CaptureException(fmt.Errorf("stripe customer balance txn failed: %w", err)) + errs.CaptureException(fmt.Errorf("stripe customer balance txn failed: %w", err)) return "", errx.New(errx.Internal, "failed to apply referral credit") } return txn.ID, nil @@ -310,7 +310,7 @@ func (s *stripeService) CreateCheckoutSession(ctx context.Context, userID uuid.U if reservedID != nil { _ = s.discountService.CancelRedemptionByID(ctx, *reservedID) } - sentry.CaptureException(fmt.Errorf("stripe checkout session failed: %w", err)) + errs.CaptureException(fmt.Errorf("stripe checkout session failed: %w", err)) return nil, errx.New(errx.Internal, "failed to create checkout session") } @@ -319,7 +319,7 @@ func (s *stripeService) CreateCheckoutSession(ctx context.Context, userID uuid.U // checkout.session.expired. if reservedID != nil { if xerr := s.discountService.AttachRedemptionStripe(ctx, *reservedID, &sess.ID, couponID); xerr != nil { - sentry.CaptureException(fmt.Errorf("attach discount redemption refs failed: %s", xerr.Message)) + errs.CaptureException(fmt.Errorf("attach discount redemption refs failed: %s", xerr.Message)) } } @@ -359,7 +359,7 @@ func (s *stripeService) mintCoupon(dc *models.DiscountCode) (string, *errx.Error c, err := coupon.New(params) if err != nil { - sentry.CaptureException(fmt.Errorf("stripe coupon creation failed: %w", err)) + errs.CaptureException(fmt.Errorf("stripe coupon creation failed: %w", err)) return "", errx.New(errx.Internal, "failed to apply discount") } return c.ID, nil @@ -410,7 +410,7 @@ func (s *stripeService) CreateCreditCheckoutSession(ctx context.Context, userID, sess, serr := session.New(params) if serr != nil { - sentry.CaptureException(fmt.Errorf("stripe credit checkout session failed: %w", serr)) + errs.CaptureException(fmt.Errorf("stripe credit checkout session failed: %w", serr)) return nil, errx.New(errx.Internal, "failed to create checkout session") } return sess, nil @@ -499,7 +499,7 @@ func (s *stripeService) CreatePortalSession(ctx context.Context, customerID, ret sess, err := portalsession.New(params) if err != nil { - sentry.CaptureException(fmt.Errorf("stripe portal session failed: %w", err)) + errs.CaptureException(fmt.Errorf("stripe portal session failed: %w", err)) return "", errx.New(errx.Internal, "failed to create billing portal session") } @@ -521,7 +521,7 @@ func (s *stripeService) CancelSubscription(ctx context.Context, subscriptionID s _, err := subscription.Update(subscriptionID, params) if err != nil { - sentry.CaptureException(fmt.Errorf("stripe subscription cancel failed: %w", err)) + errs.CaptureException(fmt.Errorf("stripe subscription cancel failed: %w", err)) return errx.New(errx.Internal, "failed to update subscription") } @@ -630,7 +630,7 @@ func (s *stripeService) ChangePlan(ctx context.Context, orgID uuid.UUID, newPlan // a direct plan change). Best-effort: the discount is already live. if reservedID != nil { if xerr := s.discountService.AttachRedemptionStripe(ctx, *reservedID, nil, couponID); xerr != nil { - sentry.CaptureException(fmt.Errorf("attach discount redemption refs failed: %s", xerr.Message)) + errs.CaptureException(fmt.Errorf("attach discount redemption refs failed: %s", xerr.Message)) } } @@ -867,7 +867,7 @@ func (s *stripeService) handleCheckoutCompleted(ctx context.Context, event *stri subID = &sub.ID } if xerr := s.discountService.MarkRedemptionApplied(ctx, checkoutSession.ID, subID); xerr != nil { - sentry.CaptureException(fmt.Errorf("mark discount redemption applied failed: %s", xerr.Message)) + errs.CaptureException(fmt.Errorf("mark discount redemption applied failed: %s", xerr.Message)) } } @@ -1130,7 +1130,7 @@ func (s *stripeService) handleInvoicePaid(ctx context.Context, event *stripe.Eve (inv.BillingReason == stripe.InvoiceBillingReasonSubscriptionCreate || inv.BillingReason == stripe.InvoiceBillingReasonSubscriptionCycle) { if err := s.credits.ResetMonthlyAllowance(ctx, sub.OrganizationID, plan.MonthlyCredits, event.ID); err != nil { - sentry.CaptureException(fmt.Errorf("monthly credit reset failed for org %s: %w", sub.OrganizationID, err)) + errs.CaptureException(fmt.Errorf("monthly credit reset failed for org %s: %w", sub.OrganizationID, err)) } else if s.audit != nil { s.audit.LogAction(ctx, sub.OrganizationID, sub.UserID, models.AuditActionUpdate, models.AuditEntityCreditGrant, nil, "", "", nil, map[string]string{ "reason": "monthly_reset", diff --git a/internal/app/token/cache.go b/internal/app/token/cache.go index 72b47adc..296c4681 100644 --- a/internal/app/token/cache.go +++ b/internal/app/token/cache.go @@ -6,11 +6,11 @@ import ( "errors" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/redis/go-redis/v9" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) func getSessionKey(id uuid.UUID) string { @@ -20,7 +20,7 @@ func getSessionKey(id uuid.UUID) string { func (s *tokenService) saveSession(ctx context.Context, session *models.Session, ttl time.Duration) *errx.Error { data, err := json.Marshal(session) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -37,13 +37,13 @@ func (s *tokenService) getSession(ctx context.Context, sessionID uuid.UUID) (*mo if errors.Is(err, redis.Nil) { return nil, nil } - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } var session models.Session if err := json.Unmarshal(data, &session); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -52,7 +52,7 @@ func (s *tokenService) getSession(ctx context.Context, sessionID uuid.UUID) (*mo func (s *tokenService) deleteSession(ctx context.Context, sessionID uuid.UUID) *errx.Error { if err := s.cache.Del(ctx, getSessionKey(sessionID)).Err(); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } diff --git a/internal/app/token/gen.go b/internal/app/token/gen.go index 94d8c1d0..c708226d 100644 --- a/internal/app/token/gen.go +++ b/internal/app/token/gen.go @@ -5,13 +5,13 @@ import ( "net/netip" "time" - "github.com/getsentry/sentry-go" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/mileusna/useragent" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/db" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/crypt" ) @@ -80,13 +80,13 @@ func (s *tokenService) GenerateSessionWithOrg(ctx context.Context, userID uuid.U ip, err := netip.ParseAddr(ipaddr) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } ipinfo, err := s.geo.Lookup(ip) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -140,14 +140,14 @@ func (s *tokenService) GenerateSessionWithOrg(ctx context.Context, userID uuid.U accessTokenExpiresAt := issuedAt.Add(AccessTokenLifeTime) accessNonce, err := crypt.Nonce() if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } session.AccessNonce = accessNonce accessToken, err := s.GenerateToken(userID, session.ID, email, accessNonce, issuedAt, accessTokenExpiresAt) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -155,14 +155,14 @@ func (s *tokenService) GenerateSessionWithOrg(ctx context.Context, userID uuid.U session.ExpiresAt = &refreshTokenExpiresAt refreshNonce, err := crypt.Nonce() if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } session.RefreshNonce = refreshNonce refreshToken, err := s.GenerateToken(userID, session.ID, email, refreshNonce, issuedAt, refreshTokenExpiresAt) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } diff --git a/internal/app/token/refresh.go b/internal/app/token/refresh.go index 76141ac6..f388190a 100644 --- a/internal/app/token/refresh.go +++ b/internal/app/token/refresh.go @@ -4,9 +4,9 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/crypt" ) @@ -39,31 +39,31 @@ func (s *tokenService) RefreshToken(ctx context.Context, refreshToken string) (* accessTokenExpiresAt := issuedAt.Add(AccessTokenLifeTime) accessNonce, xerr := crypt.Nonce() if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } newAccessToken, xerr := s.GenerateToken(sess.UserID, sess.ID, "", accessNonce, issuedAt, accessTokenExpiresAt) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } refreshTokenExpiresAt := issuedAt.Add(RefreshTokenLifeTime) refreshNonce, xerr := crypt.Nonce() if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } newRefreshToken, xerr := s.GenerateToken(sess.UserID, sess.ID, "", refreshNonce, issuedAt, refreshTokenExpiresAt) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } if err := s.tokenRepository.RefreshToken(ctx, sess.ID, t.Nonce, accessNonce, refreshNonce, issuedAt); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -75,7 +75,7 @@ func (s *tokenService) RefreshToken(ctx context.Context, refreshToken string) (* // Dropping the cache forces the next GetSession to re-read from the // updated Postgres row. if err := s.deleteSession(ctx, sess.ID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) // Don't fail the refresh — the worst case if the delete somehow // failed is the user retries; we already returned the new tokens. } diff --git a/internal/app/unibox/email.go b/internal/app/unibox/email.go index 9f25d4dc..96f7c22e 100644 --- a/internal/app/unibox/email.go +++ b/internal/app/unibox/email.go @@ -4,10 +4,10 @@ import ( "context" "strings" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/mailhtml" ) @@ -27,7 +27,7 @@ func (s *uniboxService) GetByID( { msg, owner, err := s.uniboxRepository.GetByIDForOrg(ctx, orgID, id) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } ownerID = owner @@ -67,7 +67,7 @@ func (s *uniboxService) GetByID( // still has its preview text. Returning 500 made the whole message // unopenable instead of showing what we have. if !fixtureMessage { - sentry.CaptureException(err) + errs.CaptureException(err) } resp.BodyPlain = snippet resp.BodyTruncated = !fixtureMessage diff --git a/internal/app/unibox/grounding.go b/internal/app/unibox/grounding.go index a52ad9b3..ae702927 100644 --- a/internal/app/unibox/grounding.go +++ b/internal/app/unibox/grounding.go @@ -3,10 +3,10 @@ package unibox import ( "context" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/generation" ) @@ -20,7 +20,7 @@ const GroundingLimitMax = 20 func (s *uniboxService) ThreadGrounding(ctx context.Context, orgID uuid.UUID, threadID string, limit int) ([]models.MessageGrounding, *errx.Error) { out, err := s.uniboxRepository.GroundingByThread(ctx, orgID, threadID, clampGroundingLimit(limit)) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } return out, nil @@ -31,7 +31,7 @@ func (s *uniboxService) ThreadGrounding(ctx context.Context, orgID uuid.UUID, th func (s *uniboxService) AddressGrounding(ctx context.Context, orgID uuid.UUID, address string, limit int) ([]models.MessageGrounding, *errx.Error) { out, err := s.uniboxRepository.GroundingByAddress(ctx, orgID, address, clampGroundingLimit(limit)) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } return out, nil diff --git a/internal/app/unibox/incoming.go b/internal/app/unibox/incoming.go index 754b382f..17d2d535 100644 --- a/internal/app/unibox/incoming.go +++ b/internal/app/unibox/incoming.go @@ -4,10 +4,10 @@ import ( "context" "strconv" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) func (s *uniboxService) Incoming( @@ -36,7 +36,7 @@ func (s *uniboxService) Incoming( } if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } diff --git a/internal/app/unibox/labels.go b/internal/app/unibox/labels.go index f836094d..15460bce 100644 --- a/internal/app/unibox/labels.go +++ b/internal/app/unibox/labels.go @@ -3,10 +3,10 @@ package unibox import ( "context" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) // SetThreadLabels replaces the conversation's label set with the given @@ -18,7 +18,7 @@ func (s *uniboxService) SetThreadLabels(ctx context.Context, userID uuid.UUID, t } labels, err := s.uniboxRepository.SetThreadLabels(ctx, userID, threadID, categoryIDs) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } return labels, nil @@ -31,7 +31,7 @@ func (s *uniboxService) ListThreadLabels(ctx context.Context, userID uuid.UUID, } labels, err := s.uniboxRepository.ListThreadLabels(ctx, userID, threadID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } return labels, nil diff --git a/internal/app/unibox/overview.go b/internal/app/unibox/overview.go index a6f7c023..3e9f23b4 100644 --- a/internal/app/unibox/overview.go +++ b/internal/app/unibox/overview.go @@ -3,11 +3,11 @@ package unibox import ( "context" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) // Overview rolls up the counts the dashboard's scope rail and top @@ -16,7 +16,7 @@ import ( func (s *uniboxService) Overview(ctx context.Context, orgID, userID uuid.UUID) (*models.UniboxOverview, *errx.Error) { o, err := s.uniboxRepository.Overview(ctx, orgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } if len(o.Mailboxes) > OverviewMaxMailboxes { @@ -34,7 +34,7 @@ func (s *uniboxService) Overview(ctx context.Context, orgID, userID uuid.UUID) ( if n, err := s.taskRepo.CountScheduledForUser(ctx, userID); err == nil { o.ScheduledPending = n } else { - sentry.CaptureException(err) + errs.CaptureException(err) } } // Static-for-now cap. Surfacing it in the overview lets the diff --git a/internal/app/unibox/scheduled.go b/internal/app/unibox/scheduled.go index ccee14c7..bac076cf 100644 --- a/internal/app/unibox/scheduled.go +++ b/internal/app/unibox/scheduled.go @@ -4,9 +4,9 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/rs/zerolog/log" + "github.com/warmbly/warmbly/internal/observability/errs" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -35,7 +35,7 @@ const snippetMaxLen = 240 func (s *uniboxService) ListScheduled(ctx context.Context, userID uuid.UUID) ([]models.UniboxScheduledItem, *errx.Error) { rows, err := s.taskRepo.ListScheduledForUser(ctx, userID, ScheduledListMax) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -68,7 +68,7 @@ func (s *uniboxService) ListScheduledByThread(ctx context.Context, userID uuid.U } rows, err := s.taskRepo.ListScheduledForUserByThread(ctx, userID, threadID, ScheduledThreadListMax) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -113,7 +113,7 @@ func (s *uniboxService) ListScheduledByThread(ctx context.Context, userID uuid.U func (s *uniboxService) CancelScheduled(ctx context.Context, userID, taskID uuid.UUID) *errx.Error { cloudTaskName, cancelled, err := s.taskRepo.CancelScheduledByUser(ctx, taskID, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if !cancelled { @@ -137,7 +137,7 @@ func (s *uniboxService) CancelScheduled(ctx context.Context, userID, taskID uuid // but doesn't change the response — the DB is the source // of truth and the handler safety-net catches stragglers. if st, ok := status.FromError(derr); !ok || st.Code() != codes.NotFound { - sentry.CaptureException(derr) + errs.CaptureException(derr) log.Warn(). Err(derr). Str("task_id", taskID.String()). diff --git a/internal/app/unibox/search.go b/internal/app/unibox/search.go index f533b89b..5001f7ad 100644 --- a/internal/app/unibox/search.go +++ b/internal/app/unibox/search.go @@ -3,10 +3,10 @@ package unibox import ( "context" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) // Search searches emails with filters @@ -31,7 +31,7 @@ func (s *uniboxService) Search( // sender filter is handled inside Search via params.Sender. resp, err := s.uniboxRepository.Search(ctx, orgID, userID, params) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -46,7 +46,7 @@ func (s *uniboxService) GetUnseenCount( ) (int64, *errx.Error) { count, err := s.uniboxRepository.GetUnseenCount(ctx, orgID, emailAccountID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return 0, errx.InternalError() } diff --git a/internal/app/unibox/seen.go b/internal/app/unibox/seen.go index 2b586768..0844581f 100644 --- a/internal/app/unibox/seen.go +++ b/internal/app/unibox/seen.go @@ -3,15 +3,15 @@ package unibox import ( "context" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) func (s *uniboxService) MarkSeen(ctx context.Context, userID, emailID uuid.UUID, seen bool) *errx.Error { if err := s.uniboxRepository.MarkSeen(ctx, userID, emailID, seen); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -33,14 +33,14 @@ func (s *uniboxService) MarkSeenBulk(ctx context.Context, orgID uuid.UUID, data return nil, errx.ErrUniboxFolder } if err := s.uniboxRepository.MarkSeenByFolder(ctx, orgID, data.Folder, data.Seen); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } return data, nil } if err := s.uniboxRepository.MarkSeenBulk(ctx, orgID, data.EmailIDs, data.Seen); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } diff --git a/internal/app/unibox/snooze.go b/internal/app/unibox/snooze.go index 6027f72e..cc772458 100644 --- a/internal/app/unibox/snooze.go +++ b/internal/app/unibox/snooze.go @@ -4,10 +4,10 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) func (s *uniboxService) Snooze(ctx context.Context, userID uuid.UUID, threadID string, until time.Time) (*models.UniboxSnooze, *errx.Error) { @@ -27,7 +27,7 @@ func (s *uniboxService) Snooze(ctx context.Context, userID uuid.UUID, threadID s row, err := s.uniboxRepository.UpsertSnooze(ctx, userID, threadID, until.UTC()) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } return row, nil @@ -38,7 +38,7 @@ func (s *uniboxService) Unsnooze(ctx context.Context, userID uuid.UUID, threadID return errx.New(errx.BadRequest, "thread_id is required") } if err := s.uniboxRepository.DeleteSnooze(ctx, userID, threadID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } return nil @@ -47,7 +47,7 @@ func (s *uniboxService) Unsnooze(ctx context.Context, userID uuid.UUID, threadID func (s *uniboxService) ListSnoozes(ctx context.Context, userID uuid.UUID) ([]models.UniboxSnooze, *errx.Error) { rows, err := s.uniboxRepository.ListSnoozes(ctx, userID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } return rows, nil diff --git a/internal/app/unibox/thread.go b/internal/app/unibox/thread.go index fa02cdae..b730a85f 100644 --- a/internal/app/unibox/thread.go +++ b/internal/app/unibox/thread.go @@ -4,10 +4,10 @@ import ( "context" "strconv" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) // GetByThread returns every message in a thread. limit/cursor are @@ -32,7 +32,7 @@ func (s *uniboxService) GetByThread( resp, err := s.uniboxRepository.GetByThread(ctx, orgID, emailID, threadID, l, cursor) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -48,7 +48,7 @@ func (s *uniboxService) LatestMessageIDInThread( ) (string, *errx.Error) { messageID, err := s.uniboxRepository.LatestMessageIDInThread(ctx, orgID, threadID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", errx.InternalError() } return messageID, nil diff --git a/internal/app/user/cache.go b/internal/app/user/cache.go index 885808d5..5e0db17f 100644 --- a/internal/app/user/cache.go +++ b/internal/app/user/cache.go @@ -5,11 +5,11 @@ import ( "encoding/json" "errors" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/redis/go-redis/v9" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) func getUserKey(id uuid.UUID) string { @@ -19,13 +19,13 @@ func getUserKey(id uuid.UUID) string { func (s *userService) SaveUser(ctx context.Context, user *models.User) *errx.Error { raw, err := json.Marshal(user) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } key := getUserKey(user.ID) if err := s.cache.SetEx(ctx, key, raw, UserTTL).Err(); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -38,13 +38,13 @@ func (s *userService) getUser(ctx context.Context, userID uuid.UUID) (*models.Us if errors.Is(err, redis.Nil) { return nil, nil } - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } var user models.User if err := json.Unmarshal(data, &user); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } diff --git a/internal/app/warmupcontent/batch.go b/internal/app/warmupcontent/batch.go index b652d43e..b45dfa1a 100644 --- a/internal/app/warmupcontent/batch.go +++ b/internal/app/warmupcontent/batch.go @@ -6,9 +6,9 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/rs/zerolog/log" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/pkg/generation" @@ -159,7 +159,7 @@ func (s *service) PollBatches(ctx context.Context) error { for i := range jobs { job := &jobs[i] if err := s.pollBatchJob(ctx, job); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) log.Warn().Err(err).Str("job_id", job.ID.String()).Str("batch_id", job.BatchID). Msg("warmup batch generation: poll failed") } @@ -307,7 +307,7 @@ func (s *service) ingestBatch(ctx context.Context, job *models.WarmupGenerationJ } if err := s.repo.InsertConversation(ctx, record); err != nil { job.FailedCount++ - sentry.CaptureException(err) + errs.CaptureException(err) continue } job.GeneratedCount++ diff --git a/internal/app/worker/event_email_validation.go b/internal/app/worker/event_email_validation.go index 769df6c4..d65a00c1 100644 --- a/internal/app/worker/event_email_validation.go +++ b/internal/app/worker/event_email_validation.go @@ -4,9 +4,9 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" "github.com/warmbly/warmbly/internal/email" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) func (w *WorkerService) HandleEmailValidation(ctx context.Context, data models.EventWorkerEmailValidation) error { @@ -15,19 +15,19 @@ func (w *WorkerService) HandleEmailValidation(ctx context.Context, data models.E cipher, err := w.CipherService.Cipher(ctx, data.OrgID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil } data.Credentials.IMAP.Password, err = cipher.Decrypt(ctx, data.Credentials.IMAP.Password) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil } data.Credentials.SMTP.Password, err = cipher.Decrypt(ctx, data.Credentials.SMTP.Password) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil } @@ -40,7 +40,7 @@ func (w *WorkerService) HandleEmailValidation(ctx context.Context, data models.E ok := false defer func() { if r := recover(); r != nil { - sentry.CurrentHub().Recover(r) + errs.Recover(r) } results <- ok }() @@ -65,7 +65,7 @@ func (w *WorkerService) HandleEmailValidation(ctx context.Context, data models.E } if err := w.Cache.Publish(ctx, "email_validation:"+data.ProcessID.String(), msg).Err(); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil } diff --git a/internal/app/worker/wmail/err.go b/internal/app/worker/wmail/err.go index f46c31f4..a1cde753 100644 --- a/internal/app/worker/wmail/err.go +++ b/internal/app/worker/wmail/err.go @@ -4,17 +4,16 @@ import ( "errors" "time" - "github.com/getsentry/sentry-go" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) func (w *WMail) CaptureError(err error) { - sentry.WithScope(func(scope *sentry.Scope) { - scope.SetTag("user_id", w.UserID.String()) - scope.SetTag("email_id", w.ID.String()) - sentry.CaptureException(err) - }) + errs.CaptureException(err, + errs.Tag("user_id", w.UserID.String()), + errs.Tag("email_id", w.ID.String()), + ) // If the error is a critical mail error (auth, disabled, rate limit), publish // an event so the consumer can mark the account inactive and stop syncing. diff --git a/internal/events/publisher.go b/internal/events/publisher.go index 1f83df55..8ee4f7b4 100644 --- a/internal/events/publisher.go +++ b/internal/events/publisher.go @@ -7,7 +7,6 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/app/cipher" "github.com/warmbly/warmbly/internal/infrastructure/codec" @@ -15,6 +14,7 @@ import ( "github.com/warmbly/warmbly/internal/infrastructure/kafka" "github.com/warmbly/warmbly/internal/infrastructure/storage" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/emsg" "github.com/warmbly/warmbly/internal/repository" ) @@ -328,22 +328,22 @@ func (p *publisher) publish(topic, key string, event interface{}) error { } if p.codec == nil { - sentry.CaptureException(fmt.Errorf("codec not configured, topic: %s", topic)) + errs.CaptureException(fmt.Errorf("codec not configured, topic: %s", topic)) return fmt.Errorf("codec not configured") } if p.bus == nil { - sentry.CaptureException(fmt.Errorf("event bus not configured, topic: %s", topic)) + errs.CaptureException(fmt.Errorf("event bus not configured, topic: %s", topic)) return fmt.Errorf("event bus not configured") } ctx := context.Background() data, err := p.codec.Serialize(ctx, topic, event) if err != nil { - sentry.CaptureException(fmt.Errorf("failed to serialize event: %w", err)) + errs.CaptureException(fmt.Errorf("failed to serialize event: %w", err)) return err } if err := p.bus.Publish(ctx, topic, key, data); err != nil { - sentry.CaptureException(fmt.Errorf("failed to publish event: %w", err)) + errs.CaptureException(fmt.Errorf("failed to publish event: %w", err)) return err } return nil diff --git a/internal/formserver/server.go b/internal/formserver/server.go index 51e214ea..7308cae9 100644 --- a/internal/formserver/server.go +++ b/internal/formserver/server.go @@ -14,6 +14,7 @@ import ( "encoding/json" "errors" "fmt" + "html" "net/http" "os" "path/filepath" @@ -45,6 +46,15 @@ type Config struct { // endpoints, the service's only dependency. BackendURL string InternalToken string + // BrowserSentryDSN is stamped into the page shell so the form app can + // report browser errors. Empty, which is the default, means the app never + // loads the SDK. + BrowserSentryDSN string + // Release tags those browser events with the build serving them. + Release string + // Environment is the deployment label those events carry, so staging and + // production form pages are separable in one project. + Environment string // StaticDir is the built forms app (forms/dist): index.html is the page // shell, assets/ the hashed bundles. StaticDir string @@ -77,6 +87,12 @@ func New(cfg Config) (*Server, error) { if err != nil { return nil, fmt.Errorf("formserver: cannot read %s/index.html (run `pnpm build` in forms/): %w", cfg.StaticDir, err) } + // The browser DSN and the release are the same for every request, so they + // are stamped once here rather than per shell serve. An empty DSN leaves + // the placeholder empty and the page loads no reporting SDK at all. + shell = stampMeta(shell, "wf-sentry-dsn", cfg.BrowserSentryDSN) + shell = stampMeta(shell, "wf-release", cfg.Release) + shell = stampMeta(shell, "wf-environment", cfg.Environment) limit := cfg.SubmitLimit if limit <= 0 { limit = submitDefaultLimit @@ -147,6 +163,17 @@ func (s *Server) fetchForm(c *gin.Context) (*formwire.PublicForm, error) { // stamps the real render token into it. const wfTokenPlaceholder = `` +// stampMeta fills in one of the empty meta tags forms/index.html ships. An +// empty value is left alone, and so is a shell built before the tag existed. +func stampMeta(shell []byte, name, value string) []byte { + if value == "" { + return shell + } + placeholder := []byte(``) + stamped := []byte(``) + return bytes.Replace(shell, placeholder, stamped, 1) +} + // ServeFormShell serves the app shell for a published form. Public and // unauthenticated: the unguessable public id is the capability. The form is // resolved before serving so unknown ids 404 like any dead link, the diff --git a/internal/formserver/server_test.go b/internal/formserver/server_test.go index d8b3582e..49bb322e 100644 --- a/internal/formserver/server_test.go +++ b/internal/formserver/server_test.go @@ -402,3 +402,33 @@ func TestFormServerSubmitRateLimit(t *testing.T) { t.Fatalf("third submit status %d, want 429", w.Code) } } + +// The browser DSN is stamped into the shell once at construction, so a page a +// stranger loads either carries the operator's DSN or carries an empty tag and +// loads no reporting SDK at all. There is no third state. +func TestFormShellStampsBrowserSentryDSN(t *testing.T) { + shell := []byte(`` + + `` + + `` + + ``) + + unset := string(stampMeta(stampMeta(shell, "wf-sentry-dsn", ""), "wf-release", "")) + if !strings.Contains(unset, ``) { + t.Fatalf("empty DSN should leave the placeholder untouched, got %s", unset) + } + + set := string(stampMeta(stampMeta(shell, "wf-sentry-dsn", `https://k@example.invalid/1`), "wf-release", "v1.2.3")) + if !strings.Contains(set, ``) { + t.Fatalf("DSN was not stamped, got %s", set) + } + if !strings.Contains(set, ``) { + t.Fatalf("release was not stamped, got %s", set) + } + + // A DSN is operator-supplied, so it must not be able to close the tag and + // inject markup into a page served to the public. + escaped := string(stampMeta(shell, "wf-sentry-dsn", `" />") { + t.Fatalf("stamped value was not escaped: %s", escaped) + } +} diff --git a/internal/infrastructure/db/error.go b/internal/infrastructure/db/error.go index 932ea5bc..ffd72727 100644 --- a/internal/infrastructure/db/error.go +++ b/internal/infrastructure/db/error.go @@ -1,27 +1,57 @@ package db import ( + "errors" "fmt" + "strconv" + "strings" - "github.com/getsentry/sentry-go" "github.com/jackc/pgx/v5/pgconn" + "github.com/warmbly/warmbly/internal/observability/errs" ) func CaptureError(err error, query string, params []any, operation string) { if err == nil { return } - wrappedErr := fmt.Errorf("%s failed: %w (query: %s, params: %v)", operation, err, query, params) - sentry.CaptureException(wrappedErr) - sentry.WithScope(func(scope *sentry.Scope) { - scope.SetTag("db.operation", operation) - scope.SetTag("db.query", query) // Sanitize sensitive params in prod - if pgErr, ok := err.(*pgconn.PgError); ok { - scope.SetExtra("pg.code", pgErr.Code) // e.g., "23505" for unique violation - scope.SetExtra("pg.detail", pgErr.Detail) - scope.SetExtra("pg.hint", pgErr.Hint) - } + shape := paramShape(params) + wrappedErr := fmt.Errorf("%s failed: %w (query: %s, params: %s)", operation, err, query, shape) - scope.SetExtra("db.params", params) // Redact if sensitive - }) + opts := []errs.Option{ + errs.Tag("db.operation", operation), + errs.Tag("db.query", query), + errs.Extra("db.params", shape), + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + opts = append(opts, + errs.Extra("pg.code", pgErr.Code), // e.g., "23505" for unique violation + errs.Extra("pg.detail", pgErr.Detail), + errs.Extra("pg.hint", pgErr.Hint), + ) + } + + errs.CaptureException(wrappedErr, opts...) +} + +// paramShape describes the query's arguments without their values. +// +// The values are the row: email addresses, password hashes, tokens, message +// bodies. The query text next to them already says which column each one is, +// so reporting them would put the most sensitive data in the system into an +// error tracker. How many there were and of what type is enough to tell one +// call site from another, which is all the report is for. +func paramShape(params []any) string { + if len(params) == 0 { + return "none" + } + kinds := make([]string, 0, len(params)) + for _, p := range params { + if p == nil { + kinds = append(kinds, "nil") + continue + } + kinds = append(kinds, fmt.Sprintf("%T", p)) + } + return strconv.Itoa(len(params)) + " (" + strings.Join(kinds, ", ") + ")" } diff --git a/internal/infrastructure/pubsub/client.go b/internal/infrastructure/pubsub/client.go index 1615ca95..7126b9d8 100644 --- a/internal/infrastructure/pubsub/client.go +++ b/internal/infrastructure/pubsub/client.go @@ -7,8 +7,8 @@ import ( "time" "cloud.google.com/go/pubsub" - "github.com/getsentry/sentry-go" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/observability/errs" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -197,7 +197,7 @@ func (p *StreamingPublisher) PublishTaskStatus(ctx context.Context, userID strin } if err := p.client.Publish(ctx, TopicTaskStatus, event, attrs); err != nil { - sentry.CaptureException(fmt.Errorf("failed to publish task status: %w", err)) + errs.CaptureException(fmt.Errorf("failed to publish task status: %w", err)) } } @@ -227,7 +227,7 @@ func (p *StreamingPublisher) PublishEmailError(ctx context.Context, userID strin } if err := p.client.Publish(ctx, TopicEmailError, event, attrs); err != nil { - sentry.CaptureException(fmt.Errorf("failed to publish email error: %w", err)) + errs.CaptureException(fmt.Errorf("failed to publish email error: %w", err)) } } @@ -256,7 +256,7 @@ func (p *StreamingPublisher) PublishEmailWarning(ctx context.Context, userID str } if err := p.client.Publish(ctx, TopicEmailWarning, event, attrs); err != nil { - sentry.CaptureException(fmt.Errorf("failed to publish email warning: %w", err)) + errs.CaptureException(fmt.Errorf("failed to publish email warning: %w", err)) } } @@ -281,7 +281,7 @@ func (p *StreamingPublisher) PublishCampaignProgress(ctx context.Context, userID } if err := p.client.Publish(ctx, TopicCampaignUpdate, event, attrs); err != nil { - sentry.CaptureException(fmt.Errorf("failed to publish campaign progress: %w", err)) + errs.CaptureException(fmt.Errorf("failed to publish campaign progress: %w", err)) } } @@ -307,6 +307,6 @@ func (p *StreamingPublisher) PublishWarmupStats(ctx context.Context, userID stri } if err := p.client.Publish(ctx, TopicWarmupUpdate, event, attrs); err != nil { - sentry.CaptureException(fmt.Errorf("failed to publish warmup stats: %w", err)) + errs.CaptureException(fmt.Errorf("failed to publish warmup stats: %w", err)) } } diff --git a/internal/jobs/audit_retention.go b/internal/jobs/audit_retention.go index 9e129d1f..068818e8 100644 --- a/internal/jobs/audit_retention.go +++ b/internal/jobs/audit_retention.go @@ -4,8 +4,8 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" "github.com/warmbly/warmbly/internal/config" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/repository" ) @@ -42,7 +42,7 @@ func (j *AuditRetentionJob) Run(ctx context.Context) error { cutoff := time.Now().AddDate(0, 0, -days) if _, err := j.repo.PruneOlderThan(ctx, cutoff); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return err } return nil @@ -70,14 +70,14 @@ func (s *AuditRetentionScheduler) Start(ctx context.Context) { defer ticker.Stop() if err := s.job.Run(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } for { select { case <-ticker.C: if err := s.job.Run(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } case <-s.stopCh: return diff --git a/internal/jobs/danger_zone.go b/internal/jobs/danger_zone.go index 0f31f41f..5bdd3b7f 100644 --- a/internal/jobs/danger_zone.go +++ b/internal/jobs/danger_zone.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/app/dangerzone" ) @@ -26,10 +26,10 @@ func NewDangerZoneJob(svc dangerzone.Service) *DangerZoneJob { // got marked failed. func (j *DangerZoneJob) Run(ctx context.Context) { if _, _, err := j.svc.ExecuteDuePendingDeletions(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } if err := j.svc.DispatchReminders(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } diff --git a/internal/jobs/email_verification.go b/internal/jobs/email_verification.go index d3002d63..792e6d06 100644 --- a/internal/jobs/email_verification.go +++ b/internal/jobs/email_verification.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" emailverifyapp "github.com/warmbly/warmbly/internal/app/emailverify" ) @@ -39,7 +39,7 @@ func (j *EmailVerificationJob) Run(ctx context.Context) error { for { n, err := j.svc.VerifyPending(ctx, j.batchSize) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return err } if n < j.batchSize || ctx.Err() != nil { @@ -84,7 +84,7 @@ func (s *EmailVerificationScheduler) Start(ctx context.Context) { return } if err := s.job.Run(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } } diff --git a/internal/jobs/guardrail.go b/internal/jobs/guardrail.go index 0734a5c0..f71b0dd3 100644 --- a/internal/jobs/guardrail.go +++ b/internal/jobs/guardrail.go @@ -4,8 +4,8 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" "github.com/rs/zerolog/log" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/app/guardrail" "github.com/warmbly/warmbly/internal/repository" @@ -35,7 +35,7 @@ func (j *GuardrailJob) Run(ctx context.Context) { if j.svc != nil { paused, err := j.svc.Sweep(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } else if paused > 0 { log.Info().Int("paused", paused).Msg("guardrail sweep paused campaigns") } @@ -43,7 +43,7 @@ func (j *GuardrailJob) Run(ctx context.Context) { if j.behaviorRepo != nil { if _, err := j.behaviorRepo.PurgePlansBefore(ctx, time.Now().Add(-planRetention)); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } } diff --git a/internal/jobs/org_transfer.go b/internal/jobs/org_transfer.go index bc7255fd..08ba2bcd 100644 --- a/internal/jobs/org_transfer.go +++ b/internal/jobs/org_transfer.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/app/orgtransfer" ) @@ -34,7 +34,7 @@ func (j *OrgTransferJob) Run(ctx context.Context) { return } if _, err := j.svc.PurgeExpiredExports(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } diff --git a/internal/jobs/placement_poller.go b/internal/jobs/placement_poller.go index fba25481..3f558100 100644 --- a/internal/jobs/placement_poller.go +++ b/internal/jobs/placement_poller.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/app/placement" ) @@ -41,7 +41,7 @@ func (p *PlacementPoller) Run(ctx context.Context) error { return nil } if err := p.svc.ClassifyPending(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return err } return nil @@ -56,7 +56,7 @@ func (p *PlacementPoller) Start(ctx context.Context) { select { case <-ticker.C: if err := p.Run(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } case <-p.stopCh: return diff --git a/internal/jobs/trial_expiration.go b/internal/jobs/trial_expiration.go index 2a68fd49..56731f39 100644 --- a/internal/jobs/trial_expiration.go +++ b/internal/jobs/trial_expiration.go @@ -5,12 +5,12 @@ import ( "fmt" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/notify" "github.com/warmbly/warmbly/internal/notify/templates" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/repository" ) @@ -72,26 +72,26 @@ func (j *TrialExpirationJob) Run(ctx context.Context) error { // Find expired trials without paid subscription expiredSubs, err := repository.GetExpiredTrialsWithoutPayment(ctx, j.db) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return fmt.Errorf("failed to get expired trials: %w", err) } for _, sub := range expiredSubs { // Pause all active campaigns for this organization if err := repository.PauseCampaignsByOrganizationID(ctx, j.db, sub.OrganizationID, "paused_trial_expired"); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) // Continue processing other organizations } // Disable warmup on all email accounts (they're already blocked, but clean up) if err := repository.DisableWarmupByOrganizationID(ctx, j.db, sub.OrganizationID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) // Continue processing other organizations } // Mark subscription as expired if err := repository.MarkSubscriptionTrialExpired(ctx, j.db, sub.ID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) // Continue processing other users } @@ -132,7 +132,7 @@ func (j *TrialExpirationJob) notifyTrialExpired(ctx context.Context, userID inte } if err := j.emailNotificationService.Send(ctx, []string{userEmail}, nil, nil, subject, body); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } } @@ -159,14 +159,14 @@ func (s *TrialExpirationScheduler) Start(ctx context.Context) { // Run immediately on start if err := s.job.Run(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } for { select { case <-ticker.C: if err := s.job.Run(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } case <-s.stopCh: return diff --git a/internal/jobs/verification_evidence.go b/internal/jobs/verification_evidence.go index 14a0a471..630d4da5 100644 --- a/internal/jobs/verification_evidence.go +++ b/internal/jobs/verification_evidence.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" emailverifyapp "github.com/warmbly/warmbly/internal/app/emailverify" ) @@ -39,7 +39,7 @@ func (j *DeliveryEvidenceJob) Start(ctx context.Context) { for { n, err := j.evidence.CreditCleanDeliveries(ctx, j.batch) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) break } if n < j.batch || ctx.Err() != nil { diff --git a/internal/jobs/warmup_batch_poller.go b/internal/jobs/warmup_batch_poller.go index e57fe89a..f02248d4 100644 --- a/internal/jobs/warmup_batch_poller.go +++ b/internal/jobs/warmup_batch_poller.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/app/warmupcontent" ) @@ -39,7 +39,7 @@ func (p *WarmupBatchPoller) Run(ctx context.Context) error { return nil } if err := p.svc.PollBatches(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return err } return nil @@ -54,7 +54,7 @@ func (p *WarmupBatchPoller) Start(ctx context.Context) { select { case <-ticker.C: if err := p.Run(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } case <-p.stopCh: return diff --git a/internal/jobs/warmup_generation.go b/internal/jobs/warmup_generation.go index 6607f848..b0776d85 100644 --- a/internal/jobs/warmup_generation.go +++ b/internal/jobs/warmup_generation.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/app/warmupcontent" "github.com/warmbly/warmbly/internal/repository" @@ -37,7 +37,7 @@ func (j *WarmupGenerationJob) Run(ctx context.Context) error { } settings, err := j.repo.GetGenerationSettings(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return err } if settings == nil || !settings.ScheduleEnabled { @@ -54,7 +54,7 @@ func (j *WarmupGenerationJob) Run(ctx context.Context) error { j.mu.Unlock() if err := j.svc.RunScheduled(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return err } return nil @@ -86,7 +86,7 @@ func (s *WarmupGenerationScheduler) Start(ctx context.Context) { select { case <-ticker.C: if err := s.job.Run(ctx); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } case <-s.stopCh: return diff --git a/internal/jobs/website_tracking_retention.go b/internal/jobs/website_tracking_retention.go index 1f7e25a5..c705d31d 100644 --- a/internal/jobs/website_tracking_retention.go +++ b/internal/jobs/website_tracking_retention.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/repository" ) @@ -24,13 +24,13 @@ func (j *WebsiteTrackingRetentionJob) Run(ctx context.Context) error { } cutoffs, err := j.repo.RetentionCutoffs(ctx, time.Now()) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return err } var last error for _, c := range cutoffs { if _, err := j.repo.PruneBefore(ctx, c.OrganizationID, c.Before); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) last = err } } diff --git a/internal/notify/email.go b/internal/notify/email.go index 3197f1d5..e415f059 100644 --- a/internal/notify/email.go +++ b/internal/notify/email.go @@ -7,7 +7,7 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/sesv2" "github.com/aws/aws-sdk-go-v2/service/sesv2/types" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" ) type EmailNotificationService interface { @@ -29,7 +29,7 @@ type emailNotificationService struct { func NewEmailNotficiationService(ctx context.Context, name, address string) (EmailNotificationService, error) { cfg, err := config.LoadDefaultConfig(ctx) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, err } @@ -67,7 +67,7 @@ func (s *emailNotificationService) Send(ctx context.Context, to, cc, bcc []strin _, err := s.Client.SendEmail(ctx, input) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return err } @@ -100,7 +100,7 @@ func (s *emailNotificationService) SendOutreach(ctx context.Context, to []string _, err := s.Client.SendEmail(ctx, input) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return err } return nil diff --git a/internal/notify/smtp.go b/internal/notify/smtp.go index 8722237a..831378b0 100644 --- a/internal/notify/smtp.go +++ b/internal/notify/smtp.go @@ -11,9 +11,9 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" wsmtp "github.com/warmbly/warmbly/internal/client/smtpimap/smtp" "github.com/warmbly/warmbly/internal/config" + "github.com/warmbly/warmbly/internal/observability/errs" ) const smtpSendTimeout = 30 * time.Second @@ -59,7 +59,7 @@ func (s *smtpEmailNotificationService) send(ctx context.Context, to, cc, bcc []s } if err := s.deliver(ctx, recipients, msg); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return err } return nil diff --git a/internal/notify/templates/base.go b/internal/notify/templates/base.go index 03b91293..cf0a238e 100644 --- a/internal/notify/templates/base.go +++ b/internal/notify/templates/base.go @@ -6,7 +6,7 @@ import ( "os" "strings" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" ) // ─── Centralized Business Details ──────────────────────────────── @@ -86,7 +86,7 @@ func renderEmail(subject, content string) (string, error) { } var buf bytes.Buffer if err := baseTmpl.Execute(&buf, data); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", err } return buf.String(), nil diff --git a/internal/notify/templates/deletion.go b/internal/notify/templates/deletion.go index b131b88a..50fb5a4a 100644 --- a/internal/notify/templates/deletion.go +++ b/internal/notify/templates/deletion.go @@ -6,7 +6,7 @@ import ( "html/template" "time" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" ) // Danger-zone (scheduled deletion) emails. These move off the old @@ -162,7 +162,7 @@ var ( func renderDeletion(tmpl *template.Template, subject string, data any) (string, error) { var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", err } return renderEmail(subject, buf.String()) diff --git a/internal/notify/templates/digest.go b/internal/notify/templates/digest.go index 92066d34..1de5b37f 100644 --- a/internal/notify/templates/digest.go +++ b/internal/notify/templates/digest.go @@ -5,7 +5,7 @@ import ( "fmt" "html/template" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" ) // Notification digest: several pending notifications bundled into one email @@ -67,7 +67,7 @@ func GenerateDigestHTML(count int, items []DigestItem) (string, error) { }{Count: count, Items: items, AppURL: AppURL} var buf bytes.Buffer if err := digestTmpl.Execute(&buf, data); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", err } return renderEmail(fmt.Sprintf("%d updates in your Warmbly workspace", count), buf.String()) diff --git a/internal/notify/templates/invitation.go b/internal/notify/templates/invitation.go index bcea7d8c..81e6a4ab 100644 --- a/internal/notify/templates/invitation.go +++ b/internal/notify/templates/invitation.go @@ -4,7 +4,7 @@ import ( "bytes" "html/template" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" ) // Team invitation rendered on the shared base shell. The org name and @@ -49,7 +49,7 @@ func GenerateInvitationHTML(inviterName, orgName, acceptURL string) (string, err }{InviterName: inviterName, OrgName: orgName, AcceptURL: acceptURL} var buf bytes.Buffer if err := invitationTmpl.Execute(&buf, data); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", err } return renderEmail("You've been invited to Warmbly", buf.String()) diff --git a/internal/notify/templates/login_code.go b/internal/notify/templates/login_code.go index 5e58c45b..14297a47 100644 --- a/internal/notify/templates/login_code.go +++ b/internal/notify/templates/login_code.go @@ -4,7 +4,7 @@ import ( "bytes" "html/template" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" ) // Dashboard-style content. Small uppercase eyebrow, slate-900 plain @@ -47,7 +47,7 @@ func GenerateLoginCodeHTML(code string) (string, error) { data := struct{ Code string }{Code: code} var buf bytes.Buffer if err := loginCodeTmpl.Execute(&buf, data); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", err } return renderEmail("Your Login Code", buf.String()) diff --git a/internal/notify/templates/notification.go b/internal/notify/templates/notification.go index 7cdeb9c1..21b09ee8 100644 --- a/internal/notify/templates/notification.go +++ b/internal/notify/templates/notification.go @@ -4,7 +4,7 @@ import ( "bytes" "html/template" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" ) // Generic transactional notification used by the in-app notification @@ -55,7 +55,7 @@ func GenerateNotificationHTML(title, body, ctaURL, ctaLabel string) (string, err }{Title: title, Body: body, CTAURL: ctaURL, CTALabel: ctaLabel} var buf bytes.Buffer if err := notificationTmpl.Execute(&buf, data); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", err } subject := title diff --git a/internal/notify/templates/registration_code.go b/internal/notify/templates/registration_code.go index 940cf903..b8d3ba03 100644 --- a/internal/notify/templates/registration_code.go +++ b/internal/notify/templates/registration_code.go @@ -4,7 +4,7 @@ import ( "bytes" "html/template" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" ) const registrationCodeContent = ` @@ -37,7 +37,7 @@ func GenerateRegistrationCodeHTML(code string) (string, error) { data := struct{ Code string }{Code: code} var buf bytes.Buffer if err := registrationCodeTmpl.Execute(&buf, data); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", err } return renderEmail("Your Verification Code", buf.String()) diff --git a/internal/notify/templates/reset_password.go b/internal/notify/templates/reset_password.go index 4b697545..add05197 100644 --- a/internal/notify/templates/reset_password.go +++ b/internal/notify/templates/reset_password.go @@ -4,7 +4,7 @@ import ( "bytes" "html/template" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" ) const resetPasswordContent = ` @@ -42,7 +42,7 @@ func GenerateResetPasswordHTML(firstName, url string) (string, error) { data := struct{ ResetURL string }{ResetURL: url} var buf bytes.Buffer if err := resetPasswordTmpl.Execute(&buf, data); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", err } return renderEmail("Reset Your Password", buf.String()) diff --git a/internal/notify/templates/trial_expired.go b/internal/notify/templates/trial_expired.go index 9fa2cd90..82082be7 100644 --- a/internal/notify/templates/trial_expired.go +++ b/internal/notify/templates/trial_expired.go @@ -4,7 +4,7 @@ import ( "bytes" "html/template" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" ) // Trial-expiry notice rendered on the shared base shell so it matches @@ -61,7 +61,7 @@ func GenerateTrialExpiredHTML() (string, error) { data := struct{ BillingURL string }{BillingURL: AppURL + "/settings/billing"} var buf bytes.Buffer if err := trialExpiredTmpl.Execute(&buf, data); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", err } return renderEmail("Your Warmbly trial has ended", buf.String()) diff --git a/internal/notify/templates/welcome.go b/internal/notify/templates/welcome.go index 7d6d4f2d..ebf6d651 100644 --- a/internal/notify/templates/welcome.go +++ b/internal/notify/templates/welcome.go @@ -4,7 +4,7 @@ import ( "bytes" "html/template" - "github.com/getsentry/sentry-go" + "github.com/warmbly/warmbly/internal/observability/errs" ) // Welcome template — also moved onto the shared base shell so it @@ -64,7 +64,7 @@ func GenerateWelcomeHTML(firstName string) (string, error) { }{FirstName: firstName, AppURL: AppURL} var buf bytes.Buffer if err := welcomeTmpl.Execute(&buf, data); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return "", err } return renderEmail("Welcome to "+CompanyName, buf.String()) diff --git a/internal/observability/errs/errs.go b/internal/observability/errs/errs.go new file mode 100644 index 00000000..c80ee39d --- /dev/null +++ b/internal/observability/errs/errs.go @@ -0,0 +1,203 @@ +// Package errs is the only place the Sentry SDK is called from. +// +// Every service, job and repository reports through these functions, so the +// vendor import stays in one file: turning reporting off, attaching a tag to +// every event, or swapping the backend is a change here rather than across +// ninety files. Nothing here needs Init to have run; an uninitialised SDK +// drops the event, which is what a deployment with no DSN wants. +package errs + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "github.com/getsentry/sentry-go" +) + +// Config is what a service knows about itself at boot. +type Config struct { + // DSN is empty when the operator configured no error reporting. Events are + // then summarised to the local log instead of being sent anywhere. + DSN string + // Environment is the deployment label (dev, staging, prod). + Environment string + // Release identifies the build, so a stack trace can be tied to a commit. + Release string + // Service names the process (backend, consumer, worker, forms). + Service string +} + +// Init configures the SDK for one process. Safe to call once per process. +func Init(cfg Config) error { + options := sentry.ClientOptions{ + SendDefaultPII: true, + Environment: cfg.Environment, + Release: cfg.Release, + ServerName: cfg.Service, + } + + // A DSN is used when one is configured, in any environment. Error reporting + // is the operator's choice, not a requirement of the software: demanding a + // Sentry account to run APP_ENV=prod made a self-hosted deployment fail to + // boot over a service it never asked for. + if cfg.DSN != "" { + options.Dsn = cfg.DSN + } else { + if cfg.Environment == "prod" { + log.Printf("Sentry is not configured (no SENTRY_DSN); errors are logged locally only.") + } + options.BeforeSend = func(event *sentry.Event, _ *sentry.EventHint) *sentry.Event { + log.Printf("[sentry-local][%s][%s] %s", cfg.Service, event.Level, summarize(event)) + return event + } + } + + if err := sentry.Init(options); err != nil { + return fmt.Errorf("init sentry: %w", err) + } + return nil +} + +// Option decorates the scope one event is reported on. Callers build these with +// Tag and Extra so they never name a Sentry type themselves. +type Option func(*sentry.Scope) + +// Tag adds an indexed key/value pair, searchable and groupable in Sentry. +func Tag(key, value string) Option { + return func(scope *sentry.Scope) { scope.SetTag(key, value) } +} + +// Extra adds an unindexed value, for detail that is worth reading on the event +// but not worth searching by. +func Extra(key string, value any) Option { + return func(scope *sentry.Scope) { scope.SetExtra(key, value) } +} + +// CaptureException reports err on the process-wide hub. +func CaptureException(err error, opts ...Option) { + capture(sentry.CurrentHub(), opts, func(hub *sentry.Hub) { hub.CaptureException(err) }) +} + +// CaptureExceptionContext reports err on the hub carried by ctx when there is +// one, so a request's breadcrumbs and scope travel with the event, and on the +// process-wide hub otherwise. +func CaptureExceptionContext(ctx context.Context, err error, opts ...Option) { + capture(hubFrom(ctx), opts, func(hub *sentry.Hub) { hub.CaptureException(err) }) +} + +// CaptureMessage reports a message with no error attached. +func CaptureMessage(message string, opts ...Option) { + capture(sentry.CurrentHub(), opts, func(hub *sentry.Hub) { hub.CaptureMessage(message) }) +} + +// CaptureMessageContext is CaptureMessage on ctx's hub. +func CaptureMessageContext(ctx context.Context, message string, opts ...Option) { + capture(hubFrom(ctx), opts, func(hub *sentry.Hub) { hub.CaptureMessage(message) }) +} + +// fatalFlushTimeout is how long a process about to exit waits for its last +// event. Short enough not to stall a crash loop, long enough for one POST. +const fatalFlushTimeout = 2 * time.Second + +// CaptureFatal reports err and waits for it to be sent. Use it instead of +// CaptureException wherever the next statement ends the process: the SDK sends +// in the background and os.Exit does not wait for it, so a boot failure — the +// error most worth having — was the one that never arrived. +func CaptureFatal(err error, opts ...Option) { + CaptureException(err, opts...) + Flush(fatalFlushTimeout) +} + +// Recover reports a value from recover(). Call it inside the deferred function +// that recovered, not after. +func Recover(r any) { + sentry.CurrentHub().Recover(r) +} + +// RecoverContext is Recover on ctx's hub. +func RecoverContext(ctx context.Context, r any) { + hubFrom(ctx).RecoverWithContext(ctx, r) +} + +// Flush waits up to timeout for queued events to reach the server, and reports +// whether the queue drained. Worth calling before a process exits on purpose: +// the SDK sends in the background, so an immediate exit loses the last event. +func Flush(timeout time.Duration) bool { + return sentry.Flush(timeout) +} + +// Hub returns the hub bound to ctx, or a clone of the process-wide one when +// ctx carries none. Middleware that needs a request-scoped scope uses this; +// everything else should use the Context helpers above. +// +// The fallback clones rather than handing back the process-wide hub, because +// the reason to reach for a hub instead of CaptureExceptionContext is to set +// scope on it, and setting scope on the shared hub would leak one request's +// data onto every later event in the process. +func Hub(ctx context.Context) *sentry.Hub { + if ctx != nil { + if hub := sentry.GetHubFromContext(ctx); hub != nil { + return hub + } + } + return sentry.CurrentHub().Clone() +} + +// NewContext returns ctx carrying its own hub, so scope set on one request does +// not leak into another. +func NewContext(ctx context.Context) context.Context { + return sentry.SetHubOnContext(ctx, sentry.CurrentHub().Clone()) +} + +func hubFrom(ctx context.Context) *sentry.Hub { + if ctx != nil { + if hub := sentry.GetHubFromContext(ctx); hub != nil { + return hub + } + } + return sentry.CurrentHub() +} + +// capture applies the options to a throwaway scope so they touch only this +// event, then hands the hub to send. +func capture(hub *sentry.Hub, opts []Option, send func(*sentry.Hub)) { + if len(opts) == 0 { + send(hub) + return + } + hub.WithScope(func(scope *sentry.Scope) { + for _, opt := range opts { + opt(scope) + } + send(hub) + }) +} + +func summarize(event *sentry.Event) string { + if event == nil { + return "nil event" + } + + parts := []string{} + if event.EventID != "" { + parts = append(parts, "event_id="+string(event.EventID)) + } + if event.Message != "" { + parts = append(parts, "message="+event.Message) + } + if len(event.Exception) > 0 { + ex := event.Exception[0] + exMsg := strings.TrimSpace(strings.TrimSpace(ex.Type + ": " + ex.Value)) + if exMsg != "" && exMsg != ":" { + parts = append(parts, "exception="+exMsg) + } + } + if len(parts) == 0 { + return "captured event with no message" + } + + return strings.Join(parts, " | ") +} diff --git a/internal/observability/sentry.go b/internal/observability/sentry.go index c6b989b3..c1985829 100644 --- a/internal/observability/sentry.go +++ b/internal/observability/sentry.go @@ -2,68 +2,62 @@ package observability import ( "context" - "fmt" - "log" + "os" "strings" - "github.com/getsentry/sentry-go" "github.com/warmbly/warmbly/internal/config" + "github.com/warmbly/warmbly/internal/observability/errs" + "github.com/warmbly/warmbly/internal/version" ) -// InitSentry configures Sentry for all runtime environments. -// In non-prod environments, events are logged locally via BeforeSend. +// InitSentry configures error reporting for one Go service. +// +// It resolves the two things a service knows only at boot, the operator's DSN +// and the build it is running, and hands them to the SDK wrapper. A missing or +// empty DSN is not an error: reporting is optional in every environment, +// including prod. func InitSentry(ctx context.Context, cfg *config.Config, service string) error { - options := sentry.ClientOptions{ - SendDefaultPII: true, - Environment: cfg.Env, - ServerName: service, + dsn, err := cfg.LoadSentryDSNBackend(ctx) + if err != nil { + dsn = "" } - // A DSN is used when one is configured, in any environment. Error reporting - // is the operator's choice, not a requirement of the software: demanding a - // Sentry account to run APP_ENV=prod made a self-hosted deployment fail to - // boot over a service it never asked for. - if sentryDsn, err := cfg.LoadSentryDSNBackend(ctx); err == nil && sentryDsn != "" { - options.Dsn = sentryDsn - } else { - if cfg.Env == "prod" { - log.Printf("Sentry is not configured (no SENTRY_DSN); errors are logged locally only.") - } - options.BeforeSend = func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { - log.Printf("[sentry-local][%s][%s] %s", service, event.Level, summarizeSentryEvent(event)) - return event - } - } - - if err := sentry.Init(options); err != nil { - return fmt.Errorf("init sentry: %w", err) - } - - return nil + return errs.Init(errs.Config{ + DSN: dsn, + Environment: cfg.Env, + Release: Release(), + Service: service, + }) } -func summarizeSentryEvent(event *sentry.Event) string { - if event == nil { - return "nil event" +// InitSentryEnv configures error reporting for a service that reads its own +// environment instead of the shared config loader. The forms service has no +// database and no secrets backend, so it has no config.Config to consult, and +// building one just to read SENTRY_DSN would give it both. +func InitSentryEnv(service string) error { + env := strings.TrimSpace(os.Getenv("APP_ENV")) + if env == "" { + env = "dev" } - parts := []string{} - if event.EventID != "" { - parts = append(parts, "event_id="+string(event.EventID)) - } - if event.Message != "" { - parts = append(parts, "message="+event.Message) + return errs.Init(errs.Config{ + DSN: strings.TrimSpace(os.Getenv("SENTRY_DSN")), + Environment: env, + Release: Release(), + Service: service, + }) +} + +// Release is the value every service tags its events with, so a stack trace in +// Sentry names the build it came from. It is the stamped release when the build +// stamped one and the short commit otherwise; an unstamped local build reports +// "dev", which is what version.String falls back to. +func Release() string { + if v := version.String(); v != "" && v != "dev" { + return v } - if len(event.Exception) > 0 { - ex := event.Exception[0] - exMsg := strings.TrimSpace(strings.TrimSpace(ex.Type + ": " + ex.Value)) - if exMsg != "" && exMsg != ":" { - parts = append(parts, "exception="+exMsg) - } + if c := version.ShortCommit(); c != "" { + return c } - if len(parts) == 0 { - return "captured event with no message" - } - - return strings.Join(parts, " | ") + return "dev" } diff --git a/internal/pkg/captcha/turnstile.go b/internal/pkg/captcha/turnstile.go index 60bdcc23..36991dff 100644 --- a/internal/pkg/captcha/turnstile.go +++ b/internal/pkg/captcha/turnstile.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/observability/errs" ) type TurnstileConfig struct { @@ -90,14 +90,14 @@ func (t *Turnstile) Verify(ctx context.Context, token, remoteIP string) *errx.Er strings.NewReader(data.Encode()), ) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := t.cfg.HTTPClient.Do(req) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } defer resp.Body.Close() @@ -105,21 +105,21 @@ func (t *Turnstile) Verify(ctx context.Context, token, remoteIP string) *errx.Er if resp.StatusCode != http.StatusOK { raw, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<12)) // 4KB max if readErr != nil { - sentry.CaptureException(readErr) + errs.CaptureException(readErr) } - sentry.CaptureException(fmt.Errorf("turnstile verify http status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))) + errs.CaptureException(fmt.Errorf("turnstile verify http status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))) return errx.ErrCaptcha } body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MB max if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } var r Response if err := json.Unmarshal(body, &r); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } diff --git a/internal/repository/pg_auth.go b/internal/repository/pg_auth.go index c86c0e78..8a9d9c2a 100644 --- a/internal/repository/pg_auth.go +++ b/internal/repository/pg_auth.go @@ -6,12 +6,12 @@ import ( "net/mail" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/db" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/argon2" ) @@ -61,7 +61,7 @@ func (r *authRepository) IsValidCredentials(ctx context.Context, email, password val, err := argon2.Verify(password, pw) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return uuid.Nil, errx.InternalError() } diff --git a/internal/repository/pg_contact.go b/internal/repository/pg_contact.go index c3d44b15..b2ce5163 100644 --- a/internal/repository/pg_contact.go +++ b/internal/repository/pg_contact.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/warmbly/warmbly/internal/config" @@ -18,6 +17,7 @@ import ( "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/db" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/emailverify" "github.com/warmbly/warmbly/internal/pkg/encrypt" "github.com/warmbly/warmbly/internal/utils" @@ -1384,7 +1384,7 @@ func (r *contactRepository) Search( Undeliverable bool `json:"undeliverable"` } if err := json.Unmarshal(leadProgressJSON, &lp); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } status := models.LeadStatusPending @@ -1436,7 +1436,7 @@ func (r *contactRepository) Search( Name string `json:"name"` } if err := json.Unmarshal(campaignsJSON, &campaigns); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } c.Campaigns = make([]models.MiniCampaign, len(campaigns)) @@ -1449,7 +1449,7 @@ func (r *contactRepository) Search( if len(categoriesJSON) > 0 { if err := json.Unmarshal(categoriesJSON, &c.Categories); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } } @@ -1780,7 +1780,7 @@ func (r *contactRepository) Update(ctx context.Context, userID, contactID string Name string `json:"name"` } if err := json.Unmarshal(campaignsJSON, &campaigns); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } c.Campaigns = make([]models.MiniCampaign, len(campaigns)) @@ -1994,7 +1994,7 @@ func (r *contactRepository) Update(ctx context.Context, userID, contactID string Name string `json:"name"` } if err := json.Unmarshal(newCampaignsJSON, &campaigns); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } for _, c := range campaigns { @@ -2125,7 +2125,7 @@ func (r *contactRepository) Update(ctx context.Context, userID, contactID string updatedContact.Categories = make([]models.MiniCategory, 0) if len(catJSON) > 0 { if err := json.Unmarshal(catJSON, &updatedContact.Categories); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } } @@ -2404,7 +2404,7 @@ func (r *contactRepository) BulkUpdate(ctx context.Context, userID string, orgID Name string `json:"name"` } if err := json.Unmarshal(campaignsJSON, &campaigns); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } c.Campaigns = make([]models.MiniCampaign, len(campaigns)) @@ -2421,7 +2421,7 @@ func (r *contactRepository) BulkUpdate(ctx context.Context, userID string, orgID c.Categories = make([]models.MiniCategory, 0) if len(categoriesJSON) > 0 { if err := json.Unmarshal(categoriesJSON, &c.Categories); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } } @@ -2785,7 +2785,7 @@ func (r *contactRepository) GetDetail(ctx context.Context, userID uuid.UUID, org Name string `json:"name"` } if err := json.Unmarshal(campaignsJSON, &raw); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } detail.Campaigns = make([]models.MiniCampaign, len(raw)) @@ -2796,7 +2796,7 @@ func (r *contactRepository) GetDetail(ctx context.Context, userID uuid.UUID, org detail.Categories = []models.MiniCategory{} if len(categoriesJSON) > 0 { if err := json.Unmarshal(categoriesJSON, &detail.Categories); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } } diff --git a/internal/repository/pg_email.go b/internal/repository/pg_email.go index b8b6fd4f..4d2e9ee9 100644 --- a/internal/repository/pg_email.go +++ b/internal/repository/pg_email.go @@ -7,13 +7,13 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/db" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/pkg/encrypt" "github.com/warmbly/warmbly/internal/utils" "github.com/warmbly/warmbly/internal/utils/paging" @@ -330,7 +330,7 @@ func reserveMailboxSlotTx(ctx context.Context, tx pgx.Tx, orgID *uuid.UUID, a *m func (r *emailRepository) NewOauthAccount(ctx context.Context, userID string, data models.NewOauthAccount) (*models.Email, *errx.Error) { if data.Provider == models.InboxProviderSMTPIMAP { - sentry.CaptureException(errors.New("invalid inbox provider")) + errs.CaptureException(errors.New("invalid inbox provider")) return nil, errx.InternalError() } @@ -459,7 +459,7 @@ func (r *emailRepository) NewOauthAccount(ctx context.Context, userID string, da func (r *emailRepository) NewManagedAccount(ctx context.Context, userID string, data models.NewOauthAccount) (*models.Email, *errx.Error) { if data.Provider != models.InboxProviderGoogle && data.Provider != models.InboxProviderOutlook { - sentry.CaptureException(errors.New("managed account: unsupported provider")) + errs.CaptureException(errors.New("managed account: unsupported provider")) return nil, errx.InternalError() } sigplain := utils.GetSignaturePlain(data.Name) @@ -479,7 +479,7 @@ func (r *emailRepository) NewManagedAccount(ctx context.Context, userID string, func (r *emailRepository) NewSMTPIMAPAccount(ctx context.Context, userID string, data models.NewSMTPIMAPAccount) (*models.Email, *errx.Error) { if r.Encrypt == nil { - sentry.CaptureException(errNoCredentialEncrypter) + errs.CaptureException(errNoCredentialEncrypter) return nil, errx.InternalError() } tx, err := r.DB.Begin(ctx) @@ -529,33 +529,33 @@ func (r *emailRepository) NewSMTPIMAPAccount(ctx context.Context, userID string, smtphost, err := r.Encrypt.Encrypt(data.SMTP.Host) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } smtpuser, err := r.Encrypt.Encrypt(data.SMTP.Username) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } smtppass, err := r.Encrypt.Encrypt(data.SMTP.Password) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } imaphost, err := r.Encrypt.Encrypt(data.IMAP.Host) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } imapuser, err := r.Encrypt.Encrypt(data.IMAP.Username) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } imappass, err := r.Encrypt.Encrypt(data.IMAP.Password) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -1628,7 +1628,7 @@ func (r *emailRepository) GetByCampaignSenders(ctx context.Context, scope Accoun // GetSMTPCredentials retrieves SMTP/IMAP credentials for an email account func (r *emailRepository) GetSMTPCredentials(ctx context.Context, emailAccountID uuid.UUID) (*SMTPCredentials, *errx.Error) { if r.Encrypt == nil { - sentry.CaptureException(errNoCredentialEncrypter) + errs.CaptureException(errNoCredentialEncrypter) return nil, errx.InternalError() } query := ` @@ -1657,32 +1657,32 @@ func (r *emailRepository) GetSMTPCredentials(ctx context.Context, emailAccountID var xerr error creds.SMTPHost, xerr = r.Encrypt.Decrypt(smtpHost) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } creds.SMTPUser, xerr = r.Encrypt.Decrypt(smtpUser) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } creds.SMTPPassword, xerr = r.Encrypt.Decrypt(smtpPassword) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } creds.IMAPHost, xerr = r.Encrypt.Decrypt(imapHost) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } creds.IMAPUser, xerr = r.Encrypt.Decrypt(imapUser) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } creds.IMAPPassword, xerr = r.Encrypt.Decrypt(imapPassword) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } @@ -1692,7 +1692,7 @@ func (r *emailRepository) GetSMTPCredentials(ctx context.Context, emailAccountID // GetOAuthCredentials retrieves OAuth credentials for an email account func (r *emailRepository) GetOAuthCredentials(ctx context.Context, emailAccountID uuid.UUID) (*OAuthCredentials, *errx.Error) { if r.Encrypt == nil { - sentry.CaptureException(errNoCredentialEncrypter) + errs.CaptureException(errNoCredentialEncrypter) return nil, errx.InternalError() } query := ` @@ -1718,12 +1718,12 @@ func (r *emailRepository) GetOAuthCredentials(ctx context.Context, emailAccountI // Decrypt tokens, tolerating rows written before OAuth tokens were sealed. decryptedAccessToken, accessLegacy, xerr := r.openCredential(accessToken) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } decryptedRefreshToken, refreshLegacy, xerr := r.openCredential(refreshToken) if xerr != nil { - sentry.CaptureException(xerr) + errs.CaptureException(xerr) return nil, errx.InternalError() } if accessLegacy || refreshLegacy { @@ -1745,12 +1745,12 @@ func (r *emailRepository) GetOAuthCredentials(ctx context.Context, emailAccountI func (r *emailRepository) resealOAuthCredentials(ctx context.Context, emailAccountID uuid.UUID, accessToken, refreshToken string) { encAccessToken, err := r.sealCredential(accessToken) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return } encRefreshToken, err := r.sealCredential(refreshToken) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return } diff --git a/internal/repository/pg_email_auth.go b/internal/repository/pg_email_auth.go index cec8f5ea..d5dd6016 100644 --- a/internal/repository/pg_email_auth.go +++ b/internal/repository/pg_email_auth.go @@ -5,12 +5,12 @@ import ( "errors" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/db" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) func (r *emailRepository) GetSMTPIMAP(ctx context.Context, userID, emailAccountID string) (*models.SmtpImap, *errx.Error) { @@ -63,32 +63,32 @@ func (r *emailRepository) GetSMTPIMAP(ctx context.Context, userID, emailAccountI } imap.Username, err = r.Encrypt.Decrypt(imap.Username) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } imap.Password, err = r.Encrypt.Decrypt(imap.Password) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } imap.Host, err = r.Encrypt.Decrypt(imap.Host) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } smtp.Username, err = r.Encrypt.Decrypt(smtp.Username) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } smtp.Password, err = r.Encrypt.Decrypt(smtp.Password) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } smtp.Host, err = r.Encrypt.Decrypt(smtp.Host) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } diff --git a/internal/repository/pg_servers.go b/internal/repository/pg_servers.go index 40935ca2..67987548 100644 --- a/internal/repository/pg_servers.go +++ b/internal/repository/pg_servers.go @@ -8,11 +8,11 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/jackc/pgx/v5" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/db" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" ) type ServersRepository interface { @@ -40,7 +40,7 @@ func (r *serversRepository) GetWorkers(ctx context.Context) ([]models.Worker, *e ` rows, err := r.DB.Query(ctx, query) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -52,7 +52,7 @@ func (r *serversRepository) GetWorkers(ctx context.Context) ([]models.Worker, *e &w.ID, &w.IPAddr, &w.Active, &w.CreatedAt, &w.UpdatedAt, ); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -102,7 +102,7 @@ func (r *serversRepository) UpdateWorker(ctx context.Context, id string, data *m if errors.Is(err, pgx.ErrNoRows) { return nil, errx.ErrNotFound } - sentry.CaptureException(err) + errs.CaptureException(err) return nil, errx.InternalError() } @@ -124,7 +124,7 @@ func (r *serversRepository) DeleteWorker(ctx context.Context, id string) *errx.E params..., ) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if cmd.RowsAffected() == 0 { diff --git a/internal/tasks/campaign_task.go b/internal/tasks/campaign_task.go index 077684f0..6ea16091 100644 --- a/internal/tasks/campaign_task.go +++ b/internal/tasks/campaign_task.go @@ -8,13 +8,13 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/pubsub" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/repository" "github.com/warmbly/warmbly/internal/scheduler" "github.com/warmbly/warmbly/internal/tasks/proto" @@ -27,7 +27,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { // STEP 1: Parse task ID taskID, err := uuid.Parse(task.TaskId) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.BadRequest, "invalid task ID") } @@ -53,7 +53,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { // STEP 2: Load task record taskRecord, err := s.taskRepo.GetTask(ctx, taskID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -72,14 +72,14 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { // STEP 3: Mark task as active (with advisory lock) if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "active"); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } // STEP 4: Load campaign task details campaignTask, err := s.taskRepo.GetCampaignTask(ctx, taskID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -111,7 +111,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { executionStatus = "completed" return nil } - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -288,7 +288,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { // successor. Record the failure for dashboard review, reset the task to // pending, and return 5xx so Cloud Tasks retries (with backoff). The // campaign reconciler is the backstop if retries are ever exhausted. - sentry.CaptureException(err) + errs.CaptureException(err) s.recordSchedulerFailure(ctx, campaign.ID, "scheduler_error", "Could not compute the next step; retrying", err) // Pulse the dashboard so the failure appears live for the whole team. A // CAMPAIGN_UPDATED with empty status invalidates the campaign logs query @@ -301,7 +301,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { }) } if rerr := s.taskRepo.UpdateTaskStatus(ctx, taskID, "pending"); rerr != nil { - sentry.CaptureException(rerr) + errs.CaptureException(rerr) } executionStatus = "failed" return errx.InternalError() @@ -379,7 +379,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { sequence, err := s.campaignRepo.GetSequenceByID(ctx, nextPair.SequenceID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -524,7 +524,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { }) } if rerr := s.taskRepo.UpdateTaskStatus(ctx, taskID, "pending"); rerr != nil { - sentry.CaptureException(rerr) + errs.CaptureException(rerr) } executionStatus = "failed" return errx.InternalError() @@ -608,11 +608,11 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { // STEP 13: Warm the organization DEK so the publisher's encrypt pass (the // one whose ciphertext is actually sent) fails fast here if KMS is down. if account.OrganizationID == nil { - sentry.CaptureException(fmt.Errorf("email account %s has no organization", account.ID)) + errs.CaptureException(fmt.Errorf("email account %s has no organization", account.ID)) return errx.InternalError() } if _, err := s.cipherService.Cipher(ctx, *account.OrganizationID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -650,12 +650,12 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { if rerr != nil { // The attempt could not be made durable, so it must not be made at all. // Retry the whole task rather than sending something nothing remembers. - sentry.CaptureException(rerr) + errs.CaptureException(rerr) s.taskRepo.RecordTaskFailure(ctx, taskID, "Could not reserve the send", rerr.Error()) s.recordSchedulerFailure(ctx, campaign.ID, "send_reservation_failed", fmt.Sprintf("Could not record the send to %s before dispatching it; retrying", contact.Email), rerr) if uerr := s.taskRepo.UpdateTaskStatus(ctx, taskID, "pending"); uerr != nil { - sentry.CaptureException(uerr) + errs.CaptureException(uerr) } executionStatus = "failed" return errx.InternalError() @@ -701,7 +701,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { // if none ever comes. if !errors.Is(err, ErrSendDispatchUnknown) { if relErr := s.campaignProgressRepo.ReleaseSend(ctx, campaign.ID, contact.ID, sequence.ID, nextPair.IsNewLead); relErr != nil { - sentry.CaptureException(relErr) + errs.CaptureException(relErr) log.Error().Err(relErr).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to release the reservation for a send that never left; the reclaimer will retry it") } } @@ -779,7 +779,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { // Today's counters were bumped by the reservation, inside the same // transaction, so the new-lead/day cap can never under-count. if err := s.stampSendRecorded(ctx, campaign.ID, contact.ID, sequence.ID); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) log.Error().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Str("contact_id", contact.ID.String()).Msg("Failed to record email sent; the worker result will repair the stamp") if s.campaignLogRepo != nil { s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ @@ -820,7 +820,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { // STEP 18: Mark task as completed (with advisory lock) if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "completed"); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -1021,12 +1021,12 @@ func (s *tasksService) autoPauseCampaign(ctx context.Context, campaignID, taskID func (s *tasksService) haltOrglessCampaign(ctx context.Context, campaignID, taskID uuid.UUID) { const reason = "Campaign paused: it has no workspace, so unsubscribes, bounces and complaints cannot be checked before sending. Contact support to reattach it." - sentry.CaptureException(fmt.Errorf("campaign %s reached the send path with no organization", campaignID)) + errs.CaptureException(fmt.Errorf("campaign %s reached the send path with no organization", campaignID)) log.Error().Str("campaign_id", campaignID.String()).Str("task_id", taskID.String()). Msg("campaign send blocked: no organization, suppression cannot be enforced") if err := s.campaignRepo.UpdateStatusWithLock(ctx, campaignID, "paused"); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) } s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled") if s.campaignLogRepo != nil { diff --git a/internal/tasks/dispatch.go b/internal/tasks/dispatch.go index 229e2c27..7ce4b243 100644 --- a/internal/tasks/dispatch.go +++ b/internal/tasks/dispatch.go @@ -4,10 +4,10 @@ import ( "context" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/tasks/proto" ) @@ -18,7 +18,7 @@ import ( func (s *tasksService) HandleTask(task *proto.ProcessTask) *errx.Error { taskID, err := uuid.Parse(task.TaskId) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.BadRequest, "invalid task ID") } @@ -26,7 +26,7 @@ func (s *tasksService) HandleTask(task *proto.ProcessTask) *errx.Error { rec, err := s.taskRepo.GetTask(ctx, taskID) cancel() if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } if rec == nil { diff --git a/internal/tasks/email_task.go b/internal/tasks/email_task.go index a3d58788..b73fd6d4 100644 --- a/internal/tasks/email_task.go +++ b/internal/tasks/email_task.go @@ -7,12 +7,12 @@ import ( "strings" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/repository" "github.com/warmbly/warmbly/internal/tasks/proto" ) @@ -24,7 +24,7 @@ func (s *tasksService) HandleEmailTask(task *proto.ProcessTask) *errx.Error { // STEP 1: Parse task ID taskID, err := uuid.Parse(task.TaskId) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.BadRequest, "invalid task ID") } @@ -56,7 +56,7 @@ func (s *tasksService) HandleEmailTask(task *proto.ProcessTask) *errx.Error { // STEP 2: Load task record taskRecord, err := s.taskRepo.GetTask(ctx, taskID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -131,7 +131,7 @@ func (s *tasksService) HandleEmailTask(task *proto.ProcessTask) *errx.Error { if s.warmupHealth != nil { _ = s.warmupHealth.RemoveFromAllPools(ctx, account.ID) } - sentry.CaptureException(fmt.Errorf("email account %s reached warmup with no organization", account.ID)) + errs.CaptureException(fmt.Errorf("email account %s reached warmup with no organization", account.ID)) log.Error().Str("task_id", taskID.String()).Str("email_account_id", account.ID.String()). Msg("warmup blocked: mailbox has no organization, entitlement and pool policy cannot be checked") _ = s.taskRepo.UpdateTaskStatus(ctx, taskID, "skipped_no_warmup_access") @@ -209,7 +209,7 @@ func (s *tasksService) HandleEmailTask(task *proto.ProcessTask) *errx.Error { // STEP 4: Mark task as active (with advisory lock) if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "active"); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -415,7 +415,7 @@ func (s *tasksService) HandleEmailTask(task *proto.ProcessTask) *errx.Error { // STEP 13: Mark task completed (with advisory lock) if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "completed"); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } diff --git a/internal/tasks/user_email_task.go b/internal/tasks/user_email_task.go index d006b2b4..a1d5ce9f 100644 --- a/internal/tasks/user_email_task.go +++ b/internal/tasks/user_email_task.go @@ -5,10 +5,10 @@ import ( "fmt" "time" - "github.com/getsentry/sentry-go" "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/observability/errs" "github.com/warmbly/warmbly/internal/tasks/proto" ) @@ -19,7 +19,7 @@ func (s *tasksService) HandleUserEmailTask(task *proto.ProcessTask) *errx.Error // STEP 1: Parse task ID taskID, err := uuid.Parse(task.TaskId) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.New(errx.BadRequest, "invalid task ID") } @@ -45,7 +45,7 @@ func (s *tasksService) HandleUserEmailTask(task *proto.ProcessTask) *errx.Error // STEP 2: Load task record taskRecord, err := s.taskRepo.GetTask(ctx, taskID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -79,7 +79,7 @@ func (s *tasksService) HandleUserEmailTask(task *proto.ProcessTask) *errx.Error // STEP 3: Load email_task record (with new columns) emailTask, err := s.taskRepo.GetEmailTask(ctx, taskID) if err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -112,7 +112,7 @@ func (s *tasksService) HandleUserEmailTask(task *proto.ProcessTask) *errx.Error // Tenancy gate: the entitlement check below is org-scoped, so a mailbox with // no workspace would send without it. Fail closed. if account.OrganizationID == nil { - sentry.CaptureException(fmt.Errorf("email account %s reached a unibox send with no organization", account.ID)) + errs.CaptureException(fmt.Errorf("email account %s reached a unibox send with no organization", account.ID)) _ = s.taskRepo.RecordTaskFailure(ctx, taskID, "Mailbox has no workspace", "email account organization_id is NULL at execution time") @@ -136,7 +136,7 @@ func (s *tasksService) HandleUserEmailTask(task *proto.ProcessTask) *errx.Error // STEP 5: Mark task as active (with advisory lock) if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "active"); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } @@ -203,7 +203,7 @@ func (s *tasksService) HandleUserEmailTask(task *proto.ProcessTask) *errx.Error // STEP 11: Mark task completed (with advisory lock) if err := s.taskRepo.UpdateTaskStatusWithLock(ctx, taskID, "completed"); err != nil { - sentry.CaptureException(err) + errs.CaptureException(err) return errx.InternalError() } diff --git a/realtime/config/runtime.exs b/realtime/config/runtime.exs index 2f50f7ef..2d403011 100644 --- a/realtime/config/runtime.exs +++ b/realtime/config/runtime.exs @@ -79,9 +79,31 @@ if config_env() == :prod do # unset: compose passes every optional variable through as "" so a single # .env can drive the whole stack, and Sentry rejects "" as an invalid DSN # hard enough to take the whole node down at boot. + # A variable that is present but blank counts as unset here too, for the same + # reason the DSN does: compose passes every optional variable through as "", + # and System.get_env/2 only applies its default when the name is absent. + env_or = fn name, fallback -> + case System.get_env(name) do + value when is_binary(value) -> + case String.trim(value) do + "" -> fallback + trimmed -> trimmed + end + + _ -> + fallback + end + end + case System.get_env("SENTRY_DSN") do dsn when is_binary(dsn) and dsn != "" -> - config :sentry, dsn: dsn, environment_name: :prod + # release ties a stack trace to a build, the same value every other + # service tags with. The image sets it from the release tag; an + # unstamped build reports "dev". + config :sentry, + dsn: dsn, + environment_name: env_or.("APP_ENV", "prod"), + release: env_or.("WARMBLY_RELEASE", "dev") _ -> :ok diff --git a/tracking/Cargo.lock b/tracking/Cargo.lock index 0e18dfed..30d5d10f 100644 --- a/tracking/Cargo.lock +++ b/tracking/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -40,7 +49,7 @@ dependencies = [ "miniz_oxide", "num-bigint", "quad-rand", - "rand 0.9.2", + "rand 0.9.5", "regex-lite", "serde", "serde_bytes", @@ -595,6 +604,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "base64" version = "0.21.7" @@ -607,6 +631,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64-simd" version = "0.8.0" @@ -658,6 +688,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bon" version = "3.8.2" @@ -732,6 +771,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.43" @@ -755,6 +811,16 @@ dependencies = [ "cc", ] +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -805,6 +871,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -846,7 +921,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -953,6 +1028,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + [[package]] name = "der" version = "0.7.10" @@ -966,11 +1051,10 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -985,6 +1069,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.10.0", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1236,8 +1330,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1248,10 +1344,30 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "h2" version = "0.3.27" @@ -1323,6 +1439,17 @@ dependencies = [ "digest", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "0.2.12" @@ -1673,6 +1800,55 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.114", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1701,9 +1877,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -1750,6 +1926,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1834,6 +2016,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nkeys" version = "0.4.5" @@ -1880,9 +2074,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -1924,6 +2118,174 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1979,6 +2341,22 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "os_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "serde", + "windows-sys 0.61.2", +] + [[package]] name = "outref" version = "0.5.2" @@ -2141,6 +2519,63 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40" +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.36", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls 0.23.36", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.44" @@ -2156,6 +2591,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.7" @@ -2169,14 +2610,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2215,6 +2667,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rdkafka" version = "0.36.2" @@ -2327,7 +2794,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 0.25.4", "winreg", ] @@ -2369,6 +2836,45 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.7", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.36", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -2383,6 +2889,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2424,6 +2942,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -2481,9 +3000,37 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.36", + "rustls-native-certs 0.8.3", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.13", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -2529,6 +3076,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -2611,6 +3167,103 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +[[package]] +name = "sentry" +version = "0.49.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a88b65feb368d9dde5d531a2b59b25016e36fd6e4978432371a3ee77746ca2" +dependencies = [ + "cfg_aliases", + "httpdate", + "reqwest 0.13.4", + "rustls 0.23.36", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-backtrace" +version = "0.49.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c51511d67ed670266a93afeaa26a08019b04ad1420cb9191da30f2338d22c48" +dependencies = [ + "backtrace", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.49.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b757ac1f335856e8d7f5062a1fd9bc07a05a7eb3cc48247db79bf2618a490bd" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.49.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d889520a375e5b93efb0a66def1630d21d168b0251eb55b286b03fa940ccfc84" +dependencies = [ + "rand 0.9.5", + "sentry-types", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "sentry-panic" +version = "0.49.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0fe456a7380e9df875a1ef4df1e468d35b19b9051599845fab9d8f0c71c4ae" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.49.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77796d14eedbef22c2fe78e9c69fb09f14c7ad607e624d48dce92eedc17a136e" +dependencies = [ + "bitflags 2.10.0", + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.49.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc71f5ca55942d9b2901af95d5df5c5d65c164134c22a83682cac3d0b6c7ef2d" +dependencies = [ + "debugid", + "hex", + "rand 0.9.5", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "url", + "uuid", +] + [[package]] name = "serde" version = "1.0.228" @@ -2714,7 +3367,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2765,6 +3418,22 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.11" @@ -2982,12 +3651,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.46" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -2997,15 +3665,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.26" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3021,6 +3689,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -3285,6 +3968,7 @@ dependencies = [ "rdkafka", "reqwest 0.11.27", "schema_registry_converter", + "sentry", "serde", "serde_json", "sha2", @@ -3317,6 +4001,15 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unicode-ident" version = "1.0.22" @@ -3329,6 +4022,34 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "rustls 0.23.36", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots 1.0.9", +] + +[[package]] +name = "ureq-proto" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10" +dependencies = [ + "base64 0.23.1", + "http 1.4.0", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -3339,6 +4060,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -3347,6 +4069,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3389,6 +4117,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3482,12 +4220,49 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/tracking/Cargo.toml b/tracking/Cargo.toml index bedb20cd..782710fd 100644 --- a/tracking/Cargo.toml +++ b/tracking/Cargo.toml @@ -41,6 +41,11 @@ moka = { version = "0.12", features = ["future"] } # Trusted-proxy CIDR matching for the client IP ipnet = "2" +# Error reporting. Optional at runtime: with no SENTRY_DSN the guard is never +# created, no transport thread starts and no host is contacted. rustls for the +# same reason reqwest uses it, so the image needs no OpenSSL system libraries. +sentry = { version = "0.49", default-features = false, features = ["backtrace", "contexts", "panic", "reqwest", "rustls"] } + # Logging tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/tracking/Dockerfile b/tracking/Dockerfile index a18519c2..71fbaea1 100644 --- a/tracking/Dockerfile +++ b/tracking/Dockerfile @@ -40,6 +40,12 @@ RUN apk add --no-cache ca-certificates libgcc && \ WORKDIR /app COPY --from=builder /tracking /app/tracking +# Build identity, the same VERSION/COMMIT the Go images take. Read at runtime +# and used only to tag error events, so it needs no rebuild of the binary. +ARG VERSION="" +ARG COMMIT="" +ENV WARMBLY_RELEASE=${VERSION:-${COMMIT}} + USER tracking EXPOSE 3000 diff --git a/tracking/src/config.rs b/tracking/src/config.rs index 3f1f81c3..e423b423 100644 --- a/tracking/src/config.rs +++ b/tracking/src/config.rs @@ -69,6 +69,12 @@ pub struct Config { /// header is read, so a client-supplied CF-Connecting-IP behind a generic /// proxy is ignored. For x-forwarded-for the proxy-appended last entry wins. pub client_ip_header: String, + /// Where errors and panics are reported. Empty, the default, means nowhere: + /// the SDK is never initialised and no host is contacted. + pub sentry_dsn: String, + /// The build events are tagged with, so a stack trace names a commit. The + /// image sets it from the same VERSION the Go services are stamped with. + pub release: String, } impl Config { @@ -207,6 +213,18 @@ impl Config { .filter(|v| !v.trim().is_empty()) .unwrap_or_else(|| internal_api_token.clone()); + // Error reporting. Read from the environment only: a DSN in SSM would + // make a self-host that never sets one still pay an AWS lookup. + let sentry_dsn = env::var("SENTRY_DSN") + .unwrap_or_default() + .trim() + .to_string(); + let release = env::var("WARMBLY_RELEASE") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "dev".to_string()); + Ok(Self { env: env_name, host, @@ -228,6 +246,8 @@ impl Config { pagehit_rate_limit_per_min, trusted_proxies, client_ip_header, + sentry_dsn, + release, }) } @@ -313,6 +333,15 @@ impl Config { client_ip_header: env::var("TRACKING_CLIENT_IP_HEADER") .unwrap_or_else(|_| "x-forwarded-for".to_string()) .to_ascii_lowercase(), + sentry_dsn: env::var("SENTRY_DSN") + .unwrap_or_default() + .trim() + .to_string(), + release: env::var("WARMBLY_RELEASE") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "dev".to_string()), }) } diff --git a/tracking/src/main.rs b/tracking/src/main.rs index 7f0ebead..4d85546f 100644 --- a/tracking/src/main.rs +++ b/tracking/src/main.rs @@ -55,6 +55,7 @@ async fn connect_producer(config: &Config) -> Producer { Err(e) => { if attempt >= MAX_ATTEMPTS { report_error("Failed to create tracking event producer", e.as_ref()); + observability::flush(); std::process::exit(1); } warn!( @@ -84,10 +85,12 @@ async fn main() { Ok(c) => c, Err(e) => { report_error("Failed to load config", e.as_ref()); + observability::flush(); std::process::exit(1); } }; - observability::init(&config.env); + // Held until main returns so queued events are flushed on shutdown. + let _sentry = observability::init(&config.env, Some(&config.sentry_dsn), &config.release); info!("Starting tracking service on {}", config.addr()); // Event-bus producer (NATS by default; Kafka when EVENTBUS_PROVIDER=kafka @@ -122,6 +125,7 @@ async fn main() { Ok(a) => a, Err(e) => { observability::report_issue("Invalid tracking listen address", &e.to_string()); + observability::flush(); std::process::exit(1); } }; @@ -131,6 +135,7 @@ async fn main() { Ok(l) => l, Err(e) => { observability::report_issue("Failed to bind tracking listener", &e.to_string()); + observability::flush(); std::process::exit(1); } }; @@ -144,6 +149,7 @@ async fn main() { .await { observability::report_issue("Tracking server terminated unexpectedly", &e.to_string()); + observability::flush(); std::process::exit(1); } } diff --git a/tracking/src/observability.rs b/tracking/src/observability.rs index 08ecf706..6557da38 100644 --- a/tracking/src/observability.rs +++ b/tracking/src/observability.rs @@ -1,15 +1,71 @@ +//! Error reporting for the tracking service. +//! +//! Every failure path in this crate goes through `report_issue` or +//! `report_error`, so wiring Sentry means wiring it here and nowhere else. +//! +//! Reporting is optional, like it is in every other Warmbly service: with no +//! `SENTRY_DSN` the guard below is never created, the SDK starts no transport +//! thread and contacts no host, and the failure is only written to the log. + +use sentry::ClientInitGuard; +use std::time::Duration; use tracing::{error, info}; -pub fn init(env: &str) { - if env == "prod" { - info!("Issue reporting initialized (env=prod)"); - } else { +/// Held for the lifetime of the process. Dropping it flushes queued events, so +/// `main` must keep it alive rather than binding it to `_`. +pub struct Guard(#[allow(dead_code)] Option); + +/// Initialises reporting. `dsn` empty means local logging only. +pub fn init(env: &str, dsn: Option<&str>, release: &str) -> Guard { + let dsn = dsn.map(str::trim).filter(|d| !d.is_empty()); + + let Some(dsn) = dsn else { info!("Issue reporting initialized (env={env}, local issue logging enabled)"); + return Guard(None); + }; + + // A malformed DSN must not stop the service serving pixels: the pixel is + // the product, error reporting is not. + let guard = match dsn.parse::() { + Ok(parsed) => { + // ClientOptions is #[non_exhaustive], so it is built from the + // default rather than as a struct literal. + let mut options = sentry::ClientOptions::default(); + options.dsn = Some(parsed); + options.environment = Some(env.to_owned().into()); + options.release = Some(release.to_owned().into()); + options.server_name = Some("tracking".into()); + // The `panic` feature installs the hook, so a panic in a request + // handler reaches the same project as the errors reported here. + options.attach_stacktrace = true; + Some(sentry::init(options)) + } + Err(e) => { + error!("Invalid SENTRY_DSN, error reporting disabled: {e}"); + None + } + }; + + if guard.is_some() { + info!("Issue reporting initialized (env={env}, release={release}, sentry enabled)"); + } + Guard(guard) +} + +/// Sends anything still queued, up to a couple of seconds. +/// +/// `std::process::exit` skips destructors, so the guard's own flush on drop +/// never runs on a fatal path. Without this, the error a fatal path just +/// reported is exactly the one that never arrives. +pub fn flush() { + if let Some(client) = sentry::Hub::current().client() { + client.flush(Some(Duration::from_secs(2))); } } pub fn report_issue(context: &str, details: &str) { error!("[issue-local][tracking] {context}: {details}"); + sentry::capture_message(&format!("{context}: {details}"), sentry::Level::Error); } pub fn report_error(context: &str, err: &(dyn std::error::Error + Send + Sync)) { diff --git a/web/Dockerfile b/web/Dockerfile index 741632c8..e66f65e8 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -11,9 +11,25 @@ RUN corepack enable && corepack prepare pnpm@11.9.0 --activate COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile COPY . . -# No VITE_* is baked here on purpose: URLs come from the runtime config below, -# so this one image works for any deployment. -RUN pnpm build +# The build identity, the same VERSION/COMMIT the Go images take. This is the +# one VITE_* that must be baked: it tags every error event with the build, and +# it has to match the release the source maps were uploaded under, which a +# container variable set after the bundle was built could not. Every other +# setting comes from the runtime config below, so one image still serves any +# deployment. +ARG VERSION="" +ARG COMMIT="" +# Source-map upload is optional and off unless CI passes all three. A fork or a +# self-host build sets none of them, needs no Sentry account, and ships no +# source maps. +ARG SENTRY_ORG="" +ARG SENTRY_PROJECT="" +RUN --mount=type=secret,id=sentry_auth_token,required=false \ + VITE_SENTRY_RELEASE="${VERSION:-$COMMIT}" \ + SENTRY_ORG="$SENTRY_ORG" \ + SENTRY_PROJECT="$SENTRY_PROJECT" \ + SENTRY_AUTH_TOKEN="$(cat /run/secrets/sentry_auth_token 2>/dev/null || true)" \ + pnpm build # Stage 2: serve the built SPA from nginx with a history fallback. The # entrypoint renders /config.js from container env at startup. diff --git a/web/docker-entrypoint.sh b/web/docker-entrypoint.sh index 0a6d5b48..5d70eb9b 100644 --- a/web/docker-entrypoint.sh +++ b/web/docker-entrypoint.sh @@ -3,12 +3,21 @@ # any deployment. Runs before nginx starts (nginx /docker-entrypoint.d hook). set -eu +# Values are written into JavaScript string literals, so a double quote, a +# backslash or a line break in one would end the literal early and take the +# whole config with it, leaving the app with no API_URL at all. Escape rather +# than trust whatever ended up in .env. +js() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\r\n' +} + cat > /usr/share/nginx/html/config.js <=18'} + '@sentry/bundler-plugins@10.73.0': + resolution: {integrity: sha512-4X5m2hoqgKO6SxHR7MF9QnvxPNk0hwTJFixDJ/pzQGVM+C34j/rSabouBqd0M+ZdxFeAt/9kA5X0TyeceNo9YQ==} + engines: {node: '>= 18'} + peerDependencies: + rollup: '>=3.2.0' + webpack: '>=5.0.0' + peerDependenciesMeta: + rollup: + optional: true + webpack: + optional: true + + '@sentry/cli-darwin@2.58.6': + resolution: {integrity: sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==} + engines: {node: '>=10'} + os: [darwin] + + '@sentry/cli-linux-arm64@2.58.6': + resolution: {integrity: sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux, freebsd, android] + + '@sentry/cli-linux-arm@2.58.6': + resolution: {integrity: sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux, freebsd, android] + + '@sentry/cli-linux-i686@2.58.6': + resolution: {integrity: sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [linux, freebsd, android] + + '@sentry/cli-linux-x64@2.58.6': + resolution: {integrity: sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux, freebsd, android] + + '@sentry/cli-win32-arm64@2.58.6': + resolution: {integrity: sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@sentry/cli-win32-i686@2.58.6': + resolution: {integrity: sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [win32] + + '@sentry/cli-win32-x64@2.58.6': + resolution: {integrity: sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@sentry/cli@2.58.6': + resolution: {integrity: sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==} + engines: {node: '>= 10'} + hasBin: true + + '@sentry/conventions@0.16.0': + resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==} + engines: {node: '>=14'} + '@sentry/core@10.26.0': resolution: {integrity: sha512-TjDe5QI37SLuV0q3nMOH8JcPZhv2e85FALaQMIhRILH9Ce6G7xW5GSjmH91NUVq8yc3XtiqYlz/EenEZActc4Q==} engines: {node: '>=18'} + '@sentry/core@10.73.0': + resolution: {integrity: sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==} + engines: {node: '>=18'} + '@sentry/react@10.26.0': resolution: {integrity: sha512-Qi0/FVXAalwQNr8zp0tocViH3+MRelW8ePqj3TdMzapkbXRuh07czdGgw8Zgobqcb7l4rRCRAUo2sl/H3KVkIw==} engines: {node: '>=18'} peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x + '@sentry/vite-plugin@5.4.0': + resolution: {integrity: sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA==} + engines: {node: '>= 18'} + '@simplewebauthn/browser@13.3.0': resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} @@ -2058,6 +2137,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.8.31: resolution: {integrity: sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==} hasBin: true @@ -2071,6 +2154,10 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2238,6 +2325,10 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2315,6 +2406,7 @@ packages: eslint@9.39.1: resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -2470,6 +2562,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2789,6 +2885,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2796,6 +2896,10 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + motion-dom@12.23.23: resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} @@ -2839,6 +2943,15 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -2882,6 +2995,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2904,6 +3021,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + prosemirror-changeset@2.3.1: resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==} @@ -2962,6 +3083,9 @@ packages: prosemirror-view@1.41.3: resolution: {integrity: sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -3200,6 +3324,9 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} @@ -3360,6 +3487,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -3376,6 +3506,9 @@ packages: resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} engines: {node: '>=20'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -4542,8 +4675,73 @@ snapshots: '@sentry-internal/replay-canvas': 10.26.0 '@sentry/core': 10.26.0 + '@sentry/bundler-plugins@10.73.0(rollup@4.62.2)': + dependencies: + '@babel/core': 7.28.5 + '@sentry/cli': 2.58.6 + '@sentry/core': 10.73.0 + dotenv: 17.4.2 + find-up: 5.0.0 + glob: 13.0.6 + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.62.2 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/cli-darwin@2.58.6': + optional: true + + '@sentry/cli-linux-arm64@2.58.6': + optional: true + + '@sentry/cli-linux-arm@2.58.6': + optional: true + + '@sentry/cli-linux-i686@2.58.6': + optional: true + + '@sentry/cli-linux-x64@2.58.6': + optional: true + + '@sentry/cli-win32-arm64@2.58.6': + optional: true + + '@sentry/cli-win32-i686@2.58.6': + optional: true + + '@sentry/cli-win32-x64@2.58.6': + optional: true + + '@sentry/cli@2.58.6': + dependencies: + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + progress: 2.0.3 + proxy-from-env: 1.1.0 + which: 2.0.2 + optionalDependencies: + '@sentry/cli-darwin': 2.58.6 + '@sentry/cli-linux-arm': 2.58.6 + '@sentry/cli-linux-arm64': 2.58.6 + '@sentry/cli-linux-i686': 2.58.6 + '@sentry/cli-linux-x64': 2.58.6 + '@sentry/cli-win32-arm64': 2.58.6 + '@sentry/cli-win32-i686': 2.58.6 + '@sentry/cli-win32-x64': 2.58.6 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/conventions@0.16.0': {} + '@sentry/core@10.26.0': {} + '@sentry/core@10.73.0': + dependencies: + '@sentry/conventions': 0.16.0 + '@sentry/react@10.26.0(react@19.2.0)': dependencies: '@sentry/browser': 10.26.0 @@ -4551,6 +4749,15 @@ snapshots: hoist-non-react-statics: 3.3.2 react: 19.2.0 + '@sentry/vite-plugin@5.4.0(rollup@4.62.2)': + dependencies: + '@sentry/bundler-plugins': 10.73.0(rollup@4.62.2) + transitivePeerDependencies: + - encoding + - rollup + - supports-color + - webpack + '@simplewebauthn/browser@13.3.0': {} '@standard-schema/spec@1.1.0': {} @@ -5148,6 +5355,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + baseline-browser-mapping@2.8.31: {} bidi-js@1.0.3: @@ -5163,6 +5372,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -5314,6 +5527,8 @@ snapshots: dom-accessibility-api@0.6.3: {} + dotenv@17.4.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5570,6 +5785,12 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + globals@14.0.0: {} globals@16.5.0: {} @@ -5850,6 +6071,10 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -5858,6 +6083,8 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minipass@7.1.3: {} + motion-dom@12.23.23: dependencies: motion-utils: 12.23.6 @@ -5889,6 +6116,10 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-releases@2.0.27: {} obug@2.1.1: {} @@ -5928,6 +6159,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.5 + minipass: 7.1.3 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -5948,6 +6184,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + progress@2.0.3: {} + prosemirror-changeset@2.3.1: dependencies: prosemirror-transform: 1.10.5 @@ -6051,6 +6289,8 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.10.5 + proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} punycode.js@2.3.1: {} @@ -6257,6 +6497,8 @@ snapshots: dependencies: tldts: 7.0.19 + tr46@0.0.3: {} + tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -6368,6 +6610,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} whatwg-mimetype@4.0.0: {} @@ -6379,6 +6623,11 @@ snapshots: tr46: 6.0.0 webidl-conversions: 8.0.1 + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml index ee1b7f03..8703c965 100644 --- a/web/pnpm-workspace.yaml +++ b/web/pnpm-workspace.yaml @@ -3,6 +3,10 @@ # refuse to run them. esbuild is pulled in transitively by vite/vitest # and needs to compile its native binary at install time. allowBuilds: + # The Sentry Vite plugin shells out to @sentry/cli, so its install script + # (which fetches the binary) has to run or the optional source-map upload + # has nothing to invoke. + '@sentry/cli': true esbuild: true # Overrides used to live under `pnpm.overrides` in package.json but diff --git a/web/src/lib/information.ts b/web/src/lib/information.ts index 3d038cff..e2a08e67 100644 --- a/web/src/lib/information.ts +++ b/web/src/lib/information.ts @@ -11,6 +11,13 @@ export const API_BASE_URL = `${API_URL}/v1`; export const TURNSTILE_KEY = runtimeEnv("TURNSTILE_KEY", import.meta.env.VITE_TURNSTILE_KEY); // Empty means browser error reporting is never initialised. See lib/observability. export const SENTRY_DSN = runtimeEnv("SENTRY_DSN", import.meta.env.VITE_SENTRY_DSN); +// The deployment label events are tagged with. Runtime, so one image can serve +// staging and production. +export const SENTRY_ENVIRONMENT = runtimeEnv("SENTRY_ENVIRONMENT", import.meta.env.VITE_SENTRY_ENVIRONMENT, import.meta.env.MODE); +// The build events are tagged with. Build-time on purpose: it has to match the +// release the source maps were uploaded under, and a container variable set +// after the bundle was built could not. +export const SENTRY_RELEASE = import.meta.env.VITE_SENTRY_RELEASE ?? ""; export const HUMAN_VERIFICATION_FAIL = "We couldn’t verify you’re human. Please try the security check again or reload the page."; export const PASSWORD_FAIL = "The password must be at least 8 characters long and contain both uppercase and lowercase letters, as well as a number." export const TOKEN_KEY = "auth_token"; diff --git a/web/src/lib/observability.ts b/web/src/lib/observability.ts index 89bfa3bb..6d2ffeb1 100644 --- a/web/src/lib/observability.ts +++ b/web/src/lib/observability.ts @@ -12,7 +12,7 @@ // a chunk still in flight would miss exactly that error. Not initialising it // costs a self-hoster some dead bundle weight and zero network calls. import * as Sentry from "@sentry/react"; -import { SENTRY_DSN } from "./information"; +import { SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "./information"; let reporting = false; @@ -23,7 +23,10 @@ export function initErrorReporting(): void { Sentry.init({ dsn: SENTRY_DSN, sendDefaultPii: true, - environment: import.meta.env.MODE, + environment: SENTRY_ENVIRONMENT, + // Empty is omitted rather than sent: an event tagged with the empty + // release matches no uploaded source map and reads as a real release. + release: SENTRY_RELEASE || undefined, }); reporting = true; } diff --git a/web/src/main.tsx b/web/src/main.tsx index 142dab03..f88b88a7 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -67,6 +67,7 @@ import { Toaster } from '@/components/ui/toaster'; import { initErrorReporting } from "@/lib/observability"; +// Before the first render, so a boot failure is reported too. initErrorReporting(); import { QueryClient, QueryClientProvider } from "@tanstack/react-query" diff --git a/web/vite.config.ts b/web/vite.config.ts index 6a6a7bd3..8441c331 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,12 +2,47 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import path from "path"; import tailwindcss from "@tailwindcss/vite"; +import { sentryVitePlugin } from "@sentry/vite-plugin"; + +// Source-map upload, and nothing else, is what the Sentry plugin does here. +// +// It is never a required build step: a fork, a self-host build or a local +// `pnpm build` sets none of these and the plugin is simply not in the plugin +// list, so nothing needs a Sentry account and nothing is uploaded. CI passes +// the token as a build secret only for the hosted release. +// +// Source maps are emitted only in that case too, so the shipped bundle is +// unchanged for everybody else, and `filesToDeleteAfterUpload` keeps the .map +// files out of the image once they have been sent. +const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN; +const sentryOrg = process.env.SENTRY_ORG; +const sentryProject = process.env.SENTRY_PROJECT; +const uploadSourceMaps = Boolean(sentryAuthToken && sentryOrg && sentryProject); + +const sentryPlugins = uploadSourceMaps + ? [ + sentryVitePlugin({ + authToken: sentryAuthToken, + org: sentryOrg, + project: sentryProject, + release: { name: process.env.VITE_SENTRY_RELEASE }, + sourcemaps: { filesToDeleteAfterUpload: ["dist/**/*.map"] }, + telemetry: false, + }), + ] + : []; export default defineConfig({ plugins: [ react(), tailwindcss(), + ...sentryPlugins, ], + build: { + // Only when they are going to be uploaded: shipping them otherwise + // would hand every visitor the dashboard's original sources. + sourcemap: uploadSourceMaps, + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"),