Skip to content
Merged
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
14 changes: 14 additions & 0 deletions apps/dashboard/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgres://postgres:my
# The canonical URL.
ORIGIN="http://localhost:5173"

# Analytics & error tracking
# PostHog browser SDK. Requests are reverse-proxied through this app at /internal/phog_in so
# ad blockers don't drop them. PUBLIC_POSTHOG_HOST picks the region (us/eu) and is
# also used as ui_host for the toolbar. Leave the key empty to disable PostHog.
PUBLIC_POSTHOG_KEY=""
PUBLIC_POSTHOG_HOST="https://us.posthog.com"
# Sentry DSN (browser + worker). Browser events are tunneled through /internal/sentry_in.
# Leave empty to disable Sentry.
PUBLIC_SENTRY_DSN=""
# Only needed to upload source maps at build time.
SENTRY_ORG=""
SENTRY_PROJECT=""
SENTRY_AUTH_TOKEN=""

# Better Auth
# For production use 32+ characters generated with high entropy
# https://www.better-auth.com/docs/installation
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,13 @@
"@better-svelte-email/components": "^2.1.1",
"@better-svelte-email/server": "^2.1.1",
"@plausible-analytics/tracker": "^0.4.5",
"@sentry/sveltekit": "10.75.0",
"arktype": "^2.2.3",
"autumn-js": "1.2.33",
"ip-address": "^10.2.0",
"ky": "^2.0.2",
"posthog-js": "1.434.2",
"posthog-node": "^5.52.4",
"qrcode": "^1.5.4",
"ulid": "^3.0.2",
"undici": "^8.9.0",
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ declare global {
ctx: ExecutionContext;
env: {
ORIGIN: string;
PUBLIC_POSTHOG_KEY?: string;
PUBLIC_POSTHOG_HOST?: string;
PUBLIC_SENTRY_DSN?: string;
BETTER_AUTH_SECRET: string;
VYOS_API_URL?: string;
VYOS_API_KEY?: string;
Expand Down
21 changes: 21 additions & 0 deletions apps/dashboard/src/hooks.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { handleErrorWithSentry } from '@sentry/sveltekit';
import * as Sentry from '@sentry/sveltekit';
import type { HandleClientError } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { env } from '$env/dynamic/public';
import { captureClientException } from '$lib/analytics/posthog';

if (env.PUBLIC_SENTRY_DSN) {
Sentry.init({
dsn: env.PUBLIC_SENTRY_DSN,
tunnel: '/internal/sentry_in',
environment: dev ? 'development' : 'production',
sendDefaultPii: false
});
}

const forwardToPostHog: HandleClientError = ({ error }) => {
captureClientException(error);
};

export const handleError = handleErrorWithSentry(forwardToPostHog);
37 changes: 34 additions & 3 deletions apps/dashboard/src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { redirect, type Handle } from '@sveltejs/kit';
import { building } from '$app/environment';
import { redirect, type Handle, type HandleServerError } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { handleErrorWithSentry, initCloudflareSentryHandle, sentryHandle } from '@sentry/sveltekit';
import { building, dev } from '$app/environment';
import { env as publicEnv } from '$env/dynamic/public';
import { handlePostHogProxy } from '$lib/server/posthog-proxy';
import { captureServerException } from '$lib/server/posthog';
import { getCachedAuthSession, hasAuthSessionCookie } from '$lib/server/auth-lite';
import { closeRequestDb } from '$lib/server/db';
import { instrument, timingLog } from '$lib/server/observability';
Expand All @@ -18,6 +23,7 @@ const publicRoutes = [
'/reset-password',
'/accept-invitation',
'/api/',
'/internal/',
'/_app/remote/'
];
const authPages = ['/login', '/register', '/signup', '/forgot-password'];
Expand Down Expand Up @@ -163,4 +169,29 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => {
}
};

export const handle: Handle = handleBetterAuth;
let sentryRequestHandle: Handle | undefined;

const handleSentryInit: Handle = (input) => {
if (!publicEnv.PUBLIC_SENTRY_DSN) return input.resolve(input.event);

sentryRequestHandle ??= initCloudflareSentryHandle({
dsn: publicEnv.PUBLIC_SENTRY_DSN,
environment: dev ? 'development' : 'production',
sendDefaultPii: false
});
return sentryRequestHandle(input);
};

export const handle: Handle = sequence(
handleSentryInit,
sentryHandle({ injectFetchProxyScript: false }),
handlePostHogProxy,
handleBetterAuth
);

const logServerError: HandleServerError = ({ error, event }) => {
console.error('Unhandled server error', { pathname: event.url.pathname, error });
captureServerException(error, event);
};

export const handleError = handleErrorWithSentry(logServerError);
38 changes: 38 additions & 0 deletions apps/dashboard/src/lib/analytics/posthog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { browser, dev } from '$app/environment';
import { env } from '$env/dynamic/public';
import posthog from 'posthog-js';

export const posthogProxyPath = '/internal/phog_in';

let initialized = false;

export function initPostHog() {
if (!browser || initialized) return;

const token = env.PUBLIC_POSTHOG_KEY;
if (!token) return;

initialized = true;
posthog.init(token, {
api_host: posthogProxyPath,
ui_host: env.PUBLIC_POSTHOG_HOST || 'https://us.posthog.com',
capture_pageview: 'history_change',
capture_pageleave: 'if_capture_pageview',
capture_exceptions: {
capture_unhandled_errors: true,
capture_unhandled_rejections: true,
capture_console_errors: true
},
person_profiles: 'identified_only',
logs: {
captureConsoleLogs: true,
serviceName: 'stack-dashboard',
environment: dev ? 'development' : 'production'
}
});
}

export function captureClientException(error: unknown) {
if (!initialized) return;
posthog.captureException(error);
}
12 changes: 12 additions & 0 deletions apps/dashboard/src/lib/remote/admin-projects.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
accessibilityFixtureEnabled,
accessibilityFixtureAdminProjects
} from '$lib/server/accessibility-fixtures';
import { captureServerEvent } from '$lib/server/posthog';

export type AdminProjectBillingStatus = 'configured' | 'past_due' | 'suspended' | 'none';

Expand Down Expand Up @@ -276,6 +277,12 @@ export const setProjectDisabled = command(setDisabledParams, async (params) => {
.set({ disabled: params.disabled })
.where(eq(organization.id, params.projectId));

captureServerEvent(
'admin_project_disabled_changed',
{ disabled: params.disabled },
{ projectId: params.projectId }
);

return { projectId: params.projectId, disabled: params.disabled };
});

Expand Down Expand Up @@ -322,6 +329,11 @@ export const deleteProjectWithVerification = command(deleteProjectParams, async

await consumeAdminVerification(db, adminUser.id, params.projectId, params.method, params.code);
await softDeleteOrganizationResources(db, params.projectId);
captureServerEvent(
'admin_project_deleted',
{ verification_method: params.method },
{ projectId: params.projectId }
);

return { projectId: params.projectId, name: target.name };
});
20 changes: 20 additions & 0 deletions apps/dashboard/src/lib/remote/admin-users.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
accessibilityFixtureEnabled,
accessibilityFixtureAdminUsers
} from '$lib/server/accessibility-fixtures';
import { captureServerEvent } from '$lib/server/posthog';

export type UserSession = {
id: string;
Expand Down Expand Up @@ -249,6 +250,11 @@ export const setUserDisabled = command(setDisabledParams, async (params) => {
.set({ banned: params.disabled, banReason: params.disabled ? null : target.banReason })
.where(eq(user.id, params.userId));

captureServerEvent('admin_user_disabled_changed', {
target_user_id: params.userId,
disabled: params.disabled
});

return { userId: params.userId, disabled: params.disabled };
});

Expand All @@ -263,6 +269,11 @@ export const setUserBillingExempt = command(setBillingExemptParams, async (param
.set({ billingExempt: params.billingExempt })
.where(eq(user.id, params.userId));

captureServerEvent('admin_user_billing_exempt_changed', {
target_user_id: params.userId,
billing_exempt: params.billingExempt
});

return { userId: params.userId, billingExempt: params.billingExempt };
});

Expand Down Expand Up @@ -291,6 +302,11 @@ export const setUserRole = command(setRoleParams, async (params) => {
.set({ role: params.role, isAdmin: hasAdminRole(params.role) })
.where(eq(user.id, params.userId));

captureServerEvent('admin_user_role_changed', {
target_user_id: params.userId,
role: params.role
});

return { userId: params.userId, role: params.role, isAdmin: hasAdminRole(params.role) };
});

Expand Down Expand Up @@ -327,6 +343,10 @@ export const deleteUserWithVerification = command(deleteUserParams, async (param

await consumeAdminVerification(db, adminUserId, params.userId, params.method, params.code);
await deleteUserData(db, params.userId);
captureServerEvent('admin_user_deleted', {
target_user_id: params.userId,
verification_method: params.method
});

return { userId: params.userId, email: target.email };
});
Expand Down
7 changes: 7 additions & 0 deletions apps/dashboard/src/lib/remote/admin-vms.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
accessibilityFixtureEnabled,
accessibilityFixtureAdminVms
} from '$lib/server/accessibility-fixtures';
import { captureServerEvent } from '$lib/server/posthog';

export type AdminVm = {
id: string;
Expand Down Expand Up @@ -161,6 +162,11 @@ async function adminPowerAction(
if (row.status === 'deleting') error(409, `VM "${row.name}" is being deleted`);

await getBackend(row.backend)[action](row.id, row.proxmoxId ?? undefined);
captureServerEvent(
'admin_vm_power_action',
{ vm_id: row.id, action },
{ projectId: row.ownerProjectId }
);
}

export const adminStartVm = command(powerParams, async (p) => adminPowerAction(p.vmId, 'startVm'));
Expand All @@ -178,4 +184,5 @@ export const adminDeleteVm = command(powerParams, async (params) => {
if (!row.active) return;

await queueVmDeletion(db, row);
captureServerEvent('admin_vm_deleted', { vm_id: row.id }, { projectId: row.ownerProjectId });
});
4 changes: 4 additions & 0 deletions apps/dashboard/src/lib/remote/api-tokens.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { type } from 'arktype';
import { eq, and } from 'drizzle-orm';
import { initDrizzle } from '$lib/server/db';
import { apiTokens } from '$lib/server/db/schema';
import { captureServerEvent } from '$lib/server/posthog';

type ListResult = {
id: string;
Expand Down Expand Up @@ -64,6 +65,8 @@ export const createApiToken = command(createParams, async (params) => {
})
.returning();

captureServerEvent('api_token_created', { token_id: inserted.id });

return { id: inserted.id, token: plainToken };
});

Expand All @@ -83,4 +86,5 @@ export const revokeApiToken = command(revokeParams, async (params) => {
await db
.delete(apiTokens)
.where(and(eq(apiTokens.id, params.tokenId), eq(apiTokens.userId, event.locals.user.id)));
captureServerEvent('api_token_revoked', { token_id: params.tokenId });
});
12 changes: 12 additions & 0 deletions apps/dashboard/src/lib/remote/billing.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
accessibilityFixtureEnabled,
accessibilityFixtureBillingOverview
} from '$lib/server/accessibility-fixtures';
import { captureServerEvent } from '$lib/server/posthog';

const projectParams = type({ projectId: 'string' });
const setupParams = type({ projectId: 'string', returnTo: 'string?', discountCode: 'string?' });
Expand Down Expand Up @@ -52,6 +53,7 @@ export const openBillingPortal = command(projectParams, async (params) => {
params.projectId,
`${event.url.origin}/projects/${params.projectId}/billing`
);
captureServerEvent('billing_portal_opened', {}, { projectId: params.projectId });

return { url };
});
Expand All @@ -70,6 +72,11 @@ export const purchaseCredits = command(purchaseCreditsParams, async (params) =>
await requireProjectAccess(db, event.locals.user.id, params.projectId, 'owner');

const url = await purchaseProjectCredits(params.projectId, params.credits);
captureServerEvent(
'credit_purchase_started',
{ credits: params.credits },
{ projectId: params.projectId }
);

return { url };
});
Expand All @@ -89,6 +96,11 @@ export const setupProjectBillingPayment = command(setupParams, async (params) =>
const promoParam = discountCode ? `&billing_promo=${encodeURIComponent(discountCode)}` : '';
const successUrl = `${event.url.origin}${returnPath}${separator}billing_setup=complete${promoParam}`;
const url = await setupProjectPayment(params.projectId, successUrl);
captureServerEvent(
'billing_setup_started',
{ has_discount_code: Boolean(discountCode) },
{ projectId: params.projectId }
);

return { url };
});
3 changes: 3 additions & 0 deletions apps/dashboard/src/lib/remote/email-change.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { initAuth } from '$lib/server/auth';
import { initDrizzle } from '$lib/server/db';
import { user, verification } from '$lib/server/db/schema';
import { ulid } from '$lib/server/id';
import { captureServerEvent } from '$lib/server/posthog';

const EMAIL_CHANGE_TTL_MS = 60 * 60 * 1000;

Expand Down Expand Up @@ -68,5 +69,7 @@ export const requestEmailChange = command(emailChangeParams, async (params) => {
expiresAt: new Date(Date.now() + EMAIL_CHANGE_TTL_MS)
});

captureServerEvent('email_change_requested');

return { email: newEmail };
});
10 changes: 9 additions & 1 deletion apps/dashboard/src/lib/remote/networking.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { requireProjectAccess } from '$lib/server/auth-context';
import { isBunnyConfigured } from '$lib/server/bunny';
import { setPtrRecord } from '$lib/server/ptr-records';
import type { PermissionLevel } from '$lib/auth/organization-permissions';
import { captureServerEvent } from '$lib/server/posthog';

async function requireVmAccess(vmId: string, level?: PermissionLevel) {
const event = getRequestEvent();
Expand Down Expand Up @@ -75,5 +76,12 @@ export const setVmPtrRecord = command(setPtrParams, async (params) => {
if (!address) error(400, 'An IP address inside the subnet is required');

const { ipamPrefix, ...rest } = allocation;
return setPtrRecord(db, { ...rest, sourcePrefix: ipamPrefix }, address, params.value);
const result = await setPtrRecord(
db,
{ ...rest, sourcePrefix: ipamPrefix },
address,
params.value
);
captureServerEvent('vm_ptr_record_set', { vm_id: params.vmId });
return result;
});
Loading
Loading