Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 41 additions & 9 deletions src/Exceptionless.Web/ClientApp/src/hooks.client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ServerInit } from '@sveltejs/kit';
import type { HandleClientError, ServerInit } from '@sveltejs/kit';

import { dev } from '$app/environment';
import { env } from '$env/dynamic/public';
import { normalizePath, sanitizeProperties } from '$lib/telemetry';
import { Exceptionless, toError } from '@exceptionless/browser';

// If the PUBLIC_BASE_URL is set in local storage, we will use that instead of the one from the environment variables.
Expand All @@ -16,14 +18,44 @@ export const init: ServerInit = async () => {
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;
}

// Verbose SDK logging only in dev; production should be silent
if (dev) {
c.settings['@@log:*'] = 'debug';
}

c.useSessions();
});
};

/** @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 }) => {
const errorId = crypto.randomUUID();

if (dev) {
console.warn({ error, event, message, source: 'client error handler', status });
}

try {
const route = event?.url ? normalizePath(event.url.pathname) : undefined;
const safeProps = sanitizeProperties({
...(route && { route }),
errorId,
status: String(status)
});

const builder = Exceptionless.createException(toError(error ?? message));
for (const [key, value] of Object.entries(safeProps)) {
builder.setProperty(key, value);
}

await builder.submit();
} catch {
// handleError must never throw
}

return { errorId, message };
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { env } from '$env/dynamic/public';
import { getIntercomTokenSessionKey, intercomTokenRefreshIntervalMs } from '$features/intercom/config';
import { organization } from '$features/organizations/context.svelte';
import { trackBigAction } from '$lib/telemetry';
import { ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
import { hide as hideIntercom, shutdown as shutdownIntercom } from '@intercom/messenger-js-sdk';
import { createQuery, type QueryClient } from '@tanstack/svelte-query';
Expand Down Expand Up @@ -44,7 +45,13 @@ export async function changePassword(currentPassword: string | undefined, newPas

export async function forgotPassword(email: string) {
const client = useFetchClient();
return await client.get(`auth/forgot-password/${email}`);
const response = await client.get(`auth/forgot-password/${email}`);

if (response.ok) {
trackBigAction('auth.password-reset.request', { outcome: 'success' });
}

return response;
}

export function getIntercomTokenQuery() {
Expand Down Expand Up @@ -88,6 +95,7 @@ export async function login(email: string, password: string) {

if (response.ok && response.data?.token) {
accessToken.current = response.data.token;
trackBigAction('auth.login', { outcome: 'success' });
} else if (response.status === 401) {
response.problem.setErrorMessage('Invalid email or password');
}
Expand All @@ -112,6 +120,7 @@ export async function logout(queryClient?: QueryClient, client = useFetchClient(
}

accessToken.current = null;
trackBigAction('auth.logout', { outcome: 'success' });
}

export async function resetPassword(passwordResetToken: string, password: string) {
Expand Down Expand Up @@ -147,6 +156,7 @@ export async function signup(name: string, email: string, password: string, invi

if (response.ok && response.data?.token) {
accessToken.current = response.data.token;
trackBigAction('auth.signup', { outcome: 'success' });
} else if (response.status === 401) {
response.problem.setErrorMessage('Invalid email or password');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { BillingPlan, ChangePlanRequest, ChangePlanResult } from '$lib/gene
import type { QueryClient } from '@tanstack/svelte-query';

import { accessToken } from '$features/auth/index.svelte';
import { trackBigAction } from '$lib/telemetry';
import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';

Expand Down Expand Up @@ -168,6 +169,7 @@ export function addOrganizationUser(request: AddOrganizationUserRequest) {
return response.data!;
},
onSuccess: () => {
trackBigAction('organization.invite-user', { outcome: 'success' });
queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.organizationId, undefined) });
// Also invalidate the user list for this org (different query namespace from Organization)
queryClient.invalidateQueries({ queryKey: ['User', 'organization', request.route.organizationId] });
Expand Down Expand Up @@ -211,6 +213,7 @@ export function deleteOrganization(request: DeleteOrganizationRequest) {
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id, undefined) }));
},
onSuccess: () => {
trackBigAction('organization.delete', { outcome: 'success' });
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id, undefined) }));
}
}));
Expand Down Expand Up @@ -418,6 +421,7 @@ export function postOrganization() {
},
mutationKey: queryKeys.postOrganization(),
onSuccess: (organization: ViewOrganization) => {
trackBigAction('organization.create', { outcome: 'success' });
queryClient.setQueryData(queryKeys.id(organization.id, 'stats'), organization);
queryClient.setQueryData(queryKeys.id(organization.id, undefined), organization);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { StringValueFromBody, WorkInProgressResult } from '$features/shared
import type { WebSocketMessageValue } from '$features/websockets/models';

import { accessToken } from '$features/auth/index.svelte';
import { trackBigAction } from '$lib/telemetry';
import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query';

Expand Down Expand Up @@ -212,6 +213,7 @@ export function deleteProject(request: DeleteProjectRequest) {
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
},
onSuccess: () => {
trackBigAction('project.delete', { outcome: 'success' });
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
}
}));
Expand Down Expand Up @@ -424,6 +426,7 @@ export function postProject() {
},
mutationKey: queryKeys.postProject(),
onSuccess: () => {
trackBigAction('project.create', { outcome: 'success' });
queryClient.invalidateQueries({ queryKey: queryKeys.type });
}
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { WebSocketMessageValue } from '$features/websockets/models';

import { accessToken } from '$features/auth/index.svelte';
import { ChangeType } from '$features/websockets/models';
import { trackBigAction } from '$lib/telemetry';
import { type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
import { createMutation, createQuery, type QueryClient, useQueryClient } from '@tanstack/svelte-query';

Expand Down Expand Up @@ -80,6 +81,7 @@ export function deleteSavedView(request: { route: { organizationId: string | und
void queryClient.invalidateQueries({ queryKey: queryKeys.type });
},
onSuccess: (_data: void, savedView: SavedView) => {
trackBigAction('saved-view.delete', { outcome: 'success' });
removeSavedViewFromCaches(queryClient, savedView, request.route.organizationId);
}
}));
Expand Down Expand Up @@ -132,6 +134,7 @@ export function patchSavedView(request: { route: { id: string | undefined } }) {
return response.data!;
},
onSuccess: (savedView: SavedView) => {
trackBigAction('saved-view.update', { outcome: 'success' });
syncSavedViewCaches(queryClient, savedView);
}
}));
Expand All @@ -148,6 +151,7 @@ export function postSavedView(request: { route: { organizationId: string | undef
return response.data!;
},
onSuccess: (savedView: SavedView) => {
trackBigAction('saved-view.create', { outcome: 'success' });
syncSavedViewCaches(queryClient, savedView, request.route.organizationId);
}
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { WebSocketMessageValue } from '$features/websockets/models';
import type { WorkInProgressResult } from '$shared/models';

import { accessToken } from '$features/auth/index.svelte';
import { trackBigAction } from '$lib/telemetry';
import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query';

Expand Down Expand Up @@ -133,6 +134,7 @@ export function deleteStack(request: DeleteStackRequest) {
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
},
onSuccess: () => {
trackBigAction('stack.delete', { outcome: 'success' });
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
}
}));
Expand Down Expand Up @@ -190,6 +192,7 @@ export function postAddLink(request: PostAddLinkRequest) {
queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id) });
},
onSuccess: () => {
trackBigAction('stack.add-reference', { outcome: 'success' });
queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id) });
}
}));
Expand Down Expand Up @@ -244,6 +247,7 @@ export function postMarkFixed(request: PostMarkFixedRequest) {
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
},
onSuccess: () => {
trackBigAction('stack.mark-fixed', { outcome: 'success' });
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
}
}));
Expand All @@ -262,6 +266,7 @@ export function postMarkSnoozed(request: PostMarkSnoozedRequest) {
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
},
onSuccess: () => {
trackBigAction('stack.snooze', { outcome: 'success' });
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
}
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { WebSocketMessageValue } from '$features/websockets/models';

import { accessToken } from '$features/auth/index.svelte';
import { DEFAULT_LIMIT } from '$features/shared/api/api.svelte';
import { trackBigAction } from '$lib/telemetry';
import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query';

Expand Down Expand Up @@ -90,6 +91,7 @@ export function deleteToken(request: DeleteTokenRequest) {
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
},
onSuccess: () => {
trackBigAction('token.delete', { outcome: 'success' });
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
}
}));
Expand Down Expand Up @@ -174,6 +176,7 @@ export function postProjectToken(request: PostProjectTokenRequest) {
},
mutationKey: queryKeys.postProjectToken(request.route.projectId),
onSuccess: (token: ViewToken) => {
trackBigAction('token.create', { outcome: 'success' });
queryClient.invalidateQueries({ queryKey: queryKeys.type });
queryClient.setQueryData(queryKeys.id(token.id), token);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { WebSocketMessageValue } from '$features/websockets/models';

import { accessToken } from '$features/auth/index.svelte';
import { DEFAULT_LIMIT } from '$features/shared/api/api.svelte';
import { trackBigAction } from '$lib/telemetry';
import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query';

Expand Down Expand Up @@ -71,6 +72,7 @@ export function deleteWebhook(request: DeleteWebhookRequest) {
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
},
onSuccess: () => {
trackBigAction('webhook.delete', { outcome: 'success' });
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
}
}));
Expand Down Expand Up @@ -115,6 +117,7 @@ export function postWebhook() {
},
mutationKey: queryKeys.postWebhook(),
onSuccess: (webhook: Webhook) => {
trackBigAction('webhook.create', { outcome: 'success' });
queryClient.invalidateQueries({ queryKey: queryKeys.type });
queryClient.setQueryData(queryKeys.id(webhook.id), webhook);
}
Expand Down
71 changes: 71 additions & 0 deletions src/Exceptionless.Web/ClientApp/src/lib/telemetry/Telemetry.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<!--
Telemetry.svelte — Svelte 5 component for page view and session tracking.
Mounted once in the app layout, outside the authenticated-only block so that
it can observe the userId → undefined transition and end the session on logout.
-->
<script lang="ts">
import { page } from '$app/state';
import { onDestroy } from 'svelte';

import { endSession, setUserIdentity, startSession, trackPageView } from './exceptionless.client';

interface Props {
/** Current user ID (undefined when not authenticated or query still loading) */
userId?: string;
/** Current user display name */
userName?: string;
}

let { userId, userName }: Props = $props();

// ── Page view tracking ────────────────────────────────────────────────────
// Use $effect so we react to SvelteKit navigation changes reactively.
// The lastTrackedPath guard prevents double-fires on non-navigation re-renders.
let lastTrackedPath = $state('');
$effect(() => {
const currentPath = page.url.pathname;
const currentSearch = page.url.searchParams;

if (currentPath !== lastTrackedPath) {
lastTrackedPath = currentPath;
trackPageView(currentPath, currentSearch);
}
});

// ── Session / identity lifecycle ─────────────────────────────────────────
// sessionStorage flag prevents duplicate startSession() calls across HMR
// reloads and component remounts while the user is already authenticated.
const SESSION_KEY = 'ex_session_active';

let lastUserId = $state<string | undefined>(undefined);

$effect(() => {
const currentUserId = userId;
const currentUserName = userName;

if (currentUserId && currentUserId !== lastUserId) {
// User authenticated (or identity changed)
setUserIdentity(currentUserId, currentUserName);

if (!sessionStorage.getItem(SESSION_KEY)) {
sessionStorage.setItem(SESSION_KEY, '1');
startSession();
}
} else if (!currentUserId && lastUserId) {
// User logged out (token cleared while component still mounted)
sessionStorage.removeItem(SESSION_KEY);
endSession();
}

lastUserId = currentUserId;
});

// Safety net: if the component is destroyed while a session is active (e.g.,
// navigating outside the (app) route group), end the session cleanly.
onDestroy(() => {
if (lastUserId && sessionStorage.getItem(SESSION_KEY)) {
sessionStorage.removeItem(SESSION_KEY);
endSession();
}
});
</script>
Loading