mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
Merge pull request #167 from Sea-Haven-Industries/feat/sh-298-sentry
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
Deploy / Upload private source maps (push) Blocked by required conditions
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
Deploy / Upload private source maps (push) Blocked by required conditions
feat: add browser Sentry tracing (SH-298)
This commit is contained in:
commit
73346371e5
30 changed files with 1462 additions and 118 deletions
|
|
@ -8,5 +8,13 @@
|
|||
# Production: absolute URLs MUST include the /api path segment
|
||||
VITE_API_URL=https://api.seahavenindustries.com/api
|
||||
|
||||
# Sentry browser DSN. This value is public configuration and is baked into the bundle.
|
||||
# Leave blank to disable telemetry for that build.
|
||||
VITE_SENTRY_DSN=
|
||||
|
||||
# Sentry environment label (development, staging, production). Blank falls back
|
||||
# to the Vite build mode.
|
||||
#VITE_SENTRY_ENVIRONMENT=
|
||||
|
||||
# Development proxy target (used by vite.config.ts)
|
||||
#VITE_API_TARGET=http://localhost:5141
|
||||
|
|
|
|||
|
|
@ -2,3 +2,9 @@
|
|||
# NOTE: baked into the build at `vite build`, so this is the DEV value. Staging
|
||||
# and prod builds must override VITE_API_URL per environment (api.staging..., etc.).
|
||||
VITE_API_URL=https://api.dev.seahaven.com/api
|
||||
|
||||
# Public browser configuration; the DSN is public by design and baked into the
|
||||
# bundle. Blank disables telemetry. These are the DEV deploy values: staging
|
||||
# and future prod jobs override them per environment.
|
||||
VITE_SENTRY_DSN=https://902f431c6e762aa849c9a07b77f0c3ac@o4511989453029376.ingest.de.sentry.io/4511990364700752
|
||||
VITE_SENTRY_ENVIRONMENT=development
|
||||
|
|
|
|||
14
.github/workflows/deploy-staging.yml
vendored
14
.github/workflows/deploy-staging.yml
vendored
|
|
@ -32,6 +32,8 @@ jobs:
|
|||
environment: staging
|
||||
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
|
||||
|
|
@ -68,15 +70,21 @@ jobs:
|
|||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
|
||||
aws-region: us-east-1
|
||||
|
||||
# Builds the SPA with the staging VITE_API_URL (process env overrides the
|
||||
# dev value committed in .env.production), syncs to the staging bucket,
|
||||
# and invalidates CloudFront.
|
||||
# Builds the SPA with the staging VITE_API_URL and Sentry environment
|
||||
# label (process env overrides the dev values committed in
|
||||
# .env.production), syncs to the staging bucket, and invalidates
|
||||
# CloudFront.
|
||||
- name: Build and publish SPA
|
||||
run: bash scripts/deploy-web.sh
|
||||
env:
|
||||
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,
|
||||
};
|
||||
}
|
||||
94
docs/adr/0002-sentry-observability.md
Normal file
94
docs/adr/0002-sentry-observability.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# 0002. Browser error and transaction telemetry with Sentry
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
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. 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
|
||||
|
||||
Use the pinned `@sentry/react` 10.73.0 SDK. Initialize it before the application
|
||||
router is created, and wrap `createBrowserRouter` with Sentry's current React
|
||||
Router compatibility API. Configure the browser tracing integration with a 100%
|
||||
transaction sample rate, and propagate trace headers only to the SPA origin and
|
||||
the configured `VITE_API_URL` base. Pass `Sentry.reactErrorHandler()` as the
|
||||
`onCaughtError`, `onUncaughtError`, and `onRecoverableError` callbacks of
|
||||
`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
|
||||
`shoc-frontend` Sentry project, so dev and staging deploys are live; a missing
|
||||
or blank DSN in any other build leaves telemetry inactive without preventing
|
||||
the application from starting. Default personally identifiable information
|
||||
collection remains disabled.
|
||||
|
||||
The Sentry environment label comes from `VITE_SENTRY_ENVIRONMENT` when set and
|
||||
non-blank, falling back to the Vite build mode otherwise. The committed
|
||||
`.env.production` labels dev deploys `development`, the staging deploy job
|
||||
overrides the label to `staging`, and a future production pipeline can set
|
||||
`production` without code changes.
|
||||
|
||||
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
|
||||
|
||||
- **Manual error and timing calls** — rejected because they would miss route
|
||||
transitions and distributed request context, and would require every feature
|
||||
to maintain its own instrumentation.
|
||||
- **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.
|
||||
- **Publish source maps with the SPA** — rejected because source code belongs in
|
||||
Sentry's private artifact store, not the public application bucket.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Browser navigations and API requests are recorded as connected transactions
|
||||
when the deployment supplies a valid DSN.
|
||||
- Events are labeled by `VITE_SENTRY_ENVIRONMENT` (`development` on dev
|
||||
deploys, `staging` on the staging job), falling back to the Vite build mode
|
||||
when unset.
|
||||
- Every transaction is sampled. This meets the current requirement but increases
|
||||
event volume and should be reviewed against Sentry quota and retention after
|
||||
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.
|
||||
- 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.
|
||||
308
package-lock.json
generated
308
package-lock.json
generated
|
|
@ -21,6 +21,7 @@
|
|||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@sentry/react": "10.73.0",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-query-devtools": "5.101.2",
|
||||
|
|
@ -45,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",
|
||||
|
|
@ -1974,6 +1976,295 @@
|
|||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sentry/browser": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.73.0.tgz",
|
||||
"integrity": "sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser-utils": "10.73.0",
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.73.0",
|
||||
"@sentry/feedback": "10.73.0",
|
||||
"@sentry/replay": "10.73.0",
|
||||
"@sentry/replay-canvas": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/browser-utils": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.73.0.tgz",
|
||||
"integrity": "sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"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",
|
||||
"integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.73.0.tgz",
|
||||
"integrity": "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.16.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/feedback": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.73.0.tgz",
|
||||
"integrity": "sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/react": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.73.0.tgz",
|
||||
"integrity": "sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser": "10.73.0",
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/replay": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.73.0.tgz",
|
||||
"integrity": "sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser-utils": "10.73.0",
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/replay-canvas": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.73.0.tgz",
|
||||
"integrity": "sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.73.0",
|
||||
"@sentry/replay": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@simple-libs/child-process-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz",
|
||||
|
|
@ -5736,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",
|
||||
|
|
@ -5753,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",
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@sentry/react": "10.73.0",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-query-devtools": "5.101.2",
|
||||
|
|
@ -79,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
|
|
@ -5,6 +5,7 @@ import { API_PATHS } from "@/api/api-paths";
|
|||
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
||||
import { getAccessToken } from "@/lib/auth/auth-storage";
|
||||
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
|
||||
function isAuthLoginRequest(request: Request): boolean {
|
||||
return request.url.includes(API_PATHS.authentication.login);
|
||||
|
|
@ -48,28 +49,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(
|
||||
|
|
@ -77,31 +142,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,8 +1,9 @@
|
|||
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 { uploadFormWithProgress } from "@/api/upload-form-with-progress";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
import {
|
||||
BoardPatchConflictError,
|
||||
BoardPatchValidationError,
|
||||
|
|
@ -82,10 +83,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) {
|
||||
|
|
@ -120,10 +123,21 @@ export const workOrderBoardDocumentsApi = {
|
|||
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
|
||||
): Promise<WorkOrderBoardMediaItem> => {
|
||||
const formData = buildMediaFormData(file, category);
|
||||
const { ok, status, data } = await uploadFormWithProgress(
|
||||
API_PATHS.workOrder.media(workOrderId),
|
||||
formData,
|
||||
{ signal: options?.signal, onProgress: options?.onProgress },
|
||||
const { ok, status, data } = await traceHttpOperation(
|
||||
{
|
||||
method: "POST",
|
||||
url: API_PATHS.workOrder.media(workOrderId),
|
||||
entryPoint: "workOrderBoardDocumentsApi.uploadMedia",
|
||||
},
|
||||
async ({ reportStatus }) => {
|
||||
const result = await uploadFormWithProgress(
|
||||
API_PATHS.workOrder.media(workOrderId),
|
||||
formData,
|
||||
{ signal: options?.signal, onProgress: options?.onProgress },
|
||||
);
|
||||
reportStatus(result.status);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
if (ok) {
|
||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||
|
|
@ -147,10 +161,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);
|
||||
|
|
@ -178,10 +194,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;
|
||||
|
|
@ -198,10 +216,12 @@ export const workOrderBoardDocumentsApi = {
|
|||
workOrderId: string | number,
|
||||
mediaId: string | number,
|
||||
): Promise<Blob> => {
|
||||
const response = await api.get(API_PATHS.workOrder.mediaContent(workOrderId, mediaId), {
|
||||
credentials: "include",
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"get",
|
||||
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
|
||||
{ credentials: "include", throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.getMediaContent",
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return response.blob();
|
||||
|
|
@ -232,10 +252,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 {
|
||||
|
|
|
|||
10
src/main.tsx
10
src/main.tsx
|
|
@ -1,3 +1,7 @@
|
|||
import * as Sentry from "@sentry/react";
|
||||
|
||||
import "@/observability/sentry";
|
||||
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { AppRoot } from "@/app-root";
|
||||
|
|
@ -10,7 +14,11 @@ if (!rootElement) {
|
|||
throw new Error("Root element #root not found");
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
ReactDOM.createRoot(rootElement, {
|
||||
onCaughtError: Sentry.reactErrorHandler(),
|
||||
onRecoverableError: Sentry.reactErrorHandler(),
|
||||
onUncaughtError: Sentry.reactErrorHandler(),
|
||||
}).render(
|
||||
<React.StrictMode>
|
||||
<AppProviders>
|
||||
<AppRoot />
|
||||
|
|
|
|||
111
src/observability/http-transaction.ts
Normal file
111
src/observability/http-transaction.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { setHttpStatus, spanToJSON, startSpan, type Span } from "@sentry/react";
|
||||
import { isHTTPError } from "ky";
|
||||
|
||||
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(isHTTPError(error) ? error.response.status : 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;
|
||||
}
|
||||
46
src/observability/sentry.ts
Normal file
46
src/observability/sentry.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import * as Sentry from "@sentry/react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
createRoutesFromChildren,
|
||||
matchRoutes,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
} 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;
|
||||
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,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
createRoutesFromChildren,
|
||||
matchRoutes,
|
||||
}),
|
||||
],
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { createBrowserRouter } from "react-router";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { createBrowserRouter as createBrowserRouterBase } from "react-router";
|
||||
import { RouterProvider } from "react-router/dom";
|
||||
|
||||
import { routes } from "@/routing/build-route-tree";
|
||||
|
||||
const createBrowserRouter = Sentry.wrapCreateBrowserRouter(createBrowserRouterBase);
|
||||
const router = createBrowserRouter(routes);
|
||||
|
||||
export function Routes() {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ vi.mock("@/api/api", () => ({
|
|||
apiPatch: (...args: unknown[]) => apiPatch(...args),
|
||||
apiDelete: vi.fn(),
|
||||
apiPostNoContent: vi.fn(),
|
||||
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);
|
||||
if (method === "get") return apiGetFn(url, options);
|
||||
throw new Error(`Unexpected apiRequestRaw method: ${method}`);
|
||||
},
|
||||
api: {
|
||||
patch: (...args: unknown[]) => apiPatchFn(...args),
|
||||
post: (...args: unknown[]) => apiPostFn(...args),
|
||||
|
|
|
|||
155
src/test/observability/http-transaction.test.ts
Normal file
155
src/test/observability/http-transaction.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
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 { HTTPError } from "ky";
|
||||
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 the Ky HTTPError response status instead of 500", async () => {
|
||||
const httpError = new HTTPError(
|
||||
new Response("Unauthorized", { status: 401 }),
|
||||
new Request("https://api.example.test/x"),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
traceHttpOperation({ method: "GET", url: "/x", entryPoint: "fn" }, async () => {
|
||||
throw httpError;
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(setHttpStatusMock).toHaveBeenCalledTimes(1);
|
||||
expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 401);
|
||||
});
|
||||
|
||||
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`,
|
||||
});
|
||||
});
|
||||
});
|
||||
5
src/vite-env.d.ts
vendored
5
src/vite-env.d.ts
vendored
|
|
@ -3,8 +3,13 @@
|
|||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_API_TARGET: string;
|
||||
readonly VITE_SENTRY_DSN?: string;
|
||||
readonly VITE_SENTRY_ENVIRONMENT?: string;
|
||||
}
|
||||
|
||||
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