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
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"cSpell.words": [
"acacode",
"autodocs",
"axllent",
"azurestorage",
"Backdoors",
"Bisectable",
Expand Down Expand Up @@ -45,6 +46,7 @@
"legos",
"lucene",
"lucide",
"mailpit",
"mypass",
"nameof",
"navigatetofirstpage",
Expand Down
4 changes: 3 additions & 1 deletion src/Exceptionless.Web/ClientApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
"test": "npm run test:unit -- --run && npm run test:e2e",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"upgrade": "ncu -i"
"update": "ncu -i",
"update:shadcn": "npx shadcn-svelte@latest update",
"update:skills": "npx skills update"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@

import type { PersistentEvent } from '../models/index';

import { getSessionId } from '../utils';
import Environment from './views/environment.svelte';
import Error from './views/error.svelte';
import ExtendedData from './views/extended-data.svelte';
import Overview from './views/overview.svelte';
import PromotedExtendedData from './views/promoted-extended-data.svelte';
import Request from './views/request.svelte';
import SessionEvents from './views/session-events.svelte';
import TraceLog from './views/trace-log.svelte';

interface Props {
Expand All @@ -37,7 +39,7 @@
return [];
}

const tabs = ['Overview'];
const tabs: TabType[] = ['Overview'];
if (hasErrorOrSimpleError(event)) {
tabs.push('Exception');
}
Expand All @@ -54,6 +56,10 @@
tabs.push('Trace Log');
}

if (getSessionId(event)) {
tabs.push('Session Events');
}

if (!project) {
return tabs;
}
Expand Down Expand Up @@ -168,6 +174,8 @@
<Request {filterChanged} event={eventQuery.data}></Request>
{:else if tab === 'Trace Log'}
<TraceLog logs={eventQuery.data.data?.['@trace']}></TraceLog>
{:else if tab === 'Session Events'}
<SessionEvents event={eventQuery.data}></SessionEvents>
Comment on lines 176 to +178
{:else if tab === 'Extended Data'}
<ExtendedData event={eventQuery.data} project={projectQuery.data} promoted={onPromoted}></ExtendedData>
{:else}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script lang="ts">
import { Badge } from '$comp/ui/badge';
import { getLogLevel, type LogLevel } from '$features/events/models/event-data';
import { type LogLevel } from '$features/events/models/event-data';

import { getLogLevel } from '../utils';

interface Props {
level?: LogLevel;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
getStackTrace,
hasErrorOrSimpleError
} from '$features/events/persistent-event';
import { getSessionStartDuration } from '$features/events/utils';
import ExternalLink from '@lucide/svelte/icons/external-link';
import Filter from '@lucide/svelte/icons/filter';
import Email from '@lucide/svelte/icons/mail';
Expand Down Expand Up @@ -66,22 +67,6 @@
let requestUrl = $derived(getRequestInfoUrl(event));
let requestUrlPath = $derived(getRequestInfoPath(event));
let version = $derived(event.data?.['@version']);

function getSessionStartDuration(event: PersistentEvent): Date | number | string | undefined {
if (event.data?.sessionend) {
if (event.value) {
return event.value * 1000;
}

if (event.date) {
return new Date(event.data.sessionend).getTime() - new Date(event.date).getTime();
}

throw new Error('Completed session start event has no value or date');
}

return event.date;
}
</script>

<Table.Root>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<script lang="ts">
import type { PersistentEvent } from '$features/events/models';

import { resolve } from '$app/paths';
import Duration from '$comp/formatters/duration.svelte';
import TimeAgo from '$comp/formatters/time-ago.svelte';
import Live from '$comp/live.svelte';
import { A } from '$comp/typography';
import * as Alert from '$comp/ui/alert';
import { Skeleton } from '$comp/ui/skeleton';
import * as Table from '$comp/ui/table';
import { getSessionStartDuration } from '$features/events/utils';
import { getSessionId } from '$features/events/utils/index';
import { usePremiumFeature } from '$features/organizations/hooks/use-premium-feature.svelte';
import { getSessionEventsQuery } from '$features/sessions/api.svelte';
import InfoIcon from '@lucide/svelte/icons/info';

interface Props {
event: PersistentEvent;
hasPremiumFeatures?: boolean;
time?: string;
}

let { event, hasPremiumFeatures = true, time }: Props = $props();

const sessionId = $derived(getSessionId(event));
const isSessionStart = $derived(event.type === 'session');

const userInfo = $derived(event.data?.['@user']);
const userIdentity = $derived(userInfo?.identity);
const userName = $derived(userInfo?.name);

const sessionEventsQuery = $derived(() =>
getSessionEventsQuery({
params: {
filter: '-type:heartbeat',
limit: 10,
...(time ? { time } : {})
},
route: {
sessionId: hasPremiumFeatures ? sessionId : undefined
}
})
);

$effect(() => {
if (!hasPremiumFeatures) usePremiumFeature('Sessions');
});
</script>

{#if !hasPremiumFeatures}
<Alert.Root variant="destructive" class="mb-4">
<InfoIcon class="size-4" />
<Alert.Title>Premium Feature</Alert.Title>
<Alert.Description>Sessions are a premium feature. Upgrade your plan to view session events.</Alert.Description>
</Alert.Root>
{/if}

<div class:opacity-60={!hasPremiumFeatures}>
{#if isSessionStart}
<Table.Root class="mb-4">
<Table.Body>
<Table.Row>
<Table.Head class="w-40 font-semibold whitespace-nowrap">Duration</Table.Head>
<Table.Cell class="w-4 pr-0"></Table.Cell>
<Table.Cell>
<Live live={!event.data?.sessionend} liveTitle="Online" notLiveTitle="Ended" />
<Duration value={getSessionStartDuration(event)} />
{#if event.data?.sessionend}
(ended <TimeAgo value={event.data.sessionend} />)
{/if}
<Live live={!event.data?.sessionend} liveTitle="Online" notLiveTitle="Ended" />
<Duration value={getSessionStartDuration(event)} />
{#if event.data?.sessionend}
(ended <TimeAgo value={event.data.sessionend} />)
{/if}
Comment on lines +72 to +76
</Table.Cell>
</Table.Row>
{#if userIdentity}
<Table.Row>
<Table.Head class="w-40 font-semibold whitespace-nowrap">User Identity</Table.Head>
<Table.Cell class="w-4 pr-0"></Table.Cell>
<Table.Cell>{userIdentity}</Table.Cell>
</Table.Row>
{/if}
{#if userName}
<Table.Row>
<Table.Head class="w-40 font-semibold whitespace-nowrap">User Name</Table.Head>
<Table.Cell class="w-4 pr-0"></Table.Cell>
<Table.Cell>{userName}</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
{/if}

<h3 class="mb-2 text-lg font-semibold">Session Events</h3>

{#if sessionEventsQuery().isPending}
<div class="space-y-2">
{#each Array.from({ length: 5 }, (_, i) => i) as i (i)}
<Skeleton class="h-10 w-full" />
{/each}
</div>
{:else if sessionEventsQuery().isError}
<Alert.Root variant="destructive">
<Alert.Title>Error loading session events</Alert.Title>
<Alert.Description>{sessionEventsQuery().error?.message ?? 'Unknown error'}</Alert.Description>
</Alert.Root>
{:else if (sessionEventsQuery().data ?? []).length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Summary</Table.Head>
<Table.Head class="w-32">Session Time</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each sessionEventsQuery().data ?? [] as sessionEvent (sessionEvent.id)}
<Table.Row class="hover:bg-muted/50 cursor-pointer">
<Table.Cell>
<A href={resolve('/(app)/event/[eventId]', { eventId: sessionEvent.id })}>
{sessionEvent.id}
</A>
</Table.Cell>
<Table.Cell>
<TimeAgo value={sessionEvent.date} />
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{:else}
<p class="text-muted-foreground">No session events found.</p>
{/if}
</div>
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { logLevels } from '../options';

export interface EnvironmentInfo {
architecture?: string;
available_physical_memory?: number;
Expand Down Expand Up @@ -129,36 +127,3 @@ export interface UserInfo {
identity?: string;
name?: string;
}

// TODO: Move to a helper.
export function getLogLevel(level?: LogLevel | null): LogLevel | null {
switch (level?.toLowerCase().trim()) {
case '0':
case 'false':
case 'no':
case 'off':
return 'off';
case '1':
case 'trace':
case 'true':
case 'yes':
return 'trace';
case 'debug':
return 'debug';
case 'error':
return 'error';
case 'fatal':
return 'fatal';
case 'info':
return 'info';
case 'warn':
return 'warn';
default:
return level ?? null;
}
}

export function getLogLevelDisplayName(level?: LogLevel | null): LogLevel | null {
const resolvedLevel = getLogLevel(level);
return logLevels.find((l) => l.value === resolvedLevel)?.label ?? level ?? null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { PersistentEvent } from '../models';
import type { LogLevel } from '../models/event-data';

import { logLevels } from '../options';

export function getLogLevel(level?: LogLevel | null): LogLevel | null {
switch (level?.toLowerCase().trim()) {
case '0':
case 'false':
case 'no':
case 'off':
return 'off';
case '1':
case 'trace':
case 'true':
case 'yes':
return 'trace';
case 'debug':
return 'debug';
case 'error':
return 'error';
case 'fatal':
return 'fatal';
case 'info':
return 'info';
case 'warn':
return 'warn';
default:
return level ?? null;
}
}

export function getLogLevelDisplayName(level?: LogLevel | null): LogLevel | null {
const resolvedLevel = getLogLevel(level);
return logLevels.find((l) => l.value === resolvedLevel)?.label ?? level ?? null;
}

/**
* Determine session ID from event.
* For session start events, use reference_id
* For other events, check @ref:session in data
* @param event
* @returns Session ID or undefined if not found
*/
export function getSessionId(event?: null | PersistentEvent): string | undefined {
if (!event) {
return undefined;
}

// For session start events, use reference_id
if (event.type === 'session') {
return event.reference_id ?? undefined;
}

// For other events, check @ref:session in data
return event.data?.['@ref:session'] as string | undefined;
}

/**
* Returns the session duration in milliseconds for a given event.
* If the session has ended, uses the event value or the difference between sessionend and start.
* If the session is active, returns the duration from start to now.
*/
export function getSessionStartDuration(event: PersistentEvent): number {
if (event.data?.sessionend) {
if (event.value) {
return event.value * 1000;
}
if (event.date) {
return new Date(event.data.sessionend).getTime() - new Date(event.date).getTime();
}
return 0;
}
// If session is active, duration is from start to now
return Date.now() - new Date(event.date).getTime();
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<AlertDialog.Header>
<AlertDialog.Title>Leave Organization</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to leave "<span class="inline-block max-w-[200px] truncate align-bottom" title={name}>{name}</span>"?
Are you sure you want to leave "<span class="inline-block max-w-50 truncate align-bottom" title={name}>{name}</span>"?
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<AlertDialog.Header>
<AlertDialog.Title>Delete Organization</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete "<span class="inline-block max-w-[200px] truncate align-bottom" title={name}>{name}</span>"?
Are you sure you want to delete "<span class="inline-block max-w-50 truncate align-bottom" title={name}>{name}</span>"?
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
Expand Down
Loading
Loading