diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 7ccf66867..d1f86f48c 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -14,6 +14,7 @@ declare global { interface Locals { client: Client; getUser: (() => LexAuthUser | null); + deferError: (error: unknown) => void; } interface Error { diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 76bc70a14..24ca6513b 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -18,10 +18,12 @@ function getRoot(routeId: string): string { } // eslint-disable-next-line func-style -export const handle: Handle = ({ event, resolve }) => { +export const handle: Handle = async ({ event, resolve }) => { + const deferredErrors: unknown[] = []; + event.locals.deferError = (error) => deferredErrors.push(error); console.log(`HTTP request: ${event.request.method} ${event.request.url}`); event.locals.getUser = () => getUser(event.cookies); - return traceRequest(event, async () => { + return traceRequest(event, async (span) => { await loadI18n(); const options: ResolveOptions = { @@ -37,8 +39,18 @@ export const handle: Handle = ({ event, resolve }) => { throw redirect(307, '/login'); } - return resolve(event, options); - }) + const response = await resolve(event, options); + + if (deferredErrors.length) { + deferredErrors.forEach((error) => + ensureErrorIsTraced(error, { event, span }, { + ['app.error.source']: 'server-handle-hook', + })); + throw deferredErrors[0]; + } + + return response; + }); }; // eslint-disable-next-line func-style diff --git a/frontend/src/lib/gql/gql-client.ts b/frontend/src/lib/gql/gql-client.ts index 418f32e6b..b92c82207 100644 --- a/frontend/src/lib/gql/gql-client.ts +++ b/frontend/src/lib/gql/gql-client.ts @@ -44,14 +44,14 @@ function createGqlClient(_gqlEndpoint?: string): Client { }); } -export function getClient(): GqlClient { +export function getClient(deferError?: (error: unknown) => void): GqlClient { if (browser) { if (globalClient) return globalClient; globalClient = new GqlClient(createGqlClient('')); return globalClient; } else { //We do not cache the client on the server side. - return new GqlClient(createGqlClient()); + return new GqlClient(createGqlClient(), deferError); } } @@ -74,7 +74,7 @@ type QueryStoreReturnType = { [K in keyof Data]: Readable }; class GqlClient { public ownedByUserId = ''; - constructor(public readonly client: Client) { + constructor(public readonly client: Client, private readonly deferError?: (error: unknown) => void) { this.subscription = (...args) => this.client.subscription(...args); } @@ -90,12 +90,25 @@ class GqlClient { query: TypedDocumentNode, variables: Variables, context: QueryOperationOptions = {}): OperationResultStore { - return queryStore({ + const resultStore = queryStore({ client: this.client, query, variables, context: {fetch, ...context} }); + + return derived(resultStore, (result) => { + try { + this.throwAnyUnexpectedErrors(result); + } catch (error) { + if (browser) throw error; + else if (this.deferError) this.deferError(error); + else { + console.error(error); // throwing errors here kills vite, so this is the best we can do + } + } + return result; + }); } async awaitedQueryStore( @@ -152,13 +165,14 @@ class GqlClient { private throwAnyUnexpectedErrors>(result: T): void { const error = result.error; if (!error) return; - if (this.is401(error)) throw redirect(307, '/logout'); + if (this.isStatusCode(error, 401)) throw redirect(307, '/logout'); + if (this.isStatusCode(error, 403)) throw redirect(307, '/home'); if (error.networkError) throw error.networkError; // e.g. SvelteKit redirects throw error; } - private is401(error: CombinedError): boolean { - return (error.response as Response | undefined)?.status === 401; + private isStatusCode(error: CombinedError, code: number): boolean { + return (error.response as Response | undefined)?.status === code; } private findInputErrors({data}: OperationResult): LexGqlError> | undefined { diff --git a/frontend/src/lib/otel/otel.shared.ts b/frontend/src/lib/otel/otel.shared.ts index 353abae85..f5e01bd53 100644 --- a/frontend/src/lib/otel/otel.shared.ts +++ b/frontend/src/lib/otel/otel.shared.ts @@ -24,7 +24,7 @@ export function tracer(): Tracer { return trace.getTracer(SERVICE_NAME); } -type ErrorTracer = ErrorHandler | 'server-gql-error' | 'client-gql-error' | 'client-fetch-error'; +type ErrorTracer = ErrorHandler | 'server-gql-error' | 'client-gql-error' | 'client-fetch-error' | 'server-handle-hook'; type ErrorAttributes = Attributes & { ['app.error.source']: ErrorTracer }; interface ErrorContext { diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.server.ts b/frontend/src/routes/(authenticated)/project/[project_code]/+page.server.ts new file mode 100644 index 000000000..e5483d054 --- /dev/null +++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.server.ts @@ -0,0 +1,7 @@ +import type { PageServerLoad } from './$types'; + +export const load = ((event) => { + return { + deferError: event.locals.deferError, + }; +}) satisfies PageServerLoad diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts b/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts index 21395f5a4..072efd256 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts +++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts @@ -20,7 +20,7 @@ type Project = NonNullable; export type ProjectUser = Project['users'][number]; export async function load(event: PageLoadEvent) { - const client = getClient(); + const client = getClient(event.data.deferError); const projectCode = event.params.project_code; const projectResult = await client .awaitedQueryStore(event.fetch,