mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 02:13:13 +00:00
feat(observability): identify and scrub Sentry transactions
This commit is contained in:
parent
dc313dc50a
commit
207199559f
26 changed files with 1200 additions and 128 deletions
6
.github/workflows/deploy-staging.yml
vendored
6
.github/workflows/deploy-staging.yml
vendored
|
|
@ -33,6 +33,7 @@ jobs:
|
|||
env:
|
||||
VITE_API_URL: https://api.staging.seahaven.com/api
|
||||
VITE_SENTRY_ENVIRONMENT: staging
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
AWS_REGION: us-east-1
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
|
@ -79,6 +80,11 @@ jobs:
|
|||
STACK_NAME: shoc-frontend-staging
|
||||
WAIT_FOR_INVALIDATION: "true"
|
||||
|
||||
- name: Upload private source maps
|
||||
run: bash scripts/upload-sourcemaps.sh
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
|
|||
22
.github/workflows/deploy.yml
vendored
22
.github/workflows/deploy.yml
vendored
|
|
@ -36,3 +36,25 @@ jobs:
|
|||
post-deploy-script: scripts/deploy-web.sh
|
||||
secrets:
|
||||
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
||||
|
||||
upload-sourcemaps:
|
||||
name: Upload private source maps
|
||||
needs: deploy
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- name: Build exact deployed release
|
||||
run: npm ci && npm run build
|
||||
- name: Upload source maps to Sentry
|
||||
run: bash scripts/upload-sourcemaps.sh
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
|
|
|||
26
config/sentry-release.test.ts
Normal file
26
config/sentry-release.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LOCAL_DEVELOPMENT_RELEASE, resolveSentryRelease } from "./sentry-release";
|
||||
|
||||
describe("resolveSentryRelease", () => {
|
||||
it("maps the exact commit to the frontend release", () => {
|
||||
const sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
|
||||
|
||||
expect(resolveSentryRelease(sha, true)).toEqual({
|
||||
commitSha: sha.toLowerCase(),
|
||||
release: `shoc-frontend@${sha.toLowerCase()}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails CI when the commit identity is missing or malformed", () => {
|
||||
expect(() => resolveSentryRelease(undefined, true)).toThrow(/40-character commit SHA/);
|
||||
expect(() => resolveSentryRelease("not-a-sha", true)).toThrow(/40-character commit SHA/);
|
||||
});
|
||||
|
||||
it("labels non-CI builds without a commit as local development", () => {
|
||||
expect(resolveSentryRelease(undefined, false)).toEqual({
|
||||
commitSha: LOCAL_DEVELOPMENT_RELEASE,
|
||||
release: LOCAL_DEVELOPMENT_RELEASE,
|
||||
});
|
||||
});
|
||||
});
|
||||
32
config/sentry-release.ts
Normal file
32
config/sentry-release.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export const SENTRY_SERVICE_NAME = "shoc-frontend";
|
||||
export const LOCAL_DEVELOPMENT_RELEASE = "local-development";
|
||||
|
||||
const COMMIT_SHA = /^[0-9a-f]{40}$/i;
|
||||
|
||||
export function resolveSentryRelease(
|
||||
candidate: string | undefined,
|
||||
ci: boolean,
|
||||
): {
|
||||
commitSha: string;
|
||||
release: string;
|
||||
} {
|
||||
const commitSha = candidate?.trim();
|
||||
|
||||
if (commitSha && COMMIT_SHA.test(commitSha)) {
|
||||
return {
|
||||
commitSha: commitSha.toLowerCase(),
|
||||
release: `${SENTRY_SERVICE_NAME}@${commitSha.toLowerCase()}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ci) {
|
||||
throw new Error(
|
||||
"CI builds require VITE_APP_COMMIT_SHA or GITHUB_SHA as a 40-character commit SHA",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
commitSha: LOCAL_DEVELOPMENT_RELEASE,
|
||||
release: LOCAL_DEVELOPMENT_RELEASE,
|
||||
};
|
||||
}
|
||||
|
|
@ -10,7 +10,10 @@ The SeaHaven admin SPA has no centralized browser error reporting or distributed
|
|||
transaction tracing. Operational failures therefore have to be reconstructed
|
||||
from user reports and isolated backend logs. We need browser navigation and API
|
||||
request transactions to join the backend trace while preserving the existing UI,
|
||||
routing, authentication, and server-state behavior.
|
||||
routing, authentication, and server-state behavior. Every emitted transaction
|
||||
must also identify its exact build, trace, transaction, operation, function, and
|
||||
opaque actor without sending credentials, request bodies, query values, or
|
||||
personal data.
|
||||
|
||||
## Decision
|
||||
|
||||
|
|
@ -23,6 +26,14 @@ the configured `VITE_API_URL` base. Pass `Sentry.reactErrorHandler()` as the
|
|||
`ReactDOM.createRoot`, so React 19's root error hooks capture caught, uncaught,
|
||||
and recoverable component errors as Sentry events.
|
||||
|
||||
All shared API helpers, direct Ky calls, vendor-link `fetch` calls, and vendor
|
||||
document `XMLHttpRequest` calls run inside explicit `http.client` transactions.
|
||||
They use normalized route templates and attach `code.function`, `operation.type`,
|
||||
HTTP method, `trace_id`, and `transaction_id`. Browser navigation transactions
|
||||
receive the same searchable trace identifiers in `beforeSendTransaction`.
|
||||
Authenticated sessions set only the application's opaque numeric/string user ID;
|
||||
logout and session expiry clear it.
|
||||
|
||||
The DSN comes from `VITE_SENTRY_DSN`. It is intentionally treated as public
|
||||
browser configuration because every `VITE_` value is embedded in the built
|
||||
JavaScript. `.env.production` commits the public DSN of the real
|
||||
|
|
@ -37,11 +48,22 @@ non-blank, falling back to the Vite build mode otherwise. The committed
|
|||
overrides the label to `staging`, and a future production pipeline can set
|
||||
`production` without code changes.
|
||||
|
||||
Source-map upload is outside this change. The Sentry organization and the
|
||||
`shoc-frontend` project now exist and receive telemetry through the committed
|
||||
public DSN, but upload still requires a scoped upload token that is not
|
||||
currently available, plus an explicit deployment design for handling that
|
||||
credential without exposing it in the browser bundle.
|
||||
Every deployment embeds `shoc-frontend@<40-character commit SHA>` as the Sentry
|
||||
release and exposes the same SHA as the `app.commit` tag. CI fails if it cannot
|
||||
resolve that identity. Vite creates hidden source maps, and the dev/staging
|
||||
deployment workflows upload them to the private `shoc-frontend` Sentry project
|
||||
using the repository's `SENTRY_AUTH_TOKEN` secret. The public S3 sync explicitly
|
||||
excludes `*.map`; the upload credential is never available to the browser build.
|
||||
|
||||
Before-send processors reduce request data to normalized URL plus HTTP method,
|
||||
reduce users to opaque ID only, attach native trace/transaction identifiers as
|
||||
searchable tags, and reduce breadcrumbs to safe network method/status/normalized
|
||||
URL metadata. Root and child span attributes use the same allowlist, retaining
|
||||
function, operation, actor type, normalized route, method/status, service host,
|
||||
timing, and native IDs while removing raw URLs and query values. Bodies, headers,
|
||||
cookies, query strings/values, free-form breadcrumb messages, usernames, email
|
||||
addresses, IP addresses, tokens, and filenames are not intentionally sent.
|
||||
`sendDefaultPii` remains disabled.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
|
|
@ -51,8 +73,8 @@ credential without exposing it in the browser bundle.
|
|||
- **Propagate tracing headers to every request** — rejected because third-party
|
||||
requests must not receive SeaHaven trace metadata and broad propagation can
|
||||
introduce cross-origin request failures.
|
||||
- **Add the Sentry Vite plugin now** — rejected until the organization, project,
|
||||
upload credential, and deployment ownership are defined.
|
||||
- **Publish source maps with the SPA** — rejected because source code belongs in
|
||||
Sentry's private artifact store, not the public application bucket.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
|
@ -66,6 +88,7 @@ credential without exposing it in the browser bundle.
|
|||
real traffic is measured.
|
||||
- Cross-origin API tracing depends on the backend accepting `sentry-trace` and
|
||||
`baggage` request headers; the current API CORS policy allows request headers.
|
||||
- Production stack traces remain minified until source-map upload is designed.
|
||||
- Deployed stack traces resolve against the exact commit's private source maps
|
||||
once the scoped upload secret is present and the deployment workflow runs.
|
||||
- Rollback is isolated: remove `@sentry/react`, the initialization module and
|
||||
import, restore the unwrapped router factory, and remove the Sentry env entries.
|
||||
|
|
|
|||
201
package-lock.json
generated
201
package-lock.json
generated
|
|
@ -46,6 +46,7 @@
|
|||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@sentry/cli": "3.7.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
|
|
@ -2005,6 +2006,189 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.7.0.tgz",
|
||||
"integrity": "sha512-UV6/hUaEL7X1ATx5RHPfDEObh+nTRwOeUkNwF6R+HDo6iAki063GiLOmkfroemkVD0L4370SSvGtc5+Y7MZkhQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"dependencies": {
|
||||
"progress": "^2.0.3",
|
||||
"proxy-from-env": "^1.1.0",
|
||||
"undici": "^6.22.0",
|
||||
"which": "^2.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"sentry-cli": "bin/sentry-cli"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@sentry/cli-darwin": "3.7.0",
|
||||
"@sentry/cli-linux-arm": "3.7.0",
|
||||
"@sentry/cli-linux-arm64": "3.7.0",
|
||||
"@sentry/cli-linux-i686": "3.7.0",
|
||||
"@sentry/cli-linux-x64": "3.7.0",
|
||||
"@sentry/cli-win32-arm64": "3.7.0",
|
||||
"@sentry/cli-win32-i686": "3.7.0",
|
||||
"@sentry/cli-win32-x64": "3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-darwin": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.7.0.tgz",
|
||||
"integrity": "sha512-Kb6Oem+6lfxkWSH8iz8DHwYmbMtYxFoTk3VHfeTkpz60TRj+0OPga/0ZEsCCgG/Df17E7z2kbkoe5Ld+dEj3Tw==",
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-arm": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.7.0.tgz",
|
||||
"integrity": "sha512-PKEFwwRCOG92u2dXusLbRK4cZ7XXlaUY8TkhaZy/PJ/FNsST7Sh+MbdWQCgPB0FsvZrgImcKIEyCJZtc0GunJg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-arm64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.7.0.tgz",
|
||||
"integrity": "sha512-m6VlsDJBo9/9ufASv4m4heKS0zfgUJdcSvKgIQerZaLwKOfhY8OP7zhlJPkFmnFjOjyvNyaqzoR00EnlprdwFw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-i686": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.7.0.tgz",
|
||||
"integrity": "sha512-6NeOPqsPuyrOgVGPFunPxgqHCes+a8r6pbCgq9+qSD3oa5fk9H0KOKB0sIC0YyH43OGzdzmwn0XNbdF6jwJO6A==",
|
||||
"cpu": [
|
||||
"x86",
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-x64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.7.0.tgz",
|
||||
"integrity": "sha512-74R9fuAldhC38Aoco8XQWTBvs2T2NatrDvI/ISHihej2oSnbQmL7krJF3yWn33MchQWrsFKgg7mboqemJnreZA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-arm64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.7.0.tgz",
|
||||
"integrity": "sha512-xhmTUW3N3m8iZ4mkV8SrNOr5UN8+6zpvlvJxCK6Z15Tp8S14N1T+hfKeyL2xpLxBcd7Q7oW1dYXUl5b7BHNqjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-i686": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.7.0.tgz",
|
||||
"integrity": "sha512-/CvFzrCWUB0URWKYQeoNs8UbPJcBz6KD/AX27reyQxPd8S3AnTwcaYfCf8SRHEH9Iu+1IKzi5tUUNUB18b7ijg==",
|
||||
"cpu": [
|
||||
"x86",
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-x64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.7.0.tgz",
|
||||
"integrity": "sha512-PNW1O6sZw3JXhhJFgOiW7isFHX/qbniIDxswM7kb0wF2aVUpLXLltbezchWwTAxIRXo9gcu/oMTK0rntdqL5Eg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli/node_modules/undici": {
|
||||
"version": "6.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/conventions": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz",
|
||||
|
|
@ -5843,6 +6027,16 @@
|
|||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
|
@ -5860,6 +6054,13 @@
|
|||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@
|
|||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@sentry/cli": "3.7.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ REGION="${AWS_REGION:-us-east-1}"
|
|||
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
|
||||
|
||||
echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..."
|
||||
export VITE_APP_COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
|
|
@ -41,6 +42,7 @@ echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
|
|||
aws s3 sync dist/ "s3://${BUCKET}/" \
|
||||
--delete \
|
||||
--exclude "index.html" \
|
||||
--exclude "*.map" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
|
||||
echo "Uploading index.html (never cached)..."
|
||||
|
|
|
|||
24
scripts/upload-sourcemaps.sh
Executable file
24
scripts/upload-sourcemaps.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${SENTRY_AUTH_TOKEN:?SENTRY_AUTH_TOKEN is required for source-map upload}"
|
||||
|
||||
SENTRY_ORG="${SENTRY_ORG:-seahaven}"
|
||||
SENTRY_PROJECT="${SENTRY_PROJECT:-shoc-frontend}"
|
||||
COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
|
||||
|
||||
if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')"
|
||||
RELEASE="shoc-frontend@${COMMIT_SHA}"
|
||||
|
||||
npm exec --no -- sentry-cli sourcemaps upload \
|
||||
--org "${SENTRY_ORG}" \
|
||||
--project "${SENTRY_PROJECT}" \
|
||||
--release "${RELEASE}" \
|
||||
--url-prefix "~/" \
|
||||
--validate \
|
||||
dist
|
||||
125
src/api/api.ts
125
src/api/api.ts
|
|
@ -6,6 +6,7 @@ import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-erro
|
|||
import queryClient from "@/lib/query/query-client";
|
||||
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
|
||||
function clearSessionAndRedirectToLogin(): void {
|
||||
clearAuth();
|
||||
|
|
@ -58,28 +59,92 @@ export const api: KyInstance = ky.create({
|
|||
},
|
||||
});
|
||||
|
||||
export type ApiRawMethod = "get" | "post" | "put" | "patch" | "delete";
|
||||
|
||||
export async function apiRequestRaw(
|
||||
method: ApiRawMethod,
|
||||
url: string,
|
||||
options?: Options,
|
||||
entryPoint?: string,
|
||||
): Promise<Response> {
|
||||
return traceHttpOperation(
|
||||
{
|
||||
method: method.toUpperCase(),
|
||||
url,
|
||||
entryPoint: entryPoint ?? `api.${method}`,
|
||||
},
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api[method](url, options);
|
||||
reportStatus(response.status);
|
||||
return response;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiGet<T>(url: string, options?: Options): Promise<T> {
|
||||
return api.get(url, options).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "GET", url, entryPoint: "api.apiGet" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.get(url, options);
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPost<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.post(url, { ...options, json: body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "POST", url, entryPoint: "api.apiPost" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.post(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPostForm<T>(url: string, body: FormData, options?: Options): Promise<T> {
|
||||
return api.post(url, { ...options, body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "POST", url, entryPoint: "api.apiPostForm" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.post(url, { ...options, body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPut<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.put(url, { ...options, json: body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "PUT", url, entryPoint: "api.apiPut" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.put(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.patch(url, { ...options, json: body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "PATCH", url, entryPoint: "api.apiPatch" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.patch(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(url: string, options?: Options): Promise<T> {
|
||||
return api.delete(url, options).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "DELETE", url, entryPoint: "api.apiDelete" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.delete(url, options);
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPostNoContent(
|
||||
|
|
@ -87,31 +152,43 @@ export async function apiPostNoContent(
|
|||
body?: unknown,
|
||||
options?: Options,
|
||||
): Promise<void> {
|
||||
const response = await api.post(url, { ...options, json: body });
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
return traceHttpOperation(
|
||||
{ method: "POST", url, entryPoint: "api.apiPostNoContent" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.post(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
JSON.parse(text);
|
||||
JSON.parse(text);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiDeleteNoContent(url: string, options?: Options): Promise<void> {
|
||||
const response = await api.delete(url, options);
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
return traceHttpOperation(
|
||||
{ method: "DELETE", url, entryPoint: "api.apiDeleteNoContent" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.delete(url, options);
|
||||
reportStatus(response.status);
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
JSON.parse(text);
|
||||
JSON.parse(text);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export { mapHttpStatusToMessage };
|
||||
|
|
|
|||
|
|
@ -46,11 +46,13 @@ export function useLogout(): UseMutationResult<void, Error, void> {
|
|||
mutationFn: authApi.logout,
|
||||
onSuccess: () => {
|
||||
stopQueryBroadcast(queryClient);
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
toast.info("You have been logged out");
|
||||
navigate("/login");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
stopQueryBroadcast(queryClient);
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
toast.error(error.message || "Logout failed");
|
||||
navigate("/login");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { HTTPError } from "ky";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { api, apiGet, apiPost } from "@/api/api";
|
||||
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import { mapUpliftQueueItems, mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper";
|
||||
import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
|
||||
|
|
@ -91,7 +91,12 @@ export const upliftsApi = {
|
|||
): Promise<void> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await api.get(evidenceUrl(id));
|
||||
response = await apiRequestRaw(
|
||||
"get",
|
||||
evidenceUrl(id),
|
||||
undefined,
|
||||
"upliftsApi.downloadEvidence",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError) {
|
||||
throw evidenceHttpError(error.response.status);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { api, apiGet, apiPut } from "@/api/api";
|
||||
import { apiGet, apiPut, apiRequestRaw } from "@/api/api";
|
||||
import type {
|
||||
SiteVendorPreference,
|
||||
VendorAssignmentResult,
|
||||
|
|
@ -27,7 +27,13 @@ type InsightParams = {
|
|||
};
|
||||
|
||||
async function saveDownload(path: string, fileName: string, params: InsightParams): Promise<void> {
|
||||
const blob = await api.get(`${base}/${path}`, { searchParams: searchParams(params) }).blob();
|
||||
const response = await apiRequestRaw(
|
||||
"get",
|
||||
`${base}/${path}`,
|
||||
{ searchParams: searchParams(params) },
|
||||
"vendorOperationsApi.saveDownload",
|
||||
);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { env } from "@/lib/env";
|
||||
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
import type {
|
||||
VendorPortalComment,
|
||||
VendorPortalDispatchDetail,
|
||||
|
|
@ -38,30 +39,45 @@ function uploadVendorDocument({
|
|||
purpose,
|
||||
onProgress,
|
||||
}: UploadVendorDocumentOptions): Promise<VendorPortalDocument> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
|
||||
request.setRequestHeader("X-Vendor-Token", token);
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
});
|
||||
request.addEventListener("load", () => {
|
||||
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)));
|
||||
return;
|
||||
}
|
||||
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
|
||||
});
|
||||
request.addEventListener("error", () => reject(new Error("Upload failed")));
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
if (replacesDocumentId !== undefined) {
|
||||
body.append("replacesDocumentId", String(replacesDocumentId));
|
||||
}
|
||||
body.append("purpose", purpose ?? "Completion");
|
||||
request.send(body);
|
||||
});
|
||||
return traceHttpOperation(
|
||||
{
|
||||
method: "POST",
|
||||
url: resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`),
|
||||
entryPoint: "vendorPortalApi.uploadDocument",
|
||||
actorType: "vendor_link",
|
||||
},
|
||||
({ reportStatus }) =>
|
||||
new Promise<VendorPortalDocument>((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
|
||||
request.setRequestHeader("X-Vendor-Token", token);
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
});
|
||||
request.addEventListener("load", () => {
|
||||
reportStatus(request.status);
|
||||
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(
|
||||
new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
|
||||
});
|
||||
request.addEventListener("error", () => {
|
||||
reportStatus(request.status);
|
||||
reject(new Error("Upload failed"));
|
||||
});
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
if (replacesDocumentId !== undefined) {
|
||||
body.append("replacesDocumentId", String(replacesDocumentId));
|
||||
}
|
||||
body.append("purpose", purpose ?? "Completion");
|
||||
request.send(body);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function downloadVendorDocument(
|
||||
|
|
@ -69,19 +85,32 @@ async function downloadVendorDocument(
|
|||
dispatchId: string | number,
|
||||
document: VendorPortalDocument,
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
||||
{ headers: { "X-Vendor-Token": token } },
|
||||
await traceHttpOperation(
|
||||
{
|
||||
method: "GET",
|
||||
url: resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
||||
entryPoint: "vendorPortalApi.downloadDocument",
|
||||
actorType: "vendor_link",
|
||||
},
|
||||
async ({ reportStatus }) => {
|
||||
const response = await fetch(
|
||||
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
||||
{ headers: { "X-Vendor-Token": token } },
|
||||
);
|
||||
reportStatus(response.status);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
response.status === 423 ? "File is still being scanned" : "Download failed",
|
||||
);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = document.originalFileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(response.status === 423 ? "File is still being scanned" : "Download failed");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = document.originalFileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function vendorPortalCall<T>(
|
||||
|
|
@ -89,30 +118,41 @@ async function vendorPortalCall<T>(
|
|||
path: string,
|
||||
options?: { method?: string; body?: unknown },
|
||||
): Promise<T> {
|
||||
const response = await fetch(resolveVendorPortalUrl(path), {
|
||||
method: options?.method ?? "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Vendor-Token": token,
|
||||
return traceHttpOperation<T>(
|
||||
{
|
||||
method: options?.method ?? "GET",
|
||||
url: resolveVendorPortalUrl(path),
|
||||
entryPoint: "vendorPortalApi.call",
|
||||
actorType: "vendor_link",
|
||||
},
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
async ({ reportStatus }) => {
|
||||
const response = await fetch(resolveVendorPortalUrl(path), {
|
||||
method: options?.method ?? "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Vendor-Token": token,
|
||||
},
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
reportStatus(response.status);
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error("Invalid or expired token");
|
||||
}
|
||||
if (response.status === 401) {
|
||||
throw new Error("Invalid or expired token");
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(typeof payload.message === "string" ? payload.message : undefined) ??
|
||||
(typeof payload.Message === "string" ? payload.Message : undefined) ??
|
||||
`HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(typeof payload.message === "string" ? payload.message : undefined) ??
|
||||
(typeof payload.Message === "string" ? payload.Message : undefined) ??
|
||||
`HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return (payload.data ?? payload.Data ?? payload) as T;
|
||||
return (payload.data ?? payload.Data ?? payload) as T;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function isNoApprovalRequired(payload: unknown): boolean {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { api, apiGet, apiPost } from "@/api/api";
|
||||
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
BoardPatchConflictError,
|
||||
|
|
@ -81,10 +81,12 @@ export const workOrderBoardDocumentsApi = {
|
|||
commentId: string | number,
|
||||
text: string,
|
||||
): Promise<WorkOrderComment> => {
|
||||
const response = await api.patch(API_PATHS.workOrder.commentItem(workOrderId, commentId), {
|
||||
json: { text },
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"patch",
|
||||
API_PATHS.workOrder.commentItem(workOrderId, commentId),
|
||||
{ json: { text }, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.updateComment",
|
||||
);
|
||||
const data = await readResponseJson(response);
|
||||
|
||||
if (response.ok) {
|
||||
|
|
@ -119,16 +121,21 @@ export const workOrderBoardDocumentsApi = {
|
|||
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
|
||||
): Promise<WorkOrderBoardMediaItem> => {
|
||||
const formData = buildMediaFormData(file, category);
|
||||
const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
signal: options?.signal,
|
||||
onUploadProgress: options?.onProgress
|
||||
? (progress) => {
|
||||
options.onProgress?.(Math.round(progress.percent * 100));
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"post",
|
||||
API_PATHS.workOrder.media(workOrderId),
|
||||
{
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
signal: options?.signal,
|
||||
onUploadProgress: options?.onProgress
|
||||
? (progress) => {
|
||||
options.onProgress?.(Math.round(progress.percent * 100));
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
"workOrderBoardDocumentsApi.uploadMedia",
|
||||
);
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||
|
|
@ -152,10 +159,12 @@ export const workOrderBoardDocumentsApi = {
|
|||
const formData = new FormData();
|
||||
formData.append("category", category);
|
||||
formData.append("workOrderVersion", workOrderVersion);
|
||||
const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"patch",
|
||||
API_PATHS.workOrder.mediaItem(workOrderId, mediaId),
|
||||
{ body: formData, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.updateMediaCategory",
|
||||
);
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||
|
|
@ -183,10 +192,12 @@ export const workOrderBoardDocumentsApi = {
|
|||
);
|
||||
}
|
||||
|
||||
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
|
||||
searchParams: { workOrderVersion },
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"delete",
|
||||
API_PATHS.workOrder.mediaItem(workOrderId, numericId),
|
||||
{ searchParams: { workOrderVersion }, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.deleteMedia",
|
||||
);
|
||||
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
|
|
@ -216,10 +227,12 @@ export const workOrderBoardDocumentsApi = {
|
|||
}
|
||||
|
||||
const formData = buildCompletionDocFormData(params);
|
||||
const response = await api.post(API_PATHS.workOrder.completionDoc(workOrderId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"post",
|
||||
API_PATHS.workOrder.completionDoc(workOrderId),
|
||||
{ body: formData, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.uploadCompletionDoc",
|
||||
);
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
return mapCompletion(handleApiResponse(data) ?? data);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { api, apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api";
|
||||
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut, apiRequestRaw } from "@/api/api";
|
||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
BoardPatchConflictError,
|
||||
|
|
@ -314,10 +314,12 @@ export const workOrdersApi = {
|
|||
id: string | number,
|
||||
payload: WorkOrderBoardPatchPayload,
|
||||
): Promise<WorkOrderTableRow> => {
|
||||
const response = await api.patch(API_PATHS.workOrder.boardPatch(id), {
|
||||
json: payload,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"patch",
|
||||
API_PATHS.workOrder.boardPatch(id),
|
||||
{ json: payload, throwHttpErrors: false },
|
||||
"workOrdersApi.patchBoardField",
|
||||
);
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
|
|
|
|||
110
src/observability/http-transaction.ts
Normal file
110
src/observability/http-transaction.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { setHttpStatus, spanToJSON, startSpan, type Span } from "@sentry/react";
|
||||
|
||||
const INTEGER_SEGMENT = /^\d+$/;
|
||||
const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const HEX_SEGMENT = /^[0-9a-f]+$/i;
|
||||
const TOKEN_SEGMENT = /^[0-9a-z._~-]+$/i;
|
||||
const OPAQUE_HEX_MIN_LENGTH = 8;
|
||||
const OPAQUE_TOKEN_MIN_LENGTH = 16;
|
||||
|
||||
function isIdentifierSegment(segment: string): boolean {
|
||||
if (segment.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (INTEGER_SEGMENT.test(segment)) {
|
||||
return true;
|
||||
}
|
||||
if (UUID_SEGMENT.test(segment)) {
|
||||
return true;
|
||||
}
|
||||
if (segment.length >= OPAQUE_HEX_MIN_LENGTH && HEX_SEGMENT.test(segment)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
segment.length >= OPAQUE_TOKEN_MIN_LENGTH &&
|
||||
TOKEN_SEGMENT.test(segment) &&
|
||||
/\d/.test(segment) &&
|
||||
/[a-z]/i.test(segment)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeHttpRoute(input: string): string {
|
||||
let path: string;
|
||||
try {
|
||||
path = new URL(input).pathname;
|
||||
} catch {
|
||||
path = input.split("#")[0] ?? input;
|
||||
path = path.split("?")[0] ?? path;
|
||||
}
|
||||
|
||||
return path
|
||||
.split("/")
|
||||
.map((segment) => (isIdentifierSegment(segment) ? ":id" : segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
export type HttpTraceOptions = {
|
||||
method: string;
|
||||
url: string;
|
||||
entryPoint: string;
|
||||
actorType?: string;
|
||||
};
|
||||
|
||||
export type HttpTraceContext = {
|
||||
reportStatus: (status: number) => void;
|
||||
};
|
||||
|
||||
export async function traceHttpOperation<T>(
|
||||
options: HttpTraceOptions,
|
||||
callback: (context: HttpTraceContext) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const route = normalizeHttpRoute(options.url);
|
||||
|
||||
return startSpan(
|
||||
{
|
||||
name: `${options.method.toUpperCase()} ${route}`,
|
||||
op: "http.client",
|
||||
kind: 2,
|
||||
forceTransaction: true,
|
||||
attributes: {
|
||||
"http.request.method": options.method.toUpperCase(),
|
||||
"http.route": route,
|
||||
"code.function": options.entryPoint,
|
||||
"operation.type": "http.client",
|
||||
...(options.actorType ? { "actor.type": options.actorType } : {}),
|
||||
},
|
||||
},
|
||||
async (span: Span) => {
|
||||
const spanJson = spanToJSON(span);
|
||||
if (spanJson.trace_id) {
|
||||
span.setAttribute("trace_id", spanJson.trace_id);
|
||||
}
|
||||
if (spanJson.span_id) {
|
||||
span.setAttribute("span_id", spanJson.span_id);
|
||||
span.setAttribute("transaction_id", spanJson.span_id);
|
||||
}
|
||||
|
||||
let statusReported = false;
|
||||
const reportStatus = (status: number): void => {
|
||||
if (statusReported) {
|
||||
return;
|
||||
}
|
||||
statusReported = true;
|
||||
setHttpStatus(span, status);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await callback({ reportStatus });
|
||||
if (!statusReported && result instanceof Response) {
|
||||
reportStatus(result.status);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!statusReported) {
|
||||
reportStatus(500);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
151
src/observability/sentry-scrubber.ts
Normal file
151
src/observability/sentry-scrubber.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import type { Breadcrumb, ErrorEvent, SpanJSON, TransactionEvent } from "@sentry/core";
|
||||
|
||||
import { normalizeHttpRoute } from "@/observability/http-transaction";
|
||||
|
||||
function sanitizeUrl(value: string): string {
|
||||
try {
|
||||
const parsed = new URL(value, window.location.origin);
|
||||
return `${parsed.origin}${normalizeHttpRoute(parsed.pathname)}`;
|
||||
} catch {
|
||||
return normalizeHttpRoute(value);
|
||||
}
|
||||
}
|
||||
|
||||
function keepOpaqueUserId(event: ErrorEvent | TransactionEvent): void {
|
||||
event.user = event.user?.id == null ? undefined : { id: String(event.user.id) };
|
||||
}
|
||||
|
||||
function scrubRequest(event: ErrorEvent | TransactionEvent): void {
|
||||
if (!event.request) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.request = {
|
||||
method: event.request.method,
|
||||
url: typeof event.request.url === "string" ? sanitizeUrl(event.request.url) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const SAFE_SPAN_DATA_KEYS = new Set([
|
||||
"actor.type",
|
||||
"code.function",
|
||||
"db.operation.name",
|
||||
"db.system",
|
||||
"http.method",
|
||||
"http.request.method",
|
||||
"http.response.status_code",
|
||||
"http.route",
|
||||
"http.status_code",
|
||||
"operation.type",
|
||||
"sentry.op",
|
||||
"sentry.origin",
|
||||
"server.address",
|
||||
]);
|
||||
|
||||
function safeSpanData(data: SpanJSON["data"] | undefined): SpanJSON["data"] {
|
||||
return Object.fromEntries(
|
||||
Object.entries(data ?? {}).filter(
|
||||
([key, value]) =>
|
||||
SAFE_SPAN_DATA_KEYS.has(key) && ["string", "number", "boolean"].includes(typeof value),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function valueAsString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function sanitizeHttpDescription(description: string | undefined): string | undefined {
|
||||
if (!description) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/i.exec(description);
|
||||
return match ? `${match[1]!.toUpperCase()} ${sanitizeUrl(match[2]!)}` : undefined;
|
||||
}
|
||||
|
||||
function scrubTransactionSpans(event: TransactionEvent): void {
|
||||
event.spans = event.spans?.map((span) => ({
|
||||
...span,
|
||||
data: safeSpanData(span.data),
|
||||
description: span.op?.includes("http") ? sanitizeHttpDescription(span.description) : undefined,
|
||||
}));
|
||||
|
||||
const trace = event.contexts?.trace;
|
||||
if (trace?.data) {
|
||||
trace.data = safeSpanData(trace.data);
|
||||
}
|
||||
}
|
||||
|
||||
function setStringTag(
|
||||
tags: NonNullable<TransactionEvent["tags"]>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
const stringValue = valueAsString(value);
|
||||
if (stringValue) {
|
||||
tags[key] = stringValue;
|
||||
}
|
||||
}
|
||||
|
||||
function transactionFunction(event: ErrorEvent | TransactionEvent): string | undefined {
|
||||
const traceFunction = valueAsString(event.contexts?.trace?.data?.["code.function"]);
|
||||
return traceFunction ?? (event.type === "transaction" ? event.transaction : undefined);
|
||||
}
|
||||
|
||||
function actorType(event: ErrorEvent | TransactionEvent): string {
|
||||
return (
|
||||
valueAsString(event.tags?.["actor.type"]) ??
|
||||
valueAsString(event.contexts?.trace?.data?.["actor.type"]) ??
|
||||
(event.user?.id == null ? "anonymous" : "authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
function attachTraceIdentifiers(event: ErrorEvent | TransactionEvent): void {
|
||||
const trace = event.contexts?.trace;
|
||||
const tags = { ...event.tags };
|
||||
|
||||
setStringTag(tags, "trace_id", trace?.trace_id);
|
||||
setStringTag(tags, "transaction_id", trace?.span_id);
|
||||
setStringTag(tags, "operation.type", valueAsString(trace?.data?.["operation.type"]) ?? trace?.op);
|
||||
setStringTag(tags, "code.function", transactionFunction(event));
|
||||
tags["actor.type"] = actorType(event);
|
||||
event.tags = tags;
|
||||
}
|
||||
|
||||
export function scrubErrorEvent(event: ErrorEvent): ErrorEvent {
|
||||
keepOpaqueUserId(event);
|
||||
scrubRequest(event);
|
||||
attachTraceIdentifiers(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
export function scrubTransactionEvent(event: TransactionEvent): TransactionEvent {
|
||||
keepOpaqueUserId(event);
|
||||
scrubRequest(event);
|
||||
attachTraceIdentifiers(event);
|
||||
scrubTransactionSpans(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
export function scrubBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb {
|
||||
const safe: Breadcrumb = {
|
||||
timestamp: breadcrumb.timestamp,
|
||||
type: breadcrumb.type,
|
||||
category: breadcrumb.category,
|
||||
level: breadcrumb.level,
|
||||
};
|
||||
|
||||
if (breadcrumb.category === "fetch" || breadcrumb.category === "xhr") {
|
||||
const method = breadcrumb.data?.method;
|
||||
const statusCode = breadcrumb.data?.status_code;
|
||||
const url = breadcrumb.data?.url;
|
||||
safe.data = {
|
||||
...(typeof method === "string" ? { method } : {}),
|
||||
...(typeof statusCode === "number" ? { status_code: statusCode } : {}),
|
||||
...(typeof url === "string" ? { url: sanitizeUrl(url) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return safe;
|
||||
}
|
||||
|
|
@ -8,6 +8,11 @@ import {
|
|||
} from "react-router";
|
||||
|
||||
import { env } from "@/lib/env";
|
||||
import {
|
||||
scrubBreadcrumb,
|
||||
scrubErrorEvent,
|
||||
scrubTransactionEvent,
|
||||
} from "@/observability/sentry-scrubber";
|
||||
|
||||
const sentryDsn = import.meta.env.VITE_SENTRY_DSN?.trim() || undefined;
|
||||
const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT?.trim() || import.meta.env.MODE;
|
||||
|
|
@ -16,6 +21,13 @@ const apiBaseUrl = new URL(env.apiUrl, window.location.origin).toString();
|
|||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
environment: sentryEnvironment,
|
||||
release: __SENTRY_RELEASE__,
|
||||
initialScope: {
|
||||
tags: {
|
||||
service: "shoc-frontend",
|
||||
"app.commit": __APP_COMMIT_SHA__,
|
||||
},
|
||||
},
|
||||
integrations: [
|
||||
Sentry.reactRouterBrowserTracingIntegration({
|
||||
useEffect,
|
||||
|
|
@ -26,6 +38,9 @@ Sentry.init({
|
|||
}),
|
||||
],
|
||||
sendDefaultPii: false,
|
||||
beforeSend: scrubErrorEvent,
|
||||
beforeSendTransaction: scrubTransactionEvent,
|
||||
beforeBreadcrumb: scrubBreadcrumb,
|
||||
tracesSampleRate: 1.0,
|
||||
tracePropagationTargets: [window.location.origin, apiBaseUrl],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useMemo, type ReactNode } from "react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
|
||||
import { useLogin, useLogout, useSession } from "@/domain/auth/use-cases/use-login";
|
||||
|
|
@ -14,6 +15,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
const userId = sessionQuery.data?.id;
|
||||
|
||||
useEffect(() => {
|
||||
Sentry.setUser(userId == null ? null : { id: String(userId) });
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId == null) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@ vi.mock("@/api/api", () => ({
|
|||
apiPatch: (...args: unknown[]) => apiPatch(...args),
|
||||
apiDelete: vi.fn(),
|
||||
apiPostNoContent: vi.fn(),
|
||||
api: {
|
||||
patch: (...args: unknown[]) => apiPatchFn(...args),
|
||||
post: (...args: unknown[]) => apiPostFn(...args),
|
||||
delete: (...args: unknown[]) => apiDeleteFn(...args),
|
||||
apiRequestRaw: (method: string, url: unknown, options?: unknown) => {
|
||||
if (method === "patch") return apiPatchFn(url, options);
|
||||
if (method === "post") return apiPostFn(url, options);
|
||||
if (method === "delete") return apiDeleteFn(url, options);
|
||||
throw new Error(`Unexpected apiRequestRaw method: ${method}`);
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
137
src/test/observability/http-transaction.test.ts
Normal file
137
src/test/observability/http-transaction.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const capturedSpan = vi.hoisted(() => ({ setAttribute: vi.fn() }));
|
||||
|
||||
vi.mock("@sentry/react", () => ({
|
||||
startSpan: vi.fn((options: unknown, callback: (span: unknown) => unknown) =>
|
||||
callback(capturedSpan),
|
||||
),
|
||||
spanToJSON: vi.fn(() => ({ trace_id: "trace-1", span_id: "span-1" })),
|
||||
setHttpStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { normalizeHttpRoute, traceHttpOperation } from "@/observability/http-transaction";
|
||||
|
||||
const startSpanMock = vi.mocked(Sentry.startSpan);
|
||||
const setHttpStatusMock = vi.mocked(Sentry.setHttpStatus);
|
||||
|
||||
function lastSpanOptions(): Record<string, unknown> {
|
||||
const calls = startSpanMock.mock.calls;
|
||||
const [options] = calls[calls.length - 1] ?? [];
|
||||
return (options ?? {}) as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("normalizeHttpRoute", () => {
|
||||
it("replaces integer path segments with :id", () => {
|
||||
expect(normalizeHttpRoute("/api/workorders/9/media")).toBe("/api/workorders/:id/media");
|
||||
});
|
||||
|
||||
it("replaces UUID path segments with :id", () => {
|
||||
expect(normalizeHttpRoute("/uplifts/3f2504e0-4f89-11d3-9a0c-0305e82c3301/evidence")).toBe(
|
||||
"/uplifts/:id/evidence",
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces long hex and opaque token segments with :id", () => {
|
||||
expect(normalizeHttpRoute("/files/deadbeefcafe1234")).toBe("/files/:id");
|
||||
expect(normalizeHttpRoute("/share/Ab12Cd34Ef56Gh78")).toBe("/share/:id");
|
||||
});
|
||||
|
||||
it("strips query strings and fragments without using their values", () => {
|
||||
expect(normalizeHttpRoute("/vendor-portal/dispatches?status=pending#x")).toBe(
|
||||
"/vendor-portal/dispatches",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps descriptive segments untouched", () => {
|
||||
expect(normalizeHttpRoute("/vendor-operations/insights.csv")).toBe(
|
||||
"/vendor-operations/insights.csv",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses only the pathname of absolute URLs", () => {
|
||||
expect(normalizeHttpRoute("https://api.example.com/workorders/9/media?q=1")).toBe(
|
||||
"/workorders/:id/media",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("traceHttpOperation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("starts a forced http.client transaction with safe attributes", async () => {
|
||||
await traceHttpOperation(
|
||||
{ method: "get", url: "/workorders/9/media?include=x", entryPoint: "api.apiGet" },
|
||||
async () => "ok",
|
||||
);
|
||||
|
||||
const options = lastSpanOptions();
|
||||
expect(startSpanMock).toHaveBeenCalledTimes(1);
|
||||
expect(options.name).toBe("GET /workorders/:id/media");
|
||||
expect(options.op).toBe("http.client");
|
||||
expect(options.kind).toBe(2);
|
||||
expect(options.forceTransaction).toBe(true);
|
||||
expect(options.attributes).toEqual({
|
||||
"http.request.method": "GET",
|
||||
"http.route": "/workorders/:id/media",
|
||||
"code.function": "api.apiGet",
|
||||
"operation.type": "http.client",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds actor.type only when provided and native span ids as attributes", async () => {
|
||||
await traceHttpOperation(
|
||||
{
|
||||
method: "POST",
|
||||
url: "/vendor-portal/dispatches",
|
||||
entryPoint: "vendorPortalApi.call",
|
||||
actorType: "vendor_link",
|
||||
},
|
||||
async () => null,
|
||||
);
|
||||
|
||||
expect(lastSpanOptions().attributes).toMatchObject({
|
||||
"actor.type": "vendor_link",
|
||||
});
|
||||
expect(capturedSpan.setAttribute).toHaveBeenCalledWith("trace_id", "trace-1");
|
||||
expect(capturedSpan.setAttribute).toHaveBeenCalledWith("span_id", "span-1");
|
||||
expect(capturedSpan.setAttribute).toHaveBeenCalledWith("transaction_id", "span-1");
|
||||
});
|
||||
|
||||
it("reports the status of a returned Response", async () => {
|
||||
const response = new Response("{}", { status: 201 });
|
||||
|
||||
await traceHttpOperation({ method: "POST", url: "/x", entryPoint: "fn" }, async () => response);
|
||||
|
||||
expect(setHttpStatusMock).toHaveBeenCalledTimes(1);
|
||||
expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 201);
|
||||
});
|
||||
|
||||
it("exposes a single-shot status reporter for non-Response results", async () => {
|
||||
const result = await traceHttpOperation(
|
||||
{ method: "GET", url: "/x", entryPoint: "fn" },
|
||||
async ({ reportStatus }) => {
|
||||
reportStatus(204);
|
||||
reportStatus(500);
|
||||
return "done";
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("done");
|
||||
expect(setHttpStatusMock).toHaveBeenCalledTimes(1);
|
||||
expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 204);
|
||||
});
|
||||
|
||||
it("reports status 500 when the callback throws", async () => {
|
||||
await expect(
|
||||
traceHttpOperation({ method: "GET", url: "/x", entryPoint: "fn" }, async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 500);
|
||||
});
|
||||
});
|
||||
148
src/test/observability/sentry-scrubber.test.ts
Normal file
148
src/test/observability/sentry-scrubber.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
scrubBreadcrumb,
|
||||
scrubErrorEvent,
|
||||
scrubTransactionEvent,
|
||||
} from "@/observability/sentry-scrubber";
|
||||
|
||||
describe("Sentry telemetry scrubbing", () => {
|
||||
it("keeps only an opaque user id and safe request method/path on errors", () => {
|
||||
const event = scrubErrorEvent({
|
||||
type: undefined,
|
||||
user: {
|
||||
id: "opaque-user-123",
|
||||
email: "secret.user@example.com",
|
||||
username: "Secret User",
|
||||
ip_address: "203.0.113.10",
|
||||
},
|
||||
request: {
|
||||
method: "GET",
|
||||
url: "https://app.example.com/workorders/123?token=secret",
|
||||
headers: { Authorization: "Bearer secret" },
|
||||
cookies: { session: "secret" },
|
||||
data: "secret-body",
|
||||
},
|
||||
contexts: {
|
||||
trace: {
|
||||
trace_id: "0123456789abcdef0123456789abcdef",
|
||||
span_id: "0123456789abcdef",
|
||||
op: "http.client",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(event.user).toEqual({ id: "opaque-user-123" });
|
||||
expect(event.request).toEqual({
|
||||
method: "GET",
|
||||
url: "https://app.example.com/workorders/:id",
|
||||
});
|
||||
expect(event.tags).toMatchObject({
|
||||
trace_id: "0123456789abcdef0123456789abcdef",
|
||||
transaction_id: "0123456789abcdef",
|
||||
"operation.type": "http.client",
|
||||
"actor.type": "authenticated",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the same request/user contract to transactions", () => {
|
||||
const event = scrubTransactionEvent({
|
||||
type: "transaction",
|
||||
transaction: "GET /workorders/:id",
|
||||
user: { id: "opaque-user-123", email: "secret.user@example.com" },
|
||||
request: {
|
||||
method: "GET",
|
||||
url: "/workorders/123?token=secret",
|
||||
query_string: "token=secret",
|
||||
},
|
||||
contexts: {
|
||||
trace: {
|
||||
trace_id: "fedcba9876543210fedcba9876543210",
|
||||
span_id: "fedcba9876543210",
|
||||
op: "navigation",
|
||||
data: {
|
||||
"actor.type": "vendor_link",
|
||||
"operation.type": "http.client",
|
||||
"code.function": "vendorPortalApi.call",
|
||||
"http.query": "token=secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
trace_id: "fedcba9876543210fedcba9876543210",
|
||||
span_id: "1111111111111111",
|
||||
parent_span_id: "fedcba9876543210",
|
||||
op: "http.client",
|
||||
start_timestamp: 1,
|
||||
timestamp: 2,
|
||||
description: "GET https://api.example.com/workorders/123?token=secret",
|
||||
data: {
|
||||
"http.request.method": "GET",
|
||||
"http.route": "/workorders/:id",
|
||||
"http.response.status_code": 200,
|
||||
"code.function": "vendorPortalApi.call",
|
||||
"http.query": "token=secret",
|
||||
"url.full": "https://api.example.com/workorders/123?token=secret",
|
||||
request_body: "secret-body",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(event.user).toEqual({ id: "opaque-user-123" });
|
||||
expect(event.request).toEqual({
|
||||
method: "GET",
|
||||
url: `${window.location.origin}/workorders/:id`,
|
||||
});
|
||||
expect(event.tags).toMatchObject({
|
||||
trace_id: "fedcba9876543210fedcba9876543210",
|
||||
transaction_id: "fedcba9876543210",
|
||||
"operation.type": "http.client",
|
||||
"code.function": "vendorPortalApi.call",
|
||||
"actor.type": "vendor_link",
|
||||
});
|
||||
expect(event.contexts?.trace?.data).toEqual({
|
||||
"actor.type": "vendor_link",
|
||||
"operation.type": "http.client",
|
||||
"code.function": "vendorPortalApi.call",
|
||||
});
|
||||
expect(event.spans).toEqual([
|
||||
expect.objectContaining({
|
||||
trace_id: "fedcba9876543210fedcba9876543210",
|
||||
span_id: "1111111111111111",
|
||||
parent_span_id: "fedcba9876543210",
|
||||
op: "http.client",
|
||||
start_timestamp: 1,
|
||||
timestamp: 2,
|
||||
description: "GET https://api.example.com/workorders/:id",
|
||||
data: {
|
||||
"http.request.method": "GET",
|
||||
"http.route": "/workorders/:id",
|
||||
"http.response.status_code": 200,
|
||||
"code.function": "vendorPortalApi.call",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains only safe network breadcrumb details", () => {
|
||||
const breadcrumb = scrubBreadcrumb({
|
||||
category: "fetch",
|
||||
message: "secret free-form text",
|
||||
data: {
|
||||
method: "POST",
|
||||
status_code: 201,
|
||||
url: "/uplifts/3f2504e0-4f89-11d3-9a0c-0305e82c3301?token=secret",
|
||||
request_body: "secret",
|
||||
},
|
||||
});
|
||||
|
||||
expect(breadcrumb.message).toBeUndefined();
|
||||
expect(breadcrumb.data).toEqual({
|
||||
method: "POST",
|
||||
status_code: 201,
|
||||
url: `${window.location.origin}/uplifts/:id`,
|
||||
});
|
||||
});
|
||||
});
|
||||
3
src/vite-env.d.ts
vendored
3
src/vite-env.d.ts
vendored
|
|
@ -10,3 +10,6 @@ interface ImportMetaEnv {
|
|||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare const __APP_COMMIT_SHA__: string;
|
||||
declare const __SENTRY_RELEASE__: string;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@
|
|||
"vitest.config.ts",
|
||||
"config/api-url-contract.ts",
|
||||
"config/api-url-contract.test.ts",
|
||||
"config/sentry-release.ts",
|
||||
"config/sentry-release.test.ts",
|
||||
"config/pwa-manifest.test.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,33 @@
|
|||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { assertAbsoluteApiUrlHasApiSegment } from "./config/api-url-contract";
|
||||
import { resolveSentryRelease } from "./config/sentry-release";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
|
||||
assertAbsoluteApiUrlHasApiSegment(env.VITE_API_URL);
|
||||
|
||||
let gitCommit: string | undefined;
|
||||
try {
|
||||
gitCommit = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
||||
} catch {
|
||||
gitCommit = undefined;
|
||||
}
|
||||
const sentryRelease = resolveSentryRelease(
|
||||
env.VITE_APP_COMMIT_SHA || process.env.GITHUB_SHA || gitCommit,
|
||||
process.env.CI === "true",
|
||||
);
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
define: {
|
||||
__APP_COMMIT_SHA__: JSON.stringify(sentryRelease.commitSha),
|
||||
__SENTRY_RELEASE__: JSON.stringify(sentryRelease.release),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
|
|
@ -28,6 +45,7 @@ export default defineConfig(({ mode }) => {
|
|||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
sourcemap: "hidden",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue