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}
+
+