diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.client.ts b/src/Exceptionless.Web/ClientApp/src/hooks.client.ts index 7157b4389..36d2768f4 100644 --- a/src/Exceptionless.Web/ClientApp/src/hooks.client.ts +++ b/src/Exceptionless.Web/ClientApp/src/hooks.client.ts @@ -1,10 +1,12 @@ -import type { ServerInit } from '@sveltejs/kit'; +import type { HandleClientError, ServerInit } from '@sveltejs/kit'; +import { dev } from '$app/environment'; +import { page } from '$app/state'; import { env } from '$env/dynamic/public'; -import { Exceptionless, toError } from '@exceptionless/browser'; +import { normalizePath, normalizeRouteId } from '$lib/telemetry'; +import { Exceptionless, guid, toError } from '@exceptionless/browser'; +import { useMiddleware } from '@exceptionless/fetchclient'; -// If the PUBLIC_BASE_URL is set in local storage, we will use that instead of the one from the environment variables. -// This allows you to target other environments from your browser. const PUBLIC_BASE_URL = localStorage?.getItem('PUBLIC_BASE_URL'); if (PUBLIC_BASE_URL) { env.PUBLIC_BASE_URL = PUBLIC_BASE_URL; @@ -14,16 +16,57 @@ export const init: ServerInit = async () => { await Exceptionless.startup((c) => { c.apiKey = env.PUBLIC_EXCEPTIONLESS_API_KEY; c.serverUrl = env.PUBLIC_EXCEPTIONLESS_SERVER_URL || window.location.origin; - c.defaultTags.push('UI', 'Svelte'); - c.settings['@@log:*'] = 'debug'; + + if (env.PUBLIC_APP_VERSION) { + c.version = env.PUBLIC_APP_VERSION; + } + + if (dev) { + c.settings['@@log:*'] = 'debug'; + } + + c.useSessions(); + + c.addPlugin('route-context', 10, async (ctx) => { + if (ctx.event.type !== 'usage') { + ctx.event.data = ctx.event.data ?? {}; + ctx.event.data['@route'] = normalizeRouteId(page.route.id); + } + }); + }); + + useMiddleware(async (ctx, next) => { + await next(); + + const status = ctx.response?.status; + if (!status || (status < 500 && status !== 429)) { + return; + } + + const url = ctx.request?.url ?? ''; + if (url.includes('/api/v2/events') || url.includes('/api/v2/configuration')) { + return; + } + + const method = ctx.request?.method ?? 'UNKNOWN'; + const path = normalizePath(url.replace(/^https?:\/\/[^/]+/, ''), ''); + Exceptionless.createLog(`${method} ${path}`, `HTTP ${status}`, 'warn').addTags('api-failure').submit(); }); }; -/** @type {import('@sveltejs/kit').HandleClientError} */ -export async function handleError({ error, event, message, status }) { - console.warn({ error, event, message, source: 'client error handler', status }); - await Exceptionless.createException(toError(error ?? message)) - .setProperty('status', status) - .submit(); -} +export const handleError: HandleClientError = async ({ error, event, message, status }) => { + if (dev) { + console.warn({ error, event, message, status }); + } + + try { + await Exceptionless.createException(toError(error ?? message)) + .setProperty('status', String(status)) + .submit(); + } catch { + // never throw + } + + return { errorId: Exceptionless.getLastReferenceId() ?? guid(), message }; +}; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/telemetry/Telemetry.svelte b/src/Exceptionless.Web/ClientApp/src/lib/telemetry/Telemetry.svelte new file mode 100644 index 000000000..7c1dcd505 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/telemetry/Telemetry.svelte @@ -0,0 +1,21 @@ + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/telemetry/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/telemetry/index.ts new file mode 100644 index 000000000..01e0adbb7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/telemetry/index.ts @@ -0,0 +1,2 @@ +export { normalizePath, normalizeRouteId } from './route'; +export { default as Telemetry } from './Telemetry.svelte'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/telemetry/route.ts b/src/Exceptionless.Web/ClientApp/src/lib/telemetry/route.ts new file mode 100644 index 000000000..4eac46621 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/telemetry/route.ts @@ -0,0 +1,44 @@ +const OBJECTID_SEGMENT_REGEX = /^[0-9a-f]{24}$/i; +const UUID_SEGMENT_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const NUMERIC_SEGMENT_REGEX = /^\d+$/; + +export function normalizePath(path: string, basePath = '/next'): string { + let normalized = path; + + if (basePath && normalized.startsWith(basePath)) { + normalized = normalized.slice(basePath.length); + } + + if (!normalized.startsWith('/')) { + normalized = '/' + normalized; + } + + normalized = normalized + .split('/') + .map((segment) => (segment && isIdSegment(segment) ? ':id' : segment)) + .join('/'); + + if (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + + return normalized; +} + +export function normalizeRouteId(routeId: null | string): string { + if (!routeId) { + return '/'; + } + + return ( + routeId + .replace(/\/\([^)]+\)/g, '') + .replace(/\[\[([^\]]+)\]\]/g, ':$1') + .replace(/\[\.\.\.([^\]]+)\]/g, ':$1') + .replace(/\[([^\]]+)\]/g, ':$1') || '/' + ); +} + +function isIdSegment(segment: string): boolean { + return OBJECTID_SEGMENT_REGEX.test(segment) || UUID_SEGMENT_REGEX.test(segment) || NUMERIC_SEGMENT_REGEX.test(segment); +} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index a76a8df24..5ec39e8e0 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -30,6 +30,7 @@ import { invalidateWebhookQueries } from '$features/webhooks/api.svelte'; import { isEntityChangedType, type WebSocketMessageType } from '$features/websockets/models'; import { WebSocketClient } from '$features/websockets/web-socket-client.svelte'; + import { Telemetry } from '$lib/telemetry'; import { useMiddleware } from '@exceptionless/fetchclient'; import { useQueryClient } from '@tanstack/svelte-query'; import { tick } from 'svelte'; @@ -58,7 +59,6 @@ let isOrganizationSwitcherOpen = $state(false); let isUserMenuOpen = $state(false); - // Auto-reset premium page state on navigation so pages don't need cleanup beforeNavigate(() => { premiumPage.current = undefined; }); @@ -533,3 +533,5 @@ {/if} {/if} + +