feat: install @sentry/vue + observability module skeleton
WS-7 PR-3 commit 1. Frontend mirror of the backend SDK install (commits bdb89a2..adab3be), wired against the existing apps/app SPA. - pnpm add @sentry/vue@10.52.0 (pinned). - src/observability/sentry.ts: initSentry() — empty DSN no-op (RFC §3.3), errors-only (tracesSampleRate=0, profilesSampleRate=0; RFC §2 amend.B), sendDefaultPii=false, Console integration off, beforeSend wired to the scrubber, initial scope tag app=app for GlitchTip filtering. - src/observability/scrubber.ts: TypeScript port of backend SentryEventScrubber. RFC §3.7 frontend block — body / header / query scrubbing, form_values wholesale replacement, cookies wholesale, defensive strip of contexts.storage and user.cookies, max-depth guard. - src/observability/contextBinding.ts: Vue Router beforeEach guard that binds RFC §3.6 auth-scope tags per navigation. Three zones via route.meta.public + route.path matching: - portal token zone (meta.public + meta.context=portal) → actor_scope= portal, no user_id (RFC §3.6 explicit) - /platform/* with super_admin → actor_scope=platform, no org tag - default authenticated → actor_scope=organisation when an active organisation is selected (useOrganisationStore.activeOrganisationId), otherwise actor_scope=user - unauthenticated public pages → actor_scope=anonymous Reads useAuthStore (user, appRoles, isSuperAdmin) and useOrganisationStore (activeOrganisationId) — corrected vs. RFC's speculative auth-store API. - src/observability/index.ts: barrel. - src/main.ts: initSentry runs before registerPlugins so Sentry's Vue errorHandler hooks before any plugin or component initialises; installContextBinding runs after registerPlugins so pinia is up. - env.d.ts: VITE_SENTRY_DSN_FRONTEND + VITE_SENTRY_RELEASE typed. - .env.example: new file (didn't exist before) documenting all SPA env vars including the new Sentry pair. - vite.config.ts: build.sourcemap=true (RFC §3.5 — generated, uploaded to GlitchTip by deploy.sh, then stripped before nginx serves dist/). Typecheck: green. Build: green, *.map files emitted alongside *.js chunks as expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import { VueQueryPlugin } from '@tanstack/vue-query'
|
||||
import { queryClientConfig } from '@/lib/query-client'
|
||||
import { initSentry, installContextBinding } from '@/observability'
|
||||
import { router } from '@/plugins/1.router'
|
||||
|
||||
import App from '@/App.vue'
|
||||
import { registerPlugins } from '@core/utils/plugins'
|
||||
@@ -12,9 +14,24 @@ import '@styles/styles.scss'
|
||||
// Create vue app
|
||||
const app = createApp(App)
|
||||
|
||||
// Register plugins
|
||||
// RFC-WS-7 — Sentry init runs before plugin registration so the SDK can
|
||||
// hook Vue's errorHandler before any plugin or component initialises.
|
||||
// Empty DSN = SDK no-op (mirrors backend behaviour, RFC §3.3).
|
||||
initSentry({
|
||||
app,
|
||||
router,
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN_FRONTEND ?? '',
|
||||
release: import.meta.env.VITE_SENTRY_RELEASE ?? '',
|
||||
environment: import.meta.env.MODE,
|
||||
})
|
||||
|
||||
// Register plugins (router, pinia, vuetify, …).
|
||||
registerPlugins(app)
|
||||
|
||||
// Bind auth-scope tags per route navigation. Must run after pinia is set
|
||||
// up by registerPlugins (the guard reads useAuthStore / useOrganisationStore).
|
||||
installContextBinding(router)
|
||||
|
||||
app.use(VueQueryPlugin, queryClientConfig)
|
||||
|
||||
// Mount vue app
|
||||
|
||||
106
apps/app/src/observability/contextBinding.ts
Normal file
106
apps/app/src/observability/contextBinding.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import type { RouteLocationNormalized, Router } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { useOrganisationStore } from '@/stores/useOrganisationStore'
|
||||
|
||||
/**
|
||||
* Installs a Vue Router beforeEach guard that binds Sentry scope tags per
|
||||
* RFC-WS-7 §3.6. Frontend-equivalent of the backend AuthScopeContextListener.
|
||||
*
|
||||
* Three zones:
|
||||
* - Token-based portal (route.meta.public === true && context === 'portal'):
|
||||
* actor_scope=portal, no user_id/username — RFC §3.6 explicit. Stricter
|
||||
* scrubbing applies via the {@link scrubEvent} hook regardless of zone.
|
||||
* - Platform admin (path /platform/* with super_admin role): actor_scope=
|
||||
* platform, full user context. No organisation_id (forced fallback would
|
||||
* misattribute platform-scoped events).
|
||||
* - Organizer (everything else, authenticated): actor_scope=organisation
|
||||
* when an active organisation is selected; otherwise actor_scope=user.
|
||||
* - Unauthenticated public pages (login, password reset): actor_scope=
|
||||
* anonymous.
|
||||
*
|
||||
* Crewli's auth-store API differs from the RFC's speculative shape:
|
||||
* - User identity: useAuthStore().user (User | null)
|
||||
* - Role list: useAuthStore().appRoles (string[])
|
||||
* - Active organisation: useOrganisationStore().activeOrganisationId
|
||||
* The guard reads these stores directly; pinia must be initialised before
|
||||
* the first navigation, which is satisfied by registerPlugins() running
|
||||
* before app.mount() in main.ts.
|
||||
*/
|
||||
export function installContextBinding(router: Router): void {
|
||||
router.beforeEach(to => {
|
||||
bindScope(to)
|
||||
})
|
||||
}
|
||||
|
||||
function bindScope(route: RouteLocationNormalized): void {
|
||||
Sentry.getCurrentScope().clear()
|
||||
|
||||
const scope = Sentry.getCurrentScope()
|
||||
|
||||
// Always-present route-scope tags. Frontend never has http.method on a
|
||||
// route navigation; that tag belongs to per-request fetch instrumentation
|
||||
// which sentry-vue auto-attaches to fetch breadcrumbs.
|
||||
scope.setTag('app', 'app')
|
||||
scope.setTag('route_name', String(route.name ?? 'unnamed'))
|
||||
|
||||
// Token-based portal flow (artist advance, public form fill). RFC §3.6:
|
||||
// strict mode, no user_id, no username. The backend portal token already
|
||||
// resolves the organisation via the matching artist/event row, so a
|
||||
// captured frontend event correlates via request_id.
|
||||
if (route.meta.public === true && route.meta.context === 'portal') {
|
||||
scope.setTag('actor_scope', 'portal')
|
||||
scope.setTag('actor_type', 'portal_token')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Other public routes (login, forgot-password, register) — anonymous.
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated || auth.user === null) {
|
||||
scope.setTag('actor_scope', 'anonymous')
|
||||
scope.setTag('actor_type', 'unauthenticated')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticated zones — bind user identity (RFC §3.8: ULID, never email).
|
||||
scope.setUser({
|
||||
id: String(auth.user.id),
|
||||
username: String(auth.user.id),
|
||||
})
|
||||
scope.setTag('user_id', String(auth.user.id))
|
||||
|
||||
// Platform admin scope: super_admin on /platform/* routes.
|
||||
if (route.path.startsWith('/platform') && auth.isSuperAdmin) {
|
||||
scope.setTag('actor_scope', 'platform')
|
||||
scope.setTag('actor_type', 'super_admin')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Organizer scope. Tag actor_scope=organisation when an active org is
|
||||
// selected (organisation_id from useOrganisationStore mirrors backend
|
||||
// route-param resolution). Otherwise actor_scope=user — Crewli's
|
||||
// many-to-many user-org model has no reliable single-org hint without
|
||||
// the active selection.
|
||||
const org = useOrganisationStore()
|
||||
if (org.activeOrganisationId !== null && org.activeOrganisationId !== '') {
|
||||
scope.setTag('actor_scope', 'organisation')
|
||||
scope.setTag('organisation_id', String(org.activeOrganisationId))
|
||||
}
|
||||
else {
|
||||
scope.setTag('actor_scope', 'user')
|
||||
}
|
||||
|
||||
scope.setTag('actor_type', resolveActorType(auth.appRoles))
|
||||
}
|
||||
|
||||
function resolveActorType(roles: readonly string[]): string {
|
||||
if (roles.includes('super_admin'))
|
||||
return 'super_admin'
|
||||
if (roles.includes('org_admin'))
|
||||
return 'organizer_admin'
|
||||
|
||||
return 'org_member'
|
||||
}
|
||||
4
apps/app/src/observability/index.ts
Normal file
4
apps/app/src/observability/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { installContextBinding } from './contextBinding'
|
||||
export { scrubEvent } from './scrubber'
|
||||
export { initSentry } from './sentry'
|
||||
export type { SentryInitOptions } from './sentry'
|
||||
131
apps/app/src/observability/scrubber.ts
Normal file
131
apps/app/src/observability/scrubber.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { EventHint, ErrorEvent as SentryErrorEvent } from '@sentry/vue'
|
||||
|
||||
const SENSITIVE_BODY_KEYS = [
|
||||
'password',
|
||||
'password_confirmation',
|
||||
'current_password',
|
||||
'token',
|
||||
'api_key',
|
||||
'secret',
|
||||
'webhook_secret',
|
||||
'dsn',
|
||||
'signature',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'bearer',
|
||||
'iban',
|
||||
'bic',
|
||||
'passport_number',
|
||||
'bsn',
|
||||
] as const
|
||||
|
||||
const SENSITIVE_HEADERS = [
|
||||
'authorization',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
'x-api-key',
|
||||
'x-impersonation-token',
|
||||
] as const
|
||||
|
||||
const SENSITIVE_QUERY_KEYS = ['token', 'api_key'] as const
|
||||
|
||||
const SCRUBBED = '[scrubbed]'
|
||||
const FORM_VALUES_REPLACEMENT = '[scrubbed_form_values]'
|
||||
const MAX_DEPTH = 10
|
||||
|
||||
/**
|
||||
* RFC-WS-7 §3.7 frontend block: scrubs PII from outgoing Sentry events.
|
||||
*
|
||||
* Mirrors the backend SentryEventScrubber semantics so a captured request
|
||||
* looks the same regardless of which side originated the event. Adds
|
||||
* frontend-specific scrubs (cookies via document.cookie exposure,
|
||||
* localStorage/sessionStorage never in event context).
|
||||
*/
|
||||
export function scrubEvent(event: SentryErrorEvent, _hint?: EventHint): SentryErrorEvent | null {
|
||||
if (event.request) {
|
||||
if (event.request.data !== undefined && event.request.data !== null && typeof event.request.data === 'object')
|
||||
event.request.data = scrubBody(event.request.data, 0)
|
||||
|
||||
if (event.request.headers && typeof event.request.headers === 'object')
|
||||
event.request.headers = scrubHeaders(event.request.headers as Record<string, string>)
|
||||
|
||||
if (typeof event.request.query_string === 'string' && event.request.query_string !== '')
|
||||
event.request.query_string = scrubQueryString(event.request.query_string)
|
||||
|
||||
// RFC §3.7 frontend point 1: cookies wholesale. Sentry types the field
|
||||
// as Record<string, string>; replacing with a single sentinel key keeps
|
||||
// the structure shape while removing any real values.
|
||||
if (event.request.cookies !== undefined)
|
||||
event.request.cookies = { scrubbed: SCRUBBED }
|
||||
}
|
||||
|
||||
// RFC §3.7 frontend point 2: localStorage / sessionStorage never in event
|
||||
// context. Sentry doesn't add these by default but defensively strip.
|
||||
if (event.contexts && 'storage' in event.contexts)
|
||||
delete (event.contexts as Record<string, unknown>).storage
|
||||
|
||||
// RFC §3.7 frontend point 1: scrub document.cookie if Sentry's
|
||||
// BrowserSession integration injected it under user.
|
||||
if (event.user && 'cookies' in event.user)
|
||||
delete (event.user as Record<string, unknown>).cookies
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
function scrubBody(data: unknown, depth: number): unknown {
|
||||
if (depth > MAX_DEPTH)
|
||||
return ['[max_depth]']
|
||||
|
||||
if (Array.isArray(data))
|
||||
return data.map(item => scrubBody(item, depth + 1))
|
||||
|
||||
if (data === null || typeof data !== 'object')
|
||||
return data
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
|
||||
const lowerKey = key.toLowerCase()
|
||||
|
||||
if (lowerKey === 'form_values') {
|
||||
result[key] = FORM_VALUES_REPLACEMENT
|
||||
continue
|
||||
}
|
||||
|
||||
if ((SENSITIVE_BODY_KEYS as readonly string[]).includes(lowerKey)) {
|
||||
result[key] = SCRUBBED
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
result[key] = scrubBody(value, depth + 1)
|
||||
continue
|
||||
}
|
||||
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function scrubHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if ((SENSITIVE_HEADERS as readonly string[]).includes(name.toLowerCase()))
|
||||
result[name] = SCRUBBED
|
||||
|
||||
else
|
||||
result[name] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function scrubQueryString(queryString: string): string {
|
||||
const params = new URLSearchParams(queryString)
|
||||
for (const key of Array.from(params.keys())) {
|
||||
if ((SENSITIVE_QUERY_KEYS as readonly string[]).includes(key.toLowerCase()))
|
||||
params.set(key, SCRUBBED)
|
||||
}
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
62
apps/app/src/observability/sentry.ts
Normal file
62
apps/app/src/observability/sentry.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import type { App } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
import { scrubEvent } from './scrubber'
|
||||
|
||||
export interface SentryInitOptions {
|
||||
app: App
|
||||
router: Router
|
||||
dsn: string
|
||||
release: string
|
||||
environment: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialises @sentry/vue for the SPA per RFC-WS-7 §3.2-§3.9.
|
||||
*
|
||||
* - Empty DSN → no-op (RFC §3.3, mirrors backend).
|
||||
* - Errors-only — tracesSampleRate / profilesSampleRate hard-pinned to 0
|
||||
* (RFC §2 amendment B).
|
||||
* - sendDefaultPii=false (RFC §3.7 / §3.8); user identity is bound
|
||||
* explicitly by {@link installContextBinding} as a ULID-only object.
|
||||
* - app=app initial scope tag (RFC §3.6) so GlitchTip can filter
|
||||
* frontend vs backend events.
|
||||
* - PII scrubbing via {@link scrubEvent} as the beforeSend hook (RFC §3.7
|
||||
* frontend block).
|
||||
*/
|
||||
export function initSentry(options: SentryInitOptions): void {
|
||||
if (options.dsn === '')
|
||||
return
|
||||
|
||||
Sentry.init({
|
||||
app: options.app,
|
||||
dsn: options.dsn,
|
||||
release: options.release === '' ? undefined : options.release,
|
||||
environment: options.environment,
|
||||
|
||||
// RFC §2 amendment B — errors-only.
|
||||
tracesSampleRate: 0,
|
||||
profilesSampleRate: 0,
|
||||
|
||||
// RFC §3.7 / §3.8: never let Sentry's auto-context capture IP, locals
|
||||
// from stack frames, or the User session-cookie payload.
|
||||
sendDefaultPii: false,
|
||||
|
||||
// RFC §3.7 frontend point 5: console-logging integration off in prod
|
||||
// (info / debug breadcrumbs may include user data through formatted
|
||||
// arguments). Keep the BrowserApiErrors/Vue/global integrations.
|
||||
integrations: defaults => defaults.filter(i => i.name !== 'Console'),
|
||||
|
||||
// RFC §3.7 frontend block — scrubber applied on every event.
|
||||
beforeSend: scrubEvent,
|
||||
|
||||
// RFC §3.6 — route-scope baseline tag. AuthScopeContextListener-style
|
||||
// binding of actor_scope / user_id / actor_type happens per route
|
||||
// navigation in {@link installContextBinding}.
|
||||
initialScope: {
|
||||
tags: {
|
||||
app: 'app',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user