mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 17:02:05 +00:00
Merge branch 'feat/SH-191-completion-freeze' of origin into feat/SH-191-completion-freeze
This commit is contained in:
commit
1809694973
30 changed files with 1462 additions and 118 deletions
|
|
@ -8,5 +8,13 @@
|
||||||
# Production: absolute URLs MUST include the /api path segment
|
# Production: absolute URLs MUST include the /api path segment
|
||||||
VITE_API_URL=https://api.seahavenindustries.com/api
|
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)
|
# Development proxy target (used by vite.config.ts)
|
||||||
#VITE_API_TARGET=http://localhost:5141
|
#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
|
# 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.).
|
# and prod builds must override VITE_API_URL per environment (api.staging..., etc.).
|
||||||
VITE_API_URL=https://api.dev.seahaven.com/api
|
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
|
environment: staging
|
||||||
env:
|
env:
|
||||||
VITE_API_URL: https://api.staging.seahaven.com/api
|
VITE_API_URL: https://api.staging.seahaven.com/api
|
||||||
|
VITE_SENTRY_ENVIRONMENT: staging
|
||||||
|
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||||
AWS_REGION: us-east-1
|
AWS_REGION: us-east-1
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- 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
|
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
|
||||||
aws-region: us-east-1
|
aws-region: us-east-1
|
||||||
|
|
||||||
# Builds the SPA with the staging VITE_API_URL (process env overrides the
|
# Builds the SPA with the staging VITE_API_URL and Sentry environment
|
||||||
# dev value committed in .env.production), syncs to the staging bucket,
|
# label (process env overrides the dev values committed in
|
||||||
# and invalidates CloudFront.
|
# .env.production), syncs to the staging bucket, and invalidates
|
||||||
|
# CloudFront.
|
||||||
- name: Build and publish SPA
|
- name: Build and publish SPA
|
||||||
run: bash scripts/deploy-web.sh
|
run: bash scripts/deploy-web.sh
|
||||||
env:
|
env:
|
||||||
STACK_NAME: shoc-frontend-staging
|
STACK_NAME: shoc-frontend-staging
|
||||||
WAIT_FOR_INVALIDATION: "true"
|
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
|
- name: Verify deployment
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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
|
post-deploy-script: scripts/deploy-web.sh
|
||||||
secrets:
|
secrets:
|
||||||
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
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",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@mui/icons-material": "^9.2.0",
|
"@mui/icons-material": "^9.2.0",
|
||||||
"@mui/material": "^9.2.0",
|
"@mui/material": "^9.2.0",
|
||||||
|
"@sentry/react": "10.73.0",
|
||||||
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||||
"@tanstack/react-query": "5.101.2",
|
"@tanstack/react-query": "5.101.2",
|
||||||
"@tanstack/react-query-devtools": "5.101.2",
|
"@tanstack/react-query-devtools": "5.101.2",
|
||||||
|
|
@ -45,6 +46,7 @@
|
||||||
"@commitlint/config-conventional": "^21.2.0",
|
"@commitlint/config-conventional": "^21.2.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
|
"@sentry/cli": "3.7.0",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
|
|
@ -1974,6 +1976,295 @@
|
||||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@simple-libs/child-process-utils": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz",
|
"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==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
|
@ -5753,6 +6054,13 @@
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@mui/icons-material": "^9.2.0",
|
"@mui/icons-material": "^9.2.0",
|
||||||
"@mui/material": "^9.2.0",
|
"@mui/material": "^9.2.0",
|
||||||
|
"@sentry/react": "10.73.0",
|
||||||
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||||
"@tanstack/react-query": "5.101.2",
|
"@tanstack/react-query": "5.101.2",
|
||||||
"@tanstack/react-query-devtools": "5.101.2",
|
"@tanstack/react-query-devtools": "5.101.2",
|
||||||
|
|
@ -79,6 +80,7 @@
|
||||||
"@commitlint/config-conventional": "^21.2.0",
|
"@commitlint/config-conventional": "^21.2.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
|
"@sentry/cli": "3.7.0",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ REGION="${AWS_REGION:-us-east-1}"
|
||||||
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
|
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
|
||||||
|
|
||||||
echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..."
|
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 ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
|
@ -41,6 +42,7 @@ echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
|
||||||
aws s3 sync dist/ "s3://${BUCKET}/" \
|
aws s3 sync dist/ "s3://${BUCKET}/" \
|
||||||
--delete \
|
--delete \
|
||||||
--exclude "index.html" \
|
--exclude "index.html" \
|
||||||
|
--exclude "*.map" \
|
||||||
--cache-control "public,max-age=31536000,immutable"
|
--cache-control "public,max-age=31536000,immutable"
|
||||||
|
|
||||||
echo "Uploading index.html (never cached)..."
|
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 { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
||||||
import { getAccessToken } from "@/lib/auth/auth-storage";
|
import { getAccessToken } from "@/lib/auth/auth-storage";
|
||||||
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
|
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
|
||||||
|
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||||
|
|
||||||
function isAuthLoginRequest(request: Request): boolean {
|
function isAuthLoginRequest(request: Request): boolean {
|
||||||
return request.url.includes(API_PATHS.authentication.login);
|
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> {
|
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> {
|
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> {
|
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> {
|
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> {
|
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> {
|
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(
|
export async function apiPostNoContent(
|
||||||
|
|
@ -77,31 +142,43 @@ export async function apiPostNoContent(
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
options?: Options,
|
options?: Options,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await api.post(url, { ...options, json: body });
|
return traceHttpOperation(
|
||||||
if (response.status === 204) {
|
{ method: "POST", url, entryPoint: "api.apiPostNoContent" },
|
||||||
return;
|
async ({ reportStatus }) => {
|
||||||
}
|
const response = await api.post(url, { ...options, json: body });
|
||||||
|
reportStatus(response.status);
|
||||||
|
if (response.status === 204) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
JSON.parse(text);
|
JSON.parse(text);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDeleteNoContent(url: string, options?: Options): Promise<void> {
|
export async function apiDeleteNoContent(url: string, options?: Options): Promise<void> {
|
||||||
const response = await api.delete(url, options);
|
return traceHttpOperation(
|
||||||
if (response.status === 204) {
|
{ method: "DELETE", url, entryPoint: "api.apiDeleteNoContent" },
|
||||||
return;
|
async ({ reportStatus }) => {
|
||||||
}
|
const response = await api.delete(url, options);
|
||||||
|
reportStatus(response.status);
|
||||||
|
if (response.status === 204) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
JSON.parse(text);
|
JSON.parse(text);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { mapHttpStatusToMessage };
|
export { mapHttpStatusToMessage };
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,13 @@ export function useLogout(): UseMutationResult<void, Error, void> {
|
||||||
mutationFn: authApi.logout,
|
mutationFn: authApi.logout,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
stopQueryBroadcast(queryClient);
|
stopQueryBroadcast(queryClient);
|
||||||
|
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||||
toast.info("You have been logged out");
|
toast.info("You have been logged out");
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
stopQueryBroadcast(queryClient);
|
stopQueryBroadcast(queryClient);
|
||||||
|
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||||
toast.error(error.message || "Logout failed");
|
toast.error(error.message || "Logout failed");
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { HTTPError } from "ky";
|
import { HTTPError } from "ky";
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
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 { handleApiResponse } from "@/api/handle-api-response";
|
||||||
import { mapUpliftQueueItems, mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper";
|
import { mapUpliftQueueItems, mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper";
|
||||||
import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
|
import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
|
||||||
|
|
@ -91,7 +91,12 @@ export const upliftsApi = {
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await api.get(evidenceUrl(id));
|
response = await apiRequestRaw(
|
||||||
|
"get",
|
||||||
|
evidenceUrl(id),
|
||||||
|
undefined,
|
||||||
|
"upliftsApi.downloadEvidence",
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HTTPError) {
|
if (error instanceof HTTPError) {
|
||||||
throw evidenceHttpError(error.response.status);
|
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 {
|
import type {
|
||||||
SiteVendorPreference,
|
SiteVendorPreference,
|
||||||
VendorAssignmentResult,
|
VendorAssignmentResult,
|
||||||
|
|
@ -27,7 +27,13 @@ type InsightParams = {
|
||||||
};
|
};
|
||||||
|
|
||||||
async function saveDownload(path: string, fileName: string, params: InsightParams): Promise<void> {
|
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 url = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement("a");
|
const anchor = document.createElement("a");
|
||||||
anchor.href = url;
|
anchor.href = url;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
|
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
|
||||||
|
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||||
import type {
|
import type {
|
||||||
VendorPortalComment,
|
VendorPortalComment,
|
||||||
VendorPortalDispatchDetail,
|
VendorPortalDispatchDetail,
|
||||||
|
|
@ -38,30 +39,45 @@ function uploadVendorDocument({
|
||||||
purpose,
|
purpose,
|
||||||
onProgress,
|
onProgress,
|
||||||
}: UploadVendorDocumentOptions): Promise<VendorPortalDocument> {
|
}: UploadVendorDocumentOptions): Promise<VendorPortalDocument> {
|
||||||
return new Promise((resolve, reject) => {
|
return traceHttpOperation(
|
||||||
const request = new XMLHttpRequest();
|
{
|
||||||
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
|
method: "POST",
|
||||||
request.setRequestHeader("X-Vendor-Token", token);
|
url: resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`),
|
||||||
request.upload.addEventListener("progress", (event) => {
|
entryPoint: "vendorPortalApi.uploadDocument",
|
||||||
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
|
actorType: "vendor_link",
|
||||||
});
|
},
|
||||||
request.addEventListener("load", () => {
|
({ reportStatus }) =>
|
||||||
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
|
new Promise<VendorPortalDocument>((resolve, reject) => {
|
||||||
if (request.status < 200 || request.status >= 300) {
|
const request = new XMLHttpRequest();
|
||||||
reject(new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)));
|
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
|
||||||
return;
|
request.setRequestHeader("X-Vendor-Token", token);
|
||||||
}
|
request.upload.addEventListener("progress", (event) => {
|
||||||
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
|
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
|
||||||
});
|
});
|
||||||
request.addEventListener("error", () => reject(new Error("Upload failed")));
|
request.addEventListener("load", () => {
|
||||||
const body = new FormData();
|
reportStatus(request.status);
|
||||||
body.append("file", file);
|
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
|
||||||
if (replacesDocumentId !== undefined) {
|
if (request.status < 200 || request.status >= 300) {
|
||||||
body.append("replacesDocumentId", String(replacesDocumentId));
|
reject(
|
||||||
}
|
new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)),
|
||||||
body.append("purpose", purpose ?? "Completion");
|
);
|
||||||
request.send(body);
|
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(
|
async function downloadVendorDocument(
|
||||||
|
|
@ -69,19 +85,32 @@ async function downloadVendorDocument(
|
||||||
dispatchId: string | number,
|
dispatchId: string | number,
|
||||||
document: VendorPortalDocument,
|
document: VendorPortalDocument,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await fetch(
|
await traceHttpOperation(
|
||||||
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
{
|
||||||
{ headers: { "X-Vendor-Token": token } },
|
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>(
|
async function vendorPortalCall<T>(
|
||||||
|
|
@ -89,30 +118,41 @@ async function vendorPortalCall<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options?: { method?: string; body?: unknown },
|
options?: { method?: string; body?: unknown },
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const response = await fetch(resolveVendorPortalUrl(path), {
|
return traceHttpOperation<T>(
|
||||||
method: options?.method ?? "GET",
|
{
|
||||||
headers: {
|
method: options?.method ?? "GET",
|
||||||
"Content-Type": "application/json",
|
url: resolveVendorPortalUrl(path),
|
||||||
"X-Vendor-Token": token,
|
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) {
|
if (response.status === 401) {
|
||||||
throw new Error("Invalid or expired token");
|
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) {
|
if (!response.ok) {
|
||||||
const message =
|
const message =
|
||||||
(typeof payload.message === "string" ? payload.message : undefined) ??
|
(typeof payload.message === "string" ? payload.message : undefined) ??
|
||||||
(typeof payload.Message === "string" ? payload.Message : undefined) ??
|
(typeof payload.Message === "string" ? payload.Message : undefined) ??
|
||||||
`HTTP ${response.status}`;
|
`HTTP ${response.status}`;
|
||||||
throw new Error(message);
|
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 {
|
function isNoApprovalRequired(payload: unknown): boolean {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
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 { handleApiResponse } from "@/api/handle-api-response";
|
||||||
import { uploadFormWithProgress } from "@/api/upload-form-with-progress";
|
import { uploadFormWithProgress } from "@/api/upload-form-with-progress";
|
||||||
|
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||||
import {
|
import {
|
||||||
BoardPatchConflictError,
|
BoardPatchConflictError,
|
||||||
BoardPatchValidationError,
|
BoardPatchValidationError,
|
||||||
|
|
@ -82,10 +83,12 @@ export const workOrderBoardDocumentsApi = {
|
||||||
commentId: string | number,
|
commentId: string | number,
|
||||||
text: string,
|
text: string,
|
||||||
): Promise<WorkOrderComment> => {
|
): Promise<WorkOrderComment> => {
|
||||||
const response = await api.patch(API_PATHS.workOrder.commentItem(workOrderId, commentId), {
|
const response = await apiRequestRaw(
|
||||||
json: { text },
|
"patch",
|
||||||
throwHttpErrors: false,
|
API_PATHS.workOrder.commentItem(workOrderId, commentId),
|
||||||
});
|
{ json: { text }, throwHttpErrors: false },
|
||||||
|
"workOrderBoardDocumentsApi.updateComment",
|
||||||
|
);
|
||||||
const data = await readResponseJson(response);
|
const data = await readResponseJson(response);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|
@ -120,10 +123,21 @@ export const workOrderBoardDocumentsApi = {
|
||||||
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
|
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
|
||||||
): Promise<WorkOrderBoardMediaItem> => {
|
): Promise<WorkOrderBoardMediaItem> => {
|
||||||
const formData = buildMediaFormData(file, category);
|
const formData = buildMediaFormData(file, category);
|
||||||
const { ok, status, data } = await uploadFormWithProgress(
|
const { ok, status, data } = await traceHttpOperation(
|
||||||
API_PATHS.workOrder.media(workOrderId),
|
{
|
||||||
formData,
|
method: "POST",
|
||||||
{ signal: options?.signal, onProgress: options?.onProgress },
|
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) {
|
if (ok) {
|
||||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||||
|
|
@ -147,10 +161,12 @@ export const workOrderBoardDocumentsApi = {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("category", category);
|
formData.append("category", category);
|
||||||
formData.append("workOrderVersion", workOrderVersion);
|
formData.append("workOrderVersion", workOrderVersion);
|
||||||
const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), {
|
const response = await apiRequestRaw(
|
||||||
body: formData,
|
"patch",
|
||||||
throwHttpErrors: false,
|
API_PATHS.workOrder.mediaItem(workOrderId, mediaId),
|
||||||
});
|
{ body: formData, throwHttpErrors: false },
|
||||||
|
"workOrderBoardDocumentsApi.updateMediaCategory",
|
||||||
|
);
|
||||||
const data = await readResponseJson(response);
|
const data = await readResponseJson(response);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||||
|
|
@ -178,10 +194,12 @@ export const workOrderBoardDocumentsApi = {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
|
const response = await apiRequestRaw(
|
||||||
searchParams: { workOrderVersion },
|
"delete",
|
||||||
throwHttpErrors: false,
|
API_PATHS.workOrder.mediaItem(workOrderId, numericId),
|
||||||
});
|
{ searchParams: { workOrderVersion }, throwHttpErrors: false },
|
||||||
|
"workOrderBoardDocumentsApi.deleteMedia",
|
||||||
|
);
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -198,10 +216,12 @@ export const workOrderBoardDocumentsApi = {
|
||||||
workOrderId: string | number,
|
workOrderId: string | number,
|
||||||
mediaId: string | number,
|
mediaId: string | number,
|
||||||
): Promise<Blob> => {
|
): Promise<Blob> => {
|
||||||
const response = await api.get(API_PATHS.workOrder.mediaContent(workOrderId, mediaId), {
|
const response = await apiRequestRaw(
|
||||||
credentials: "include",
|
"get",
|
||||||
throwHttpErrors: false,
|
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
|
||||||
});
|
{ credentials: "include", throwHttpErrors: false },
|
||||||
|
"workOrderBoardDocumentsApi.getMediaContent",
|
||||||
|
);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return response.blob();
|
return response.blob();
|
||||||
|
|
@ -232,10 +252,12 @@ export const workOrderBoardDocumentsApi = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = buildCompletionDocFormData(params);
|
const formData = buildCompletionDocFormData(params);
|
||||||
const response = await api.post(API_PATHS.workOrder.completionDoc(workOrderId), {
|
const response = await apiRequestRaw(
|
||||||
body: formData,
|
"post",
|
||||||
throwHttpErrors: false,
|
API_PATHS.workOrder.completionDoc(workOrderId),
|
||||||
});
|
{ body: formData, throwHttpErrors: false },
|
||||||
|
"workOrderBoardDocumentsApi.uploadCompletionDoc",
|
||||||
|
);
|
||||||
const data = await readResponseJson(response);
|
const data = await readResponseJson(response);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return mapCompletion(handleApiResponse(data) ?? data);
|
return mapCompletion(handleApiResponse(data) ?? data);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
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 { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||||
import {
|
import {
|
||||||
BoardPatchConflictError,
|
BoardPatchConflictError,
|
||||||
|
|
@ -314,10 +314,12 @@ export const workOrdersApi = {
|
||||||
id: string | number,
|
id: string | number,
|
||||||
payload: WorkOrderBoardPatchPayload,
|
payload: WorkOrderBoardPatchPayload,
|
||||||
): Promise<WorkOrderTableRow> => {
|
): Promise<WorkOrderTableRow> => {
|
||||||
const response = await api.patch(API_PATHS.workOrder.boardPatch(id), {
|
const response = await apiRequestRaw(
|
||||||
json: payload,
|
"patch",
|
||||||
throwHttpErrors: false,
|
API_PATHS.workOrder.boardPatch(id),
|
||||||
});
|
{ json: payload, throwHttpErrors: false },
|
||||||
|
"workOrdersApi.patchBoardField",
|
||||||
|
);
|
||||||
|
|
||||||
let data: unknown;
|
let data: unknown;
|
||||||
try {
|
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 React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { AppRoot } from "@/app-root";
|
import { AppRoot } from "@/app-root";
|
||||||
|
|
@ -10,7 +14,11 @@ if (!rootElement) {
|
||||||
throw new Error("Root element #root not found");
|
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>
|
<React.StrictMode>
|
||||||
<AppProviders>
|
<AppProviders>
|
||||||
<AppRoot />
|
<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 { useEffect, useMemo, type ReactNode } from "react";
|
||||||
|
import * as Sentry from "@sentry/react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
|
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
|
||||||
import { useLogin, useLogout, useSession } from "@/domain/auth/use-cases/use-login";
|
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;
|
const userId = sessionQuery.data?.id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Sentry.setUser(userId == null ? null : { id: String(userId) });
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
return;
|
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 { RouterProvider } from "react-router/dom";
|
||||||
|
|
||||||
import { routes } from "@/routing/build-route-tree";
|
import { routes } from "@/routing/build-route-tree";
|
||||||
|
|
||||||
|
const createBrowserRouter = Sentry.wrapCreateBrowserRouter(createBrowserRouterBase);
|
||||||
const router = createBrowserRouter(routes);
|
const router = createBrowserRouter(routes);
|
||||||
|
|
||||||
export function Routes() {
|
export function Routes() {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,13 @@ vi.mock("@/api/api", () => ({
|
||||||
apiPatch: (...args: unknown[]) => apiPatch(...args),
|
apiPatch: (...args: unknown[]) => apiPatch(...args),
|
||||||
apiDelete: vi.fn(),
|
apiDelete: vi.fn(),
|
||||||
apiPostNoContent: 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: {
|
api: {
|
||||||
patch: (...args: unknown[]) => apiPatchFn(...args),
|
patch: (...args: unknown[]) => apiPatchFn(...args),
|
||||||
post: (...args: unknown[]) => apiPostFn(...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 {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_URL: string;
|
readonly VITE_API_URL: string;
|
||||||
readonly VITE_API_TARGET: string;
|
readonly VITE_API_TARGET: string;
|
||||||
|
readonly VITE_SENTRY_DSN?: string;
|
||||||
|
readonly VITE_SENTRY_ENVIRONMENT?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
readonly env: ImportMetaEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare const __APP_COMMIT_SHA__: string;
|
||||||
|
declare const __SENTRY_RELEASE__: string;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@
|
||||||
"vitest.config.ts",
|
"vitest.config.ts",
|
||||||
"config/api-url-contract.ts",
|
"config/api-url-contract.ts",
|
||||||
"config/api-url-contract.test.ts",
|
"config/api-url-contract.test.ts",
|
||||||
|
"config/sentry-release.ts",
|
||||||
|
"config/sentry-release.test.ts",
|
||||||
"config/pwa-manifest.test.ts"
|
"config/pwa-manifest.test.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,33 @@
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import { assertAbsoluteApiUrlHasApiSegment } from "./config/api-url-contract";
|
import { assertAbsoluteApiUrlHasApiSegment } from "./config/api-url-contract";
|
||||||
|
import { resolveSentryRelease } from "./config/sentry-release";
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, process.cwd(), "");
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
|
|
||||||
assertAbsoluteApiUrlHasApiSegment(env.VITE_API_URL);
|
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 {
|
return {
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
define: {
|
||||||
|
__APP_COMMIT_SHA__: JSON.stringify(sentryRelease.commitSha),
|
||||||
|
__SENTRY_RELEASE__: JSON.stringify(sentryRelease.release),
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "src"),
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
|
@ -28,6 +45,7 @@ export default defineConfig(({ mode }) => {
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
|
sourcemap: "hidden",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue