From 5a57820e7686048b9236fb760b805c6d5082377d Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Tue, 27 Oct 2026 16:07:08 +1100 Subject: [PATCH 1/3] feat: report errors to Sentry Sentry has not sent anything since 2020, when Sentry.init was removed. - Load @sentry/angular after sign-in, so it is not in the initial bundle. Errors from before it loads are queued. - Read the DSN from the authority config.backoffice.sentry_dsn, with the PlaceOS default. '' or false turns it off. - Send errors, 10% of traces and replays of sessions with an error. Send only the user ID. - Upload hidden source maps in CI when the SENTRY_* secrets are set, and update the service worker hashes after debug IDs are injected. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 27 ++++++++++ bun.lock | 53 +++++++++++++++----- config/ngsw-rehash.js | 24 +++++++++ package.json | 3 +- project.json | 5 ++ src/app/app.config.ts | 3 ++ src/app/app.ts | 4 ++ src/app/common/sentry.ts | 88 +++++++++++++++++++++++++++++++++ src/tests/common/sentry.spec.ts | 75 ++++++++++++++++++++++++++++ 9 files changed, 270 insertions(+), 12 deletions(-) create mode 100644 config/ngsw-rehash.js create mode 100644 src/app/common/sentry.ts create mode 100644 src/tests/common/sentry.spec.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c8f7bebbc..ef58d4ea0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -90,6 +90,33 @@ jobs: - name: Add application changelog to build run: cp "${RUNNER_TEMP}/CHANGELOG.md" dist/backoffice/browser/CHANGELOG.md + # Upload source maps so Sentry shows the original code. Skipped + # until the SENTRY_* secrets are set. Debug IDs change the scripts, + # so the service worker hashes are updated after. Development + # builds have no service worker. + # The release name must match SENTRY_RELEASE in src/app/common/sentry.ts. + - name: Upload source maps to Sentry + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: | + if [ -z "$SENTRY_AUTH_TOKEN" ] || [ -z "$SENTRY_ORG" ] || [ -z "$SENTRY_PROJECT" ]; then + echo "SENTRY_AUTH_TOKEN, SENTRY_ORG or SENTRY_PROJECT is not set. Skipping upload." + exit 0 + fi + release=$(bun -e "import { VERSION } from './src/env/version.ts'; console.log('backoffice@' + VERSION.hash)") + bunx sentry-cli sourcemaps inject dist/backoffice/browser + if [ -f dist/backoffice/browser/ngsw.json ]; then + bun config/ngsw-rehash.js dist/backoffice/browser + fi + bunx sentry-cli sourcemaps upload --release="$release" dist/backoffice/browser + + # Production builds make hidden source maps for Sentry only + - name: Remove hidden source maps + if: steps.build_ext.outputs.configuration != 'development' + run: find dist/backoffice/browser -name '*.map' -delete + - name: Upload build artifact uses: actions/upload-artifact@v4 with: diff --git a/bun.lock b/bun.lock index 66e3a0867..59f550b20 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "@angular/platform-browser": "22.2.3", "@angular/router": "22.2.3", "@angular/service-worker": "22.2.3", + "@sentry/angular": "^11.9.0", "postcss": "^8.5.6", "shiki": "^4.0.2", "tailwindcss": "^4.1.17", @@ -47,7 +48,7 @@ "@placeos/ts-client": "^6.11.0", "@playwright/test": "^1.36.0", "@schematics/angular": "22.2.3", - "@sentry/browser": "^10.25.0", + "@sentry/cli": "^3.8.2", "@swc-node/register": "~1.9.1", "@swc/core": "~1.5.7", "@swc/helpers": "~0.5.11", @@ -848,17 +849,39 @@ "@schematics/angular": ["@schematics/angular@22.2.3", "", { "dependencies": { "@angular-devkit/core": "22.2.3", "@angular-devkit/schematics": "22.2.3", "jsonc-parser": "3.3.1", "oxc-parser": "0.150.0", "typescript": "6.0.3" } }, "sha512-DY8wv/LtNf3YD/EEoFdER4ihMyw5yywuOQEKasm4d2uBJ5peGIWV3LXJXXqD7b4WVC8xS1eUAjingyWfI9reHw=="], - "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.25.0", "", { "dependencies": { "@sentry/core": "10.25.0" } }, "sha512-wzg1ITZxrRtQouHPCgpt3tl1GiNAWFVy2RYK2KstFEhpYBAOUn9BAdP7KU9UyHBFKqbAvV4oGtAT8H2/Y4+leA=="], + "@sentry/angular": ["@sentry/angular@11.9.0", "", { "dependencies": { "@sentry/browser": "11.9.0", "@sentry/conventions": "^0.27.0", "@sentry/core": "11.9.0", "tslib": "^2.4.1" }, "peerDependencies": { "@angular/common": ">= 14.x <= 22.x", "@angular/core": ">= 14.x <= 22.x", "@angular/router": ">= 14.x <= 22.x", "rxjs": "^6.5.5 || ^7.x" } }, "sha512-8DGRbLdMRavhsN7DkE6vKrMVrYnxYLNC4N3XGLxTBRoctZNZQopXRLbW7XdGXxMohSRsh3oUpgIwjZUOfqPd5A=="], - "@sentry-internal/feedback": ["@sentry-internal/feedback@10.25.0", "", { "dependencies": { "@sentry/core": "10.25.0" } }, "sha512-qlbT4tOd+WRyKpLdsbi26rkynGBoVabnY8/9rFnTxZ0WIUG5EFhJFqEeRLMyv+uk0uRFF3H0I9+u+qP/BKxIcQ=="], + "@sentry/browser": ["@sentry/browser@11.9.0", "", { "dependencies": { "@sentry/browser-utils": "11.9.0", "@sentry/conventions": "^0.27.0", "@sentry/core": "11.9.0", "@sentry/feedback": "11.9.0", "@sentry/replay": "11.9.0", "@sentry/replay-canvas": "11.9.0" } }, "sha512-3YQ6DJyeqOV2b02m+kUNLUcFAlzyjCL30+4NZgUQLWlpmZgTO+b01v+HXwY5d7etf8mAKwEjL8BPoVqkVqL2Kg=="], - "@sentry-internal/replay": ["@sentry-internal/replay@10.25.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.25.0", "@sentry/core": "10.25.0" } }, "sha512-V/kKQn9T46HBTiP0bIThmpVr94K4vXwYM3/EHVpGSq4P9RynX06cgps8GLHq94+A0kX/DbK9igEMZmIuzS1q3A=="], + "@sentry/browser-utils": ["@sentry/browser-utils@11.9.0", "", { "dependencies": { "@sentry/conventions": "^0.27.0", "@sentry/core": "11.9.0", "web-vitals": "^6.2.3" } }, "sha512-HwEa+BYxNJ+9msyzKSL2w5+eQwWRGk13StZoMinRmkq+tK34pmIOeDRq/ARlofyYzffvY6R9Xggh9peLjYn2Rg=="], - "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.25.0", "", { "dependencies": { "@sentry-internal/replay": "10.25.0", "@sentry/core": "10.25.0" } }, "sha512-zuj5jVNswZ/aA1nPPbU+VIFkQG0695lbyIfS1Skq+5o2FdRIS3MGnBXw1abI9h4pft8GLQWcKiBxISM7UpSz6w=="], + "@sentry/cli": ["@sentry/cli@3.8.2", "", { "dependencies": { "progress": "^2.0.3", "proxy-from-env": "^1.1.0", "undici": "^6.22.0", "which": "^2.0.2" }, "optionalDependencies": { "@sentry/cli-darwin": "3.8.2", "@sentry/cli-linux-arm": "3.8.2", "@sentry/cli-linux-arm64": "3.8.2", "@sentry/cli-linux-i686": "3.8.2", "@sentry/cli-linux-x64": "3.8.2", "@sentry/cli-win32-arm64": "3.8.2", "@sentry/cli-win32-i686": "3.8.2", "@sentry/cli-win32-x64": "3.8.2" }, "bin": { "sentry-cli": "bin/sentry-cli" } }, "sha512-JbB/UTBPRQREkI6CaxD8Cs0ppNc3YT8CROgRV7WeaqPn7mZOAgTYqtcxiW/OF7OquOIHUd0u6gu2vL+g5ITdHQ=="], - "@sentry/browser": ["@sentry/browser@10.25.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.25.0", "@sentry-internal/feedback": "10.25.0", "@sentry-internal/replay": "10.25.0", "@sentry-internal/replay-canvas": "10.25.0", "@sentry/core": "10.25.0" } }, "sha512-UgSVT3RTM3vsK914TPuHVJQsjq5ooXVmjMtsWP3Ep+6f7N+1UVX4ZXsyyj5lDOcWdc79FgproD+MrEf9Cj6uBg=="], + "@sentry/cli-darwin": ["@sentry/cli-darwin@3.8.2", "", { "os": "darwin" }, "sha512-S9xIhtpPk5uU0J0dMLz5VCtlBn+OHjG7En8HkZNGYsleNACQOwcG5wcfAnGuq+7x643KrEOubtd1VPtGfrBHsQ=="], - "@sentry/core": ["@sentry/core@10.25.0", "", {}, "sha512-mGi4BYIPwZjWdOXHrPoXz1AW4/cQbFoiuW/m+OOATmtSoGTDnWYwP+qZU7VLlL+v8ZEzxfPi2C1NPfJtPj7QWA=="], + "@sentry/cli-linux-arm": ["@sentry/cli-linux-arm@3.8.2", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm" }, "sha512-maj9TXsnyWqNKs+UTHc+/gpsvXPVlCbheH+QHaBx8FD9u0B56REVDfxKUSCzeC9eTGDf0eKAFxbxWiMXKG4B5Q=="], + + "@sentry/cli-linux-arm64": ["@sentry/cli-linux-arm64@3.8.2", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm64" }, "sha512-sWKkGTSOqRmGW8d2Q6ir1gBEkvfXWNbDxcf/dF0MJt8q9p594k+04GOm5mZ/VAePKGzs9W8fXE1xfmZ/UNCLcQ=="], + + "@sentry/cli-linux-i686": ["@sentry/cli-linux-i686@3.8.2", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "ia32" }, "sha512-6aOKjTWGl5XEYCOOha4GO3aq5C7ISmYB8QECec8mFLWx0L3QoXdKtaNQoLUYslsSN0jEmFeOgA0U27Tp5OEBww=="], + + "@sentry/cli-linux-x64": ["@sentry/cli-linux-x64@3.8.2", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "x64" }, "sha512-icx2NoqaxuE4H5khIoRCB/MDX76/kKbDZFvJe/UYloGp9aucxi6c7mk8oDxFvskMFqLfbprQ6RzKH7iILmODQg=="], + + "@sentry/cli-win32-arm64": ["@sentry/cli-win32-arm64@3.8.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-lSzj1ywih1madjyMrTHSjJKPhvErozSsSrG7QF6yIt7dMLYX4Qh9X3puVReh1YB5mOPCX8MJPtur67YJmR1S+w=="], + + "@sentry/cli-win32-i686": ["@sentry/cli-win32-i686@3.8.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-k4dPGNNliNY46G/4hSeJygHlMoyMoL292IO+YP3MasBqyqPEy9hz6Py3FjUVW/Ckso52eoAVtGgb6DntFBjIWA=="], + + "@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@3.8.2", "", { "os": "win32", "cpu": "x64" }, "sha512-IBX5W2M3Zckpg3zOXfnNoayWTKhVlZN/b7ifO7dRArkOsNRzVa2lNB5M7L28O0/ZYu/8V+SnzCoHVGN9bxRdwg=="], + + "@sentry/conventions": ["@sentry/conventions@0.27.0", "", {}, "sha512-Mrqd/o7n/z7qWSYfQQA7x4fFTA0IqOG6JNsbBpnC97khPwZk7n6DPtOO7YgaYplSl8R8QLRbBz9Xv2lVhToSAg=="], + + "@sentry/core": ["@sentry/core@11.9.0", "", { "dependencies": { "@sentry/conventions": "^0.27.0" } }, "sha512-t0dZyHILt2XVC4Fe9UmAty6YRfOuQv95cvWeNl+HlW4LphjF5/jkALV3cJCUMdhpblYELgIJC4U/S/GoYuI2bA=="], + + "@sentry/feedback": ["@sentry/feedback@11.9.0", "", { "dependencies": { "@sentry/core": "11.9.0" } }, "sha512-Rq3xwo66AuuLgS4/v/ZrNwJo+barxzAtXRZWmMU5T60AGX2apTFQfvo0H4zsVwvxouvw9NBg8RmL7jF9MdNG7g=="], + + "@sentry/replay": ["@sentry/replay@11.9.0", "", { "dependencies": { "@sentry/browser-utils": "11.9.0", "@sentry/core": "11.9.0" } }, "sha512-fSM8f1skiu6+O1Mzpn3BjtjoESygA+UcZaXyLr7t/1yDNPi7ne2+pJnrXo4oIFV1UbWJHPedo2vOIBNHDLABTg=="], + + "@sentry/replay-canvas": ["@sentry/replay-canvas@11.9.0", "", { "dependencies": { "@sentry/core": "11.9.0", "@sentry/replay": "11.9.0" } }, "sha512-EqSlEg1iJFXKlGrpddZ+ytX7SS5oOgui5kAYqDOfNolmxoQc2dz0KXSEtLpiWjwnnfeJ5El0uAmv/1jPdrd4aw=="], "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], @@ -1808,10 +1831,14 @@ "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], "proxy-agent-negotiate": ["proxy-agent-negotiate@1.1.0", "", { "peerDependencies": { "kerberos": "^2.0.0" }, "optionalPeers": ["kerberos"] }, "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "prr": ["prr@1.0.1", "", {}, "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw=="], "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="], @@ -2046,7 +2073,7 @@ "typescript-eslint": ["typescript-eslint@8.65.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.65.0", "@typescript-eslint/parser": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA=="], - "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "undici": ["undici@6.29.1", "", {}, "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -2106,6 +2133,8 @@ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "web-vitals": ["web-vitals@6.2.3", "", {}, "sha512-Dmy+vdO1UPMqXiAtZSNayhl4f794Bxfd94W4HIj2HlYtczEQfEOon969memlcpcHrTuOKLa5baFjX7WbgmOq+A=="], + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], "websocket": ["websocket@1.0.35", "", { "dependencies": { "bufferutil": "^4.0.1", "debug": "^2.2.0", "es5-ext": "^0.10.63", "typedarray-to-buffer": "^3.1.5", "utf-8-validate": "^5.0.2", "yaeti": "^0.0.6" } }, "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q=="], @@ -2116,7 +2145,7 @@ "whatwg-url": ["whatwg-url@12.0.1", "", { "dependencies": { "tr46": "^4.1.1", "webidl-conversions": "^7.0.0" } }, "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ=="], - "which": ["which@3.0.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -2562,8 +2591,6 @@ "cosmiconfig/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], - "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "default-browser/default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], "eslint/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], @@ -2636,6 +2663,10 @@ "nx/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "nx/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + + "nx/which": ["which@3.0.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg=="], + "nx/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "nx/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], diff --git a/config/ngsw-rehash.js b/config/ngsw-rehash.js new file mode 100644 index 000000000..1bdedcd04 --- /dev/null +++ b/config/ngsw-rehash.js @@ -0,0 +1,24 @@ +// Update the file hashes in a built ngsw.json after files change, for example +// after `sentry-cli sourcemaps inject` adds debug IDs. The service worker +// rejects files whose hash does not match. +// Usage: bun config/ngsw-rehash.js dist/backoffice/browser +const { createHash } = require('crypto'); +const { readFileSync, writeFileSync } = require('fs'); +const { join } = require('path'); + +const dir = process.argv[2]; +if (!dir) throw new Error('Usage: ngsw-rehash.js '); +const manifest_path = join(dir, 'ngsw.json'); +const manifest = JSON.parse(readFileSync(manifest_path, 'utf-8')); + +let changed = 0; +for (const url of Object.keys(manifest.hashTable)) { + const hash = createHash('sha1') + .update(readFileSync(join(dir, url))) + .digest('hex'); + if (manifest.hashTable[url] === hash) continue; + manifest.hashTable[url] = hash; + changed++; +} +writeFileSync(manifest_path, JSON.stringify(manifest, null, 2)); +console.log(`Updated ${changed} hashes in ${manifest_path}`); diff --git a/package.json b/package.json index d8de414c9..404560dee 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@angular/platform-browser": "22.2.3", "@angular/router": "22.2.3", "@angular/service-worker": "22.2.3", + "@sentry/angular": "^11.9.0", "postcss": "^8.5.6", "shiki": "^4.0.2", "tailwindcss": "^4.1.17", @@ -57,7 +58,7 @@ "@placeos/ts-client": "^6.11.0", "@playwright/test": "^1.36.0", "@schematics/angular": "22.2.3", - "@sentry/browser": "^10.25.0", + "@sentry/cli": "^3.8.2", "@swc-node/register": "~1.9.1", "@swc/core": "~1.5.7", "@swc/helpers": "~0.5.11", diff --git a/project.json b/project.json index d63f45ec1..0976298c5 100644 --- a/project.json +++ b/project.json @@ -55,6 +55,11 @@ "configurations": { "production": { "serviceWorker": "ngsw-config.json", + "sourceMap": { + "scripts": true, + "styles": false, + "hidden": true + }, "budgets": [ { "type": "initial", diff --git a/src/app/app.config.ts b/src/app/app.config.ts index f2a5683e4..248bda3fb 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,5 +1,6 @@ import { ApplicationConfig, + ErrorHandler, isDevMode, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection, @@ -12,10 +13,12 @@ import { import { provideServiceWorker } from '@angular/service-worker'; import { appRoutes } from './app.routes'; import { reloadApplicationOnChunkLoadError } from './common/application'; +import { SentryReportingErrorHandler } from './common/sentry'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), + { provide: ErrorHandler, useClass: SentryReportingErrorHandler }, provideZonelessChangeDetection(), provideRouter( appRoutes, diff --git a/src/app/app.ts b/src/app/app.ts index 415e63fa7..4af80b576 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -21,6 +21,7 @@ import { AsyncHandler } from './common/async-handler.class'; import { detectIE, log } from './common/general'; import { setNotifyOutlet } from './common/notifications'; import { PlaceSettings, setLoadingMessage, setupPlace } from './common/placeos'; +import { initSentry } from './common/sentry'; import { SettingsService } from './common/settings.service'; import { signalFromClient, waitForSignalValue } from './common/signals'; import { currentUser } from './common/user-state'; @@ -221,6 +222,9 @@ export class AppComponent extends AsyncHandler implements OnInit { 30 * 1000, ).catch(() => false); if (!user_ready) return this.onInitError(); + initSentry(this._injector, currentUser().id).catch((error) => + log('Init', 'Failed to start Sentry', [error], 'warn'), + ); setLoadingMessage('Initialising locales...'); // TranslatePipe is pure, so load translations before the shell renders await this._initLocale(); diff --git a/src/app/common/sentry.ts b/src/app/common/sentry.ts new file mode 100644 index 000000000..c77863c2b --- /dev/null +++ b/src/app/common/sentry.ts @@ -0,0 +1,88 @@ +import { ErrorHandler, Injectable, Injector, isDevMode } from '@angular/core'; +import { authority, isMock } from '@placeos/ts-client'; + +import { VERSION } from '../../env/version'; +import { log } from './general'; + +/** DSN for deployments that do not set `config.backoffice.sentry_dsn` */ +const DEFAULT_SENTRY_DSN = + 'https://7941b5b93d7c4712803604891cdfec23@o437082.ingest.sentry.io/5399124'; +/** Most errors kept while Sentry loads */ +const MAX_QUEUED_ERRORS = 20; + +/** Release name. CI uploads source maps with this name. */ +export const SENTRY_RELEASE = `backoffice@${VERSION.hash}`; + +type ErrorReporter = (error: unknown) => void; + +let _reporter: ErrorReporter | null = null; +let _queue: unknown[] | null = []; + +/** + * Send an error to Sentry. Before Sentry loads, keep the error in a queue. + * After Sentry is turned off, drop it. + */ +export function reportError(error: unknown) { + if (_reporter) return _reporter(error); + if (_queue && _queue.length < MAX_QUEUED_ERRORS) _queue.push(error); +} + +/** + * Angular error handler. Logs errors to the console as the default handler + * does, and sends them to Sentry. + */ +@Injectable() +export class SentryReportingErrorHandler implements ErrorHandler { + public handleError(error: unknown) { + console.error(error); + reportError(error); + } +} + +/** + * DSN for this deployment. The authority can set `config.backoffice.sentry_dsn` + * to a different DSN, or to `''` or `false` to turn Sentry off. + */ +export function sentryDsn(): string { + const value = authority()?.config?.backoffice?.sentry_dsn; + if (value === undefined || value === null) return DEFAULT_SENTRY_DSN; + return typeof value === 'string' ? value : ''; +} + +/** + * Load and start Sentry. Call once after the authority and user load. + * Does nothing on localhost, in mock mode or when the DSN is empty. + */ +export async function initSentry(injector: Injector, user_id: string) { + const dsn = sentryDsn(); + if (!dsn || isMock() || location.hostname === 'localhost') { + _queue = null; + return; + } + const Sentry = await import('@sentry/angular'); + Sentry.init({ + dsn, + release: SENTRY_RELEASE, + environment: isDevMode() ? 'development' : 'production', + integrations: (defaults) => [ + // Angular's global error listeners already send window errors + // to the error handler. This prevents duplicate events. + ...defaults.filter(({ name }) => name !== 'GlobalHandlers'), + Sentry.browserTracingIntegration(), + Sentry.replayIntegration(), + ], + tracesSampleRate: isDevMode() ? 1 : 0.1, + replaysSessionSampleRate: 0, + replaysOnErrorSampleRate: 1, + }); + // Starts the router spans for the tracing integration + injector.get(Sentry.TraceService); + Sentry.setUser({ id: user_id }); + Sentry.setTag('domain', authority()?.domain || location.hostname); + const handler = Sentry.createErrorHandler({ logErrors: false }); + _reporter = (error) => handler.handleError(error); + const queued = _queue || []; + _queue = null; + queued.forEach(_reporter); + log('Sentry', `Reporting errors for ${SENTRY_RELEASE}`); +} diff --git a/src/tests/common/sentry.spec.ts b/src/tests/common/sentry.spec.ts new file mode 100644 index 000000000..260a16715 --- /dev/null +++ b/src/tests/common/sentry.spec.ts @@ -0,0 +1,75 @@ +/** @vitest-environment-options { "url": "https://placeos.example/backoffice/" } */ +import { Injector } from '@angular/core'; +import { authority } from '@placeos/ts-client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@placeos/ts-client', () => ({ + authority: vi.fn(), + isMock: vi.fn(() => false), +})); + +const handle_error = vi.fn(); +const sentry_init = vi.fn(); +vi.mock('@sentry/angular', () => ({ + init: sentry_init, + browserTracingIntegration: vi.fn(), + replayIntegration: vi.fn(), + TraceService: class {}, + setUser: vi.fn(), + setTag: vi.fn(), + createErrorHandler: () => ({ handleError: handle_error }), +})); + +const injector = { get: vi.fn() } as unknown as Injector; + +function setAuthorityDsn(sentry_dsn: unknown) { + vi.mocked(authority).mockReturnValue({ + domain: 'placeos.example', + config: { backoffice: { sentry_dsn } }, + } as unknown as ReturnType); +} + +/** Load a new copy of the module, so each test starts with an empty queue */ +async function loadSentry() { + vi.resetModules(); + return import('../../app/common/sentry'); +} + +describe('sentry', () => { + beforeEach(() => vi.clearAllMocks()); + + it('uses the default DSN when the authority does not set one', async () => { + const { sentryDsn } = await loadSentry(); + setAuthorityDsn(undefined); + expect(sentryDsn()).toContain('ingest.sentry.io'); + setAuthorityDsn('https://key@example.ingest.sentry.io/1'); + expect(sentryDsn()).toBe('https://key@example.ingest.sentry.io/1'); + setAuthorityDsn(''); + expect(sentryDsn()).toBe(''); + setAuthorityDsn(false); + expect(sentryDsn()).toBe(''); + }); + + it('sends errors reported before start up once Sentry starts', async () => { + const { initSentry, reportError } = await loadSentry(); + setAuthorityDsn(undefined); + const error = new Error('early'); + reportError(error); + expect(handle_error).not.toHaveBeenCalled(); + await initSentry(injector, 'user-1'); + expect(sentry_init).toHaveBeenCalledOnce(); + expect(handle_error).toHaveBeenCalledWith(error); + reportError('late'); + expect(handle_error).toHaveBeenCalledWith('late'); + }); + + it('does not load Sentry when the authority turns it off', async () => { + const { initSentry, reportError } = await loadSentry(); + setAuthorityDsn(''); + reportError(new Error('early')); + await initSentry(injector, 'user-1'); + reportError(new Error('late')); + expect(sentry_init).not.toHaveBeenCalled(); + expect(handle_error).not.toHaveBeenCalled(); + }); +}); From acb15ad78c224bd9aa2d4bad332bdd1de19da87c Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Tue, 27 Oct 2026 16:22:08 +1100 Subject: [PATCH 2/3] docs: add Sentry setup guide Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++ docs/sentry.md | 114 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 docs/sentry.md diff --git a/README.md b/README.md index bf8861dbb..287e3531b 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,11 @@ config/ Development proxy and build metadata scripts Most feature areas are lazy-loaded from `src/app/app.routes.ts`. The application uses hash-based routing and enables its service worker only in production. +## Error reporting + +Backoffice sends errors to Sentry. See [`docs/sentry.md`](docs/sentry.md) to +set the DSN for a domain and to set up source map uploads. + ## Stack - Angular 22 and Angular Material diff --git a/docs/sentry.md b/docs/sentry.md new file mode 100644 index 000000000..4fee2fd1a --- /dev/null +++ b/docs/sentry.md @@ -0,0 +1,114 @@ +# Sentry error reporting + +Backoffice sends errors, performance traces and session replays to +[Sentry](https://sentry.io). The code is in +[`src/app/common/sentry.ts`](../src/app/common/sentry.ts). + +## What Backoffice sends + +| Data | When | +| -------- | -------------------------------------------------------- | +| Errors | All errors that go to the Angular error handler | +| Traces | 10% of page loads and navigations (100% on dev builds) | +| Replays | Only for a session that has an error | +| User | The user ID. Backoffice does not send the email or name. | +| `domain` | Tag with the domain of the authority | + +Replays mask all text and block all media. + +Backoffice loads Sentry after the user signs in. Errors that occur before this +stay in a queue (20 errors maximum). Backoffice sends them when Sentry starts. + +Sentry does not start: + +- On `localhost` +- In mock mode +- When the DSN is empty + +## Set the DSN for a domain + +Each domain can set its DSN in the domain config at +`config.backoffice.sentry_dsn`: + +```json +{ + "backoffice": { + "sentry_dsn": "https://@.ingest.sentry.io/" + } +} +``` + +| Value | Result | +| --------------- | --------------------------------------- | +| Not set | Backoffice uses the PlaceOS default DSN | +| A DSN | Backoffice sends to that Sentry project | +| `""` or `false` | Backoffice does not send data to Sentry | + +To change the default DSN, change `DEFAULT_SENTRY_DSN` in +[`src/app/common/sentry.ts`](../src/app/common/sentry.ts). + +## Upload source maps + +The build minifies the scripts. To show the original code in Sentry stack +traces, CI uploads source maps to Sentry. Do these steps one time. + +### 1. Find the organization slug + +The slug is the short name of the organization in the Sentry URL: +`https://.sentry.io/`. You can also find it in **Settings → +Organization → General Settings → Organization Slug**. + +### 2. Find the project slug + +1. Go to **Settings → Projects** and open the project. +2. Open **Client Keys (DSN)**. Make sure that the DSN is the same as the + default DSN in `sentry.ts`. The last number of the DSN is the project ID. +3. Open **General Settings**. The **Name** field is the project slug. + +Do not use the numbers in the DSN. These are IDs, not slugs. + +### 3. Make an auth token + +Go to **Settings → Developer Settings → Organization Tokens** and make a token. +On some Sentry versions, this page has the name **Auth Tokens**. + +### 4. Add the GitHub secrets + +In the GitHub repository, go to **Settings → Secrets and variables → Actions**. +Add these repository secrets: + +| Secret | Value | +| ------------------- | ------------------------------ | +| `SENTRY_AUTH_TOKEN` | The token from step 3 | +| `SENTRY_ORG` | The organization slug (step 1) | +| `SENTRY_PROJECT` | The project slug (step 2) | + +CI does not upload source maps until all three secrets have a value. The +upload starts on the next run of the **BUILD** workflow. + +### 5. Make sure that the upload works + +1. Open the **BUILD** workflow run in GitHub Actions. +2. Open the **Upload source maps to Sentry** step. Make sure that it does not + show `Skipping upload`. +3. In Sentry, open an error from the new release. The stack trace must show + the TypeScript source, not minified code. + +## How the upload works + +The **Upload source maps to Sentry** step in +[`.github/workflows/build.yml`](../.github/workflows/build.yml) does these +steps: + +1. `sentry-cli sourcemaps inject` adds a debug ID to each script and its map. + Sentry uses the debug ID to find the map for a script. +2. [`config/ngsw-rehash.js`](../config/ngsw-rehash.js) updates the file hashes + in `ngsw.json`. The debug IDs change the scripts, and the service worker + does not accept a file if its hash is different. Only production builds + have a service worker. +3. `sentry-cli sourcemaps upload` uploads the maps to the release + `backoffice@`. This name must be the same as `SENTRY_RELEASE` in + `sentry.ts`. + +Production builds make hidden source maps. The scripts do not link to the maps, +and CI deletes the maps before the deploy. Development builds keep their maps. From ac0e009cc183719200ab156eac23a2796121e2a5 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Tue, 27 Oct 2026 16:28:00 +1100 Subject: [PATCH 3/3] fix: always log when Sentry fails to start Co-Authored-By: Claude Opus 5.5 --- src/app/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/app.ts b/src/app/app.ts index 4af80b576..a42cde6dc 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -223,7 +223,7 @@ export class AppComponent extends AsyncHandler implements OnInit { ).catch(() => false); if (!user_ready) return this.onInitError(); initSentry(this._injector, currentUser().id).catch((error) => - log('Init', 'Failed to start Sentry', [error], 'warn'), + log('Init', 'Failed to start Sentry', [error], 'warn', true), ); setLoadingMessage('Initialising locales...'); // TranslatePipe is pure, so load translations before the shell renders