diff --git a/bun.lockb b/bun.lockb
index fba2c48..ad164f4 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/src/app/(bluerpint-list)/BlueprintList.tsx b/src/app/(bluerpint-list)/BlueprintList.tsx
index 829e478..5d940f3 100644
--- a/src/app/(bluerpint-list)/BlueprintList.tsx
+++ b/src/app/(bluerpint-list)/BlueprintList.tsx
@@ -8,6 +8,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import Loader from '@/components/ui/loader';
import { useAuthStore } from '@/lib/stores/useAuthStore';
import { toast } from 'react-toastify';
+import { getErrorMessage } from '@/lib/errors';
const PAGINATION_LIMIT = 20;
@@ -269,7 +270,7 @@ export default function BlueprintList({ search, filters, sort }: BlueprintListPr
{isLoading ? (
) : error ? (
-
Error loading more blueprints: {error.message}
+ Error loading more blueprints: {getErrorMessage(error)}
) : !hasMore && blueprints.length > 0 ? (
No more blueprints to load
) : blueprints.length === 0 && !isLoading ? (
diff --git a/src/app/[id]/ConnectEmails.tsx b/src/app/[id]/ConnectEmails.tsx
index 159d039..44bfea6 100644
--- a/src/app/[id]/ConnectEmails.tsx
+++ b/src/app/[id]/ConnectEmails.tsx
@@ -7,6 +7,7 @@ import useGoogleAuth from '../hooks/useGoogleAuth';
import { toast } from 'react-toastify';
import { findOrCreateDSP } from '../utils';
import { useEmailCacheStore } from '@/lib/stores/useEmailCacheStore';
+import { getErrorMessage } from '@/lib/errors';
const ConnectEmails = () => {
const { setFile, setStep, setEmlUploadMode } = useProofStore();
@@ -105,7 +106,7 @@ const ConnectEmails = () => {
emailCacheStore.clearCache();
setEmlUploadMode('upload');
})
- .catch((err) => toast.error(err.message ?? err));
+ .catch((err) => toast.error(getErrorMessage(err)));
}
}}
id="drag-and-drop-emails"
@@ -155,7 +156,7 @@ const ConnectEmails = () => {
setEmlUploadMode('upload');
})
.catch((err) => {
- toast.error(err.message ?? err);
+ toast.error(getErrorMessage(err));
});
}
}}
diff --git a/src/app/[id]/error.tsx b/src/app/[id]/error.tsx
new file mode 100644
index 0000000..265b62d
--- /dev/null
+++ b/src/app/[id]/error.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { useEffect } from 'react';
+
+export default function BlueprintError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ console.error('Blueprint page error:', error);
+ }, [error]);
+
+ return (
+
+
+
Failed to load blueprint
+
+ There was a problem loading this blueprint. It may not exist or there might be a temporary issue.
+
+ {error.digest && (
+
Error ID: {error.digest}
+ )}
+
+
+
+ );
+}
diff --git a/src/app/[id]/page.tsx b/src/app/[id]/page.tsx
index d309c28..96fcaf1 100644
--- a/src/app/[id]/page.tsx
+++ b/src/app/[id]/page.tsx
@@ -1,5 +1,5 @@
'use client';
-import { use, useEffect } from 'react';
+import { use, useEffect, useRef } from 'react';
import Image from 'next/image';
import { getStatusColorLight, getStatusIcon, getStatusName } from '../utils';
import { Button } from '@/components/ui/button';
@@ -19,6 +19,8 @@ import { Blueprint, Status, ZkFramework } from '@zk-email/sdk';
import { toast } from 'react-toastify';
import { useAuthStore } from '@/lib/stores/useAuthStore';
import { initNoirWasm } from '@/lib/utils';
+import { captureEvent, getClientInfo } from '@/lib/analytics';
+import { isAuthError, isNotFoundError, getErrorMessage } from '@/lib/errors';
const Pattern = ({ params }: { params: Promise<{ id: string }> }) => {
const { id } = use(params);
@@ -42,6 +44,9 @@ const Pattern = ({ params }: { params: Promise<{ id: string }> }) => {
: ['Connect emails', 'Select emails', 'View and verify'];
let step = searchParams.get('step') || '0';
+ const pageStartRef = useRef(performance.now());
+ const stepStartRef = useRef(performance.now());
+ const lastStepRef = useRef(step);
useEffect(() => {
reset();
@@ -59,16 +64,52 @@ const Pattern = ({ params }: { params: Promise<{ id: string }> }) => {
}
})
.catch((err) => {
- if (err.toString().includes('401')) {
+ if (isAuthError(err)) {
clearAuth();
return;
}
console.error(`Failed to get blueprint with id ${id}: `, err);
- toast.error('This blueprint could not be found');
+ toast.error(isNotFoundError(err) ? 'This blueprint could not be found' : getErrorMessage(err));
router.push('/');
});
}, []);
+ useEffect(() => {
+ const now = performance.now();
+ if (lastStepRef.current !== step) {
+ captureEvent('proof_wizard_step_time_spent', {
+ step: lastStepRef.current,
+ step_label: steps[parseInt(lastStepRef.current)],
+ duration_ms: Math.round(now - stepStartRef.current),
+ blueprint_id: id,
+ has_external_inputs: !!blueprint?.props.externalInputs?.length,
+ ...getClientInfo(),
+ });
+ lastStepRef.current = step;
+ stepStartRef.current = now;
+ }
+ }, [step, id, steps, blueprint]);
+
+ useEffect(() => {
+ return () => {
+ const now = performance.now();
+ captureEvent('proof_wizard_step_time_spent', {
+ step: lastStepRef.current,
+ step_label: steps[parseInt(lastStepRef.current)],
+ duration_ms: Math.round(now - stepStartRef.current),
+ blueprint_id: id,
+ has_external_inputs: !!blueprint?.props.externalInputs?.length,
+ ...getClientInfo(),
+ });
+ captureEvent('proof_wizard_page_time_spent', {
+ duration_ms: Math.round(now - pageStartRef.current),
+ blueprint_id: id,
+ has_external_inputs: !!blueprint?.props.externalInputs?.length,
+ ...getClientInfo(),
+ });
+ };
+ }, [id, steps, blueprint]);
+
const onCancelCompilation = async () => {
if (!blueprint) return;
try {
@@ -76,7 +117,7 @@ const Pattern = ({ params }: { params: Promise<{ id: string }> }) => {
router.push(`/create/${id}`);
} catch (err) {
console.error('Failed to cancel blueprint compilation: ', err);
- toast.error('Failed to cancel blueprint compilation');
+ toast.error(`Failed to cancel blueprint compilation: ${getErrorMessage(err)}`);
}
};
diff --git a/src/app/[id]/store.ts b/src/app/[id]/store.ts
index 7cb4516..29086a6 100644
--- a/src/app/[id]/store.ts
+++ b/src/app/[id]/store.ts
@@ -15,6 +15,7 @@ import { useEmlStore } from '@/lib/stores/useEmlStore';
import sdk from '@/lib/sdk';
import { initNoirWasm } from '@/lib/utils';
import { get, set } from 'idb-keyval';
+import { captureEvent, getClientInfo } from '@/lib/analytics';
export type Step = '0' | '1' | '2' | '3';
export type EmlUploadMode = 'upload' | 'connect';
@@ -159,6 +160,20 @@ export const useProofStore = create()(
// Create prover and generate proof
const prover = blueprint.createProver({ isLocal });
+ const startTime =
+ typeof performance !== 'undefined' ? performance.now() : Date.now();
+ captureEvent('proof_generation_started', {
+ is_local: isLocal,
+ zk_framework: blueprint.props.clientZkFramework,
+ client_zk_framework: blueprint.props.clientZkFramework,
+ server_zk_framework: blueprint.props.serverZkFramework,
+ blueprint_id: blueprint.props.id,
+ blueprint_slug: blueprint.props.slug,
+ blueprint_version: blueprint.props.version,
+ external_inputs_count: externalInputs?.length ?? 0,
+ eml_upload_mode: get().emlUploadMode,
+ ...getClientInfo(),
+ });
let proof: Proof;
try {
let options: GenerateProofOptions = {};
@@ -167,9 +182,41 @@ export const useProofStore = create()(
options.noirWasm = noirWasm;
}
proof = await prover.generateProof(file, externalInputs || [], options);
+ const endTime =
+ typeof performance !== 'undefined' ? performance.now() : Date.now();
+ captureEvent('proof_generation_succeeded', {
+ is_local: isLocal,
+ zk_framework: blueprint.props.clientZkFramework,
+ client_zk_framework: blueprint.props.clientZkFramework,
+ server_zk_framework: blueprint.props.serverZkFramework,
+ blueprint_id: blueprint.props.id,
+ blueprint_slug: blueprint.props.slug,
+ blueprint_version: blueprint.props.version,
+ external_inputs_count: externalInputs?.length ?? 0,
+ eml_upload_mode: get().emlUploadMode,
+ duration_ms: Math.round(endTime - startTime),
+ proof_id: proof.props.id,
+ ...getClientInfo(),
+ });
// save proof.props with blueprint.props.id as proof on useProofEmailStore here
} catch (err) {
console.error('Failed to generate a proof request');
+ const endTime =
+ typeof performance !== 'undefined' ? performance.now() : Date.now();
+ captureEvent('proof_generation_failed', {
+ is_local: isLocal,
+ zk_framework: blueprint.props.clientZkFramework,
+ client_zk_framework: blueprint.props.clientZkFramework,
+ server_zk_framework: blueprint.props.serverZkFramework,
+ blueprint_id: blueprint.props.id,
+ blueprint_slug: blueprint.props.slug,
+ blueprint_version: blueprint.props.version,
+ external_inputs_count: externalInputs?.length ?? 0,
+ eml_upload_mode: get().emlUploadMode,
+ duration_ms: Math.round(endTime - startTime),
+ error_message: err instanceof Error ? err.message : String(err),
+ ...getClientInfo(),
+ });
throw err;
}
diff --git a/src/app/[id]/versions/VersionCard.tsx b/src/app/[id]/versions/VersionCard.tsx
index a6a0467..b72416b 100644
--- a/src/app/[id]/versions/VersionCard.tsx
+++ b/src/app/[id]/versions/VersionCard.tsx
@@ -17,6 +17,7 @@ import { getFileContent } from '@/lib/utils';
import { Step } from '../store';
import DragAndDropFile from '@/app/components/DragAndDropFile';
import { useEmlStore } from '@/lib/stores/useEmlStore';
+import { getErrorMessage } from '@/lib/errors';
interface VersionCardProps {
blueprint: Blueprint;
@@ -78,7 +79,7 @@ const VersionCard = ({
toast.success('Compilation cancelled');
} catch (err) {
console.error('Failed to cancel blueprint compilation: ', err);
- toast.error('Failed to cancel blueprint compilation');
+ toast.error(`Failed to cancel blueprint compilation: ${getErrorMessage(err)}`);
}
};
@@ -91,7 +92,7 @@ const VersionCard = ({
}
} catch (err) {
// TODO: Handle different kind of errors, e.g. per field errors
- toast.error('Failed to submit blueprint');
+ toast.error(`Failed to submit blueprint: ${getErrorMessage(err)}`);
console.error('Failed to submit blueprint: ', err);
} finally {
setIsSaveDraftLoading(false);
diff --git a/src/app/[id]/versions/page.tsx b/src/app/[id]/versions/page.tsx
index 143943f..f8762ee 100644
--- a/src/app/[id]/versions/page.tsx
+++ b/src/app/[id]/versions/page.tsx
@@ -12,6 +12,7 @@ import { toast } from 'react-toastify';
import Loader from '@/components/ui/loader';
import { useAuthStore } from '@/lib/stores/useAuthStore';
import { getCombinedBlueprintStatus } from '@/app/utils';
+import { getErrorMessage } from '@/lib/errors';
const VersionsPage = ({ params }: { params: Promise<{ id: string }> }) => {
const router = useRouter();
@@ -67,7 +68,7 @@ const VersionsPage = ({ params }: { params: Promise<{ id: string }> }) => {
}
} catch (err) {
console.error('Failed to delete blueprint: ', err);
- toast.error('Failed to delete blueprint');
+ toast.error(`Failed to delete blueprint: ${getErrorMessage(err)}`);
} finally {
setIsDeleteBlueprintLoading(false);
}
diff --git a/src/app/create/[id]/page.tsx b/src/app/create/[id]/page.tsx
index 359e885..199bdca 100644
--- a/src/app/create/[id]/page.tsx
+++ b/src/app/create/[id]/page.tsx
@@ -2,7 +2,7 @@
import { useCreateBlueprintStore } from './store';
-import { use, useEffect, useState } from 'react';
+import { use, useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import Image from 'next/image';
import { extractEMLDetails, DecomposedRegex, testBlueprint, parseEmail } from '@zk-email/sdk';
@@ -25,6 +25,8 @@ import { debounce } from '@/app/utils';
import ModalGenerator from '@/components/ModalGenerator';
import { useEmlStore } from '@/lib/stores/useEmlStore';
import Loader from '@/components/ui/loader';
+import { captureEvent, getClientInfo } from '@/lib/analytics';
+import { getErrorMessage } from '@/lib/errors';
type Step = '0' | '1' | '2';
@@ -86,6 +88,9 @@ const CreateBlueprint = ({ params }: { params: Promise<{ id: string }> }) => {
const searchParams = useSearchParams();
let step = searchParams.get('step') || '0';
+ const pageStartRef = useRef(performance.now());
+ const stepStartRef = useRef(performance.now());
+ const lastStepRef = useRef(step);
const setStep = (step: Step, id?: string) => {
if (id) {
@@ -107,6 +112,39 @@ const CreateBlueprint = ({ params }: { params: Promise<{ id: string }> }) => {
};
}, [optOut]);
+ useEffect(() => {
+ const now = performance.now();
+ if (lastStepRef.current !== step) {
+ captureEvent('create_blueprint_step_time_spent', {
+ step: lastStepRef.current,
+ step_label: steps[parseInt(lastStepRef.current)],
+ duration_ms: Math.round(now - stepStartRef.current),
+ blueprint_id: id,
+ ...getClientInfo(),
+ });
+ lastStepRef.current = step;
+ stepStartRef.current = now;
+ }
+ }, [step, id, steps]);
+
+ useEffect(() => {
+ return () => {
+ const now = performance.now();
+ captureEvent('create_blueprint_step_time_spent', {
+ step: lastStepRef.current,
+ step_label: steps[parseInt(lastStepRef.current)],
+ duration_ms: Math.round(now - stepStartRef.current),
+ blueprint_id: id,
+ ...getClientInfo(),
+ });
+ captureEvent('create_blueprint_page_time_spent', {
+ duration_ms: Math.round(now - pageStartRef.current),
+ blueprint_id: id,
+ ...getClientInfo(),
+ });
+ };
+ }, [id, steps]);
+
useEffect(() => {
if (savedEmls[id]) {
const parser = new PostalMime();
@@ -133,7 +171,7 @@ const CreateBlueprint = ({ params }: { params: Promise<{ id: string }> }) => {
setHasLoadedBlueprint(true);
} catch (err) {
console.error('Failed to load blueprint:', err);
- toast.error('Failed to load blueprint');
+ toast.error(getErrorMessage(err));
} finally {
setIsBlueprintLoading(false);
}
@@ -183,7 +221,7 @@ const CreateBlueprint = ({ params }: { params: Promise<{ id: string }> }) => {
router.push(`/${blueprintId}`);
} catch (error) {
console.error('Failed to compile:', error);
- toast.error(`Failed to compile blueprint: ${error?.toString()?.replace('Error: ', '')}`);
+ toast.error(`Failed to compile blueprint: ${getErrorMessage(error)}`);
} finally {
setIsCompileLoading(false);
}
diff --git a/src/app/create/error.tsx b/src/app/create/error.tsx
new file mode 100644
index 0000000..81b8bc9
--- /dev/null
+++ b/src/app/create/error.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { useEffect } from 'react';
+
+export default function CreateError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ console.error('Create blueprint error:', error);
+ }, [error]);
+
+ return (
+
+
+
Failed to load blueprint editor
+
+ There was a problem loading the blueprint creation page. Please try again.
+
+ {error.digest && (
+
Error ID: {error.digest}
+ )}
+
+
+
+ );
+}
diff --git a/src/app/error.tsx b/src/app/error.tsx
new file mode 100644
index 0000000..718f41e
--- /dev/null
+++ b/src/app/error.tsx
@@ -0,0 +1,44 @@
+'use client';
+
+import { useEffect } from 'react';
+
+export default function Error({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ // Log the error to console for debugging
+ console.error('Application error:', error);
+ }, [error]);
+
+ return (
+
+
+
Something went wrong
+
+ An unexpected error occurred. Please try again or contact support if the problem persists.
+
+ {error.digest && (
+
Error ID: {error.digest}
+ )}
+
+
+
+ );
+}
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
new file mode 100644
index 0000000..46f9134
--- /dev/null
+++ b/src/lib/analytics.ts
@@ -0,0 +1,33 @@
+import posthog from 'posthog-js';
+
+export type AnalyticsProps = Record;
+
+export function isAnalyticsOptedOut(): boolean {
+ if (typeof window === 'undefined') return true;
+ return window.localStorage.getItem('optOut') === 'true';
+}
+
+export function getClientInfo(): AnalyticsProps {
+ if (typeof window === 'undefined') return {};
+ const nav = navigator as Navigator & {
+ deviceMemory?: number;
+ connection?: { effectiveType?: string };
+ };
+
+ return {
+ client_user_agent: nav.userAgent,
+ client_platform: nav.platform,
+ client_device_memory_gb: nav.deviceMemory,
+ client_hardware_concurrency: nav.hardwareConcurrency,
+ client_connection_type: nav.connection?.effectiveType,
+ client_screen_width: window.screen?.width,
+ client_screen_height: window.screen?.height,
+ };
+}
+
+export function captureEvent(eventName: string, properties: AnalyticsProps = {}) {
+ if (typeof window === 'undefined') return;
+ if (isAnalyticsOptedOut()) return;
+ posthog.capture(eventName, properties);
+}
+
diff --git a/src/lib/errors.ts b/src/lib/errors.ts
new file mode 100644
index 0000000..b38343b
--- /dev/null
+++ b/src/lib/errors.ts
@@ -0,0 +1,75 @@
+import { ApiError, ErrorCode } from '@zk-email/sdk';
+
+/**
+ * Get a user-friendly error message from an error
+ */
+export function getErrorMessage(error: unknown): string {
+ if (error instanceof ApiError) {
+ // Return specific messages based on error code
+ switch (error.code) {
+ case ErrorCode.NOT_FOUND:
+ case ErrorCode.BLUEPRINT_NOT_FOUND:
+ return 'The requested resource was not found';
+ case ErrorCode.PROOF_NOT_FOUND:
+ return 'The proof was not found';
+ case ErrorCode.UNAUTHORIZED:
+ return 'Please sign in to continue';
+ case ErrorCode.FORBIDDEN:
+ return 'You do not have permission to perform this action';
+ case ErrorCode.INVALID_BODY:
+ case ErrorCode.INVALID_REQUEST:
+ return error.message || 'Invalid request';
+ case ErrorCode.ALREADY_EXISTS:
+ return 'This resource already exists';
+ case ErrorCode.INTERNAL_ERROR:
+ return 'An unexpected error occurred. Please try again later.';
+ default:
+ return error.message;
+ }
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ if (typeof error === 'string') {
+ return error;
+ }
+
+ return 'An unexpected error occurred';
+}
+
+/**
+ * Check if an error is a specific API error code
+ */
+export function isErrorCode(error: unknown, code: string): boolean {
+ return error instanceof ApiError && error.code === code;
+}
+
+/**
+ * Check if error is a "not found" error (404)
+ */
+export function isNotFoundError(error: unknown): boolean {
+ return error instanceof ApiError && error.isNotFoundError();
+}
+
+/**
+ * Check if error is an authentication error (401/403)
+ */
+export function isAuthError(error: unknown): boolean {
+ return error instanceof ApiError && error.isAuthError();
+}
+
+/**
+ * Check if error is a validation error (400)
+ */
+export function isValidationError(error: unknown): boolean {
+ return error instanceof ApiError && error.isValidationError();
+}
+
+/**
+ * Check if error is a server error (5xx)
+ */
+export function isServerError(error: unknown): boolean {
+ return error instanceof ApiError && error.isServerError();
+}