diff --git a/packages/hoppscotch-app/src/helpers/backend/GQLClient.ts b/packages/hoppscotch-app/src/helpers/backend/GQLClient.ts index 38872fd5d..8b14006c2 100644 --- a/packages/hoppscotch-app/src/helpers/backend/GQLClient.ts +++ b/packages/hoppscotch-app/src/helpers/backend/GQLClient.ts @@ -1,4 +1,3 @@ -// TODO: fix cache import { ref } from "vue" import { createClient, @@ -9,10 +8,11 @@ import { makeOperation, createRequest, subscriptionExchange, + errorExchange, + CombinedError, + Operation, } from "@urql/core" import { authExchange } from "@urql/exchange-auth" -// import { offlineExchange } from "@urql/exchange-graphcache" -// import { makeDefaultStorage } from "@urql/exchange-graphcache/default-storage" import { devtoolsExchange } from "@urql/devtools" import { SubscriptionClient } from "subscriptions-transport-ws" import * as E from "fp-ts/Either" @@ -20,11 +20,6 @@ import * as TE from "fp-ts/TaskEither" import { pipe, constVoid, flow } from "fp-ts/function" import { subscribe, pipe as wonkaPipe } from "wonka" import { filter, map, Subject } from "rxjs" -// import { keyDefs } from "./caching/keys" -// import { optimisticDefs } from "./caching/optimistic" -// import { updatesDef } from "./caching/updates" -// import { resolversDef } from "./caching/resolvers" -// import schema from "./backend-schema.json" import { authIdToken$, getAuthIDToken, @@ -32,15 +27,25 @@ import { waitProbableLoginToConfirm, } from "~/helpers/fb/auth" +// TODO: Implement caching + const BACKEND_GQL_URL = import.meta.env.VITE_BACKEND_GQL_URL ?? "https://api.hoppscotch.io/graphql" const BACKEND_WS_URL = import.meta.env.VITE_BACKEND_WS_URL ?? "wss://api.hoppscotch.io/graphql" -// const storage = makeDefaultStorage({ -// idbName: "hoppcache-v1", -// maxAge: 7, -// }) +/** + * A type that defines error events that are possible during backend operations on the GQLCLient + */ +export type GQLClientErrorEvent = + | { type: "SUBSCRIPTION_CONN_CALLBACK_ERR_REPORT"; errors: Error[] } + | { type: "CLIENT_REPORTED_ERROR"; error: CombinedError; op: Operation } + +/** + * A stream of the errors that occur during GQLClient operations. + * Exposed to be subscribed to by systems like sentry for error reporting + */ +export const gqlClientError$ = new Subject() const subscriptionClient = new SubscriptionClient(BACKEND_WS_URL, { reconnect: true, @@ -49,6 +54,14 @@ const subscriptionClient = new SubscriptionClient(BACKEND_WS_URL, { authorization: `Bearer ${authIdToken$.value}`, } }, + connectionCallback(error) { + if (error?.length > 0) { + gqlClientError$.next({ + type: "SUBSCRIPTION_CONN_CALLBACK_ERR_REPORT", + errors: error, + }) + } + }, }) authIdToken$.subscribe(() => { @@ -61,14 +74,6 @@ const createHoppClient = () => exchanges: [ devtoolsExchange, dedupExchange, - // offlineExchange({ - // schema: schema as any, - // keys: keyDefs, - // optimistic: optimisticDefs, - // updates: updatesDef, - // resolvers: resolversDef, - // storage, - // }), authExchange({ addAuthToOperation({ authState, operation }) { if (!authState || !authState.authToken) { @@ -109,6 +114,15 @@ const createHoppClient = () => forwardSubscription: (operation) => subscriptionClient.request(operation), }), + errorExchange({ + onError(error, op) { + gqlClientError$.next({ + type: "CLIENT_REPORTED_ERROR", + error, + op, + }) + }, + }), ], }) diff --git a/packages/hoppscotch-app/src/helpers/backend/caching/keys.ts b/packages/hoppscotch-app/src/helpers/backend/caching/keys.ts deleted file mode 100644 index 5a9aa6d3b..000000000 --- a/packages/hoppscotch-app/src/helpers/backend/caching/keys.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { GraphCacheKeysConfig } from "../graphql" - -export const keyDefs: GraphCacheKeysConfig = { - User: (data) => data.uid!, - TeamMember: (data) => data.membershipID!, - Team: (data) => data.id!, - Shortcode: (data) => data.id!, - TeamInvitation: (data) => data.id!, -} diff --git a/packages/hoppscotch-app/src/helpers/backend/caching/optimistic.ts b/packages/hoppscotch-app/src/helpers/backend/caching/optimistic.ts deleted file mode 100644 index 30d86a00b..000000000 --- a/packages/hoppscotch-app/src/helpers/backend/caching/optimistic.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { GraphCacheOptimisticUpdaters } from "../graphql" - -export const optimisticDefs: GraphCacheOptimisticUpdaters = {} diff --git a/packages/hoppscotch-app/src/helpers/backend/caching/resolvers.ts b/packages/hoppscotch-app/src/helpers/backend/caching/resolvers.ts deleted file mode 100644 index 3a34d2af8..000000000 --- a/packages/hoppscotch-app/src/helpers/backend/caching/resolvers.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - GraphCacheResolvers, - Shortcode, - Team, - TeamInvitation, - WithTypename, -} from "../graphql" - -export const resolversDef: GraphCacheResolvers = { - Query: { - team: (_parent, { teamID }) => - >{ - __typename: "Team" as const, - id: teamID, - }, - user: (_parent, { uid }) => ({ - __typename: "User", - uid, - }), - teamInvitation: (_parent, args) => - >{ - __typename: "TeamInvitation", - id: args.inviteID, - }, - shortcode: (_parent, args) => - >{ - __typename: "Shortcode", - id: args.code, - }, - }, -} diff --git a/packages/hoppscotch-app/src/helpers/backend/caching/updates.ts b/packages/hoppscotch-app/src/helpers/backend/caching/updates.ts deleted file mode 100644 index c17eb309b..000000000 --- a/packages/hoppscotch-app/src/helpers/backend/caching/updates.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { gql } from "@urql/core" -import { GraphCacheUpdaters } from "../graphql" - -export const updatesDef: GraphCacheUpdaters = { - Subscription: { - teamMemberAdded: (_r, { teamID }, cache) => { - cache.invalidate( - { - __typename: "Team", - id: teamID, - }, - "teamMembers" - ) - }, - teamMemberUpdated: (_r, { teamID }, cache) => { - cache.invalidate( - { - __typename: "Team", - id: teamID, - }, - "teamMembers" - ) - - cache.invalidate( - { - __typename: "Team", - id: teamID, - }, - "myRole" - ) - }, - teamMemberRemoved: (_r, { teamID }, cache) => { - cache.invalidate( - { - __typename: "Team", - id: teamID, - }, - "teamMembers" - ) - }, - teamInvitationAdded: (_r, { teamID }, cache) => { - cache.invalidate( - { - __typename: "Team", - id: teamID, - }, - "teamInvitations" - ) - }, - teamInvitationRemoved: (_r, { teamID }, cache) => { - cache.invalidate( - { - __typename: "Team", - id: teamID, - }, - "teamInvitations" - ) - }, - }, - Mutation: { - createTeamInvitation: (result, _args, cache) => { - cache.invalidate( - { - __typename: "Team", - id: result.createTeamInvitation.teamID!, - }, - "teamInvitations" - ) - }, - acceptTeamInvitation: (_result, _args, cache) => { - cache.invalidate({ __typename: "Query" }, "myTeams") - }, - revokeTeamInvitation: (_result, args, cache) => { - const targetTeamID = cache.resolve( - { - __typename: "TeamInvitation", - id: args.inviteID, - }, - "teamID" - ) - - if (typeof targetTeamID === "string") { - const newInvites = ( - cache.resolve( - { - __typename: "Team", - id: targetTeamID, - }, - "teamInvitations" - ) as string[] - ).filter( - (inviteKey) => - inviteKey !== - cache.keyOfEntity({ - __typename: "TeamInvitation", - id: args.inviteID, - }) - ) - - cache.link( - { __typename: "Team", id: targetTeamID }, - "teamInvitations", - newInvites - ) - } - }, - createShortcode: (result, _args, cache) => { - cache.writeFragment( - gql` - fragment _ on Shortcode { - id - request - } - `, - { - id: result.createShortcode.id, - request: result.createShortcode.request, - } - ) - }, - }, -} diff --git a/packages/hoppscotch-app/src/modules/sentry.ts b/packages/hoppscotch-app/src/modules/sentry.ts index 50a47085f..02ef6f044 100644 --- a/packages/hoppscotch-app/src/modules/sentry.ts +++ b/packages/hoppscotch-app/src/modules/sentry.ts @@ -6,6 +6,12 @@ import { RouteLocationNormalized, Router } from "vue-router" import { settingsStore } from "~/newstore/settings" import { App } from "vue" import { APP_IS_IN_DEV_MODE } from "~/helpers/dev" +import { gqlClientError$ } from "~/helpers/backend/GQLClient" + +/** + * The tag names we allow giving to Sentry + */ +type SentryTag = "BACKEND_OPERATIONS" interface SentryVueRouter { onError: (fn: (err: Error) => void) => void @@ -44,7 +50,7 @@ let sentryActive = false function initSentry(dsn: string, router: Router, app: App) { Sentry.init({ app, - dsn: import.meta.env.VITE_SENTRY_DSN, + dsn, environment: APP_IS_IN_DEV_MODE ? "dev" : import.meta.env.VITE_SENTRY_ENVIRONMENT, @@ -67,6 +73,73 @@ function deinitSentry() { sentryActive = false } +/** + * Reports a set of related errors to Sentry + * @param errs The errors to report + * @param tag The tag for the errord + * @param extraTags Additional tag data to add + * @param extras Extra information to attach + */ +function reportErrors( + errs: Error[], + tag: SentryTag, + extraTags: Record | null = null, + extras: any = undefined +) { + if (sentryActive) { + Sentry.withScope((scope) => { + scope.setTag("tag", tag) + if (extraTags) { + Object.entries(extraTags).forEach(([key, value]) => { + scope.setTag(key, value) + }) + } + if (extras !== null && extras === undefined) scope.setExtras(extras) + + errs.forEach((err) => Sentry.captureException(err)) + }) + } +} + +/** + * Reports a specific error to Sentry + * @param err The error to report + * @param tag The tag for the error + * @param extraTags Additional tag data to add + * @param extras Extra information to attach + */ +function reportError( + err: Error, + tag: SentryTag, + extraTags: Record | null = null, + extras: any = undefined +) { + reportErrors([err], tag, extraTags, extras) +} + +/** + * Subscribes to events occuring in various subsystems in the app + * for personalized error reporting + */ +function subscribeToAppEventsForReporting() { + gqlClientError$.subscribe((ev) => { + switch (ev.type) { + case "SUBSCRIPTION_CONN_CALLBACK_ERR_REPORT": + reportErrors(ev.errors, "BACKEND_OPERATIONS", { from: ev.type }) + break + + case "CLIENT_REPORTED_ERROR": + reportError( + ev.error, + "BACKEND_OPERATIONS", + { from: ev.type }, + { op: ev.op } + ) + break + } + }) +} + export default { onRouterInit(app, router) { if (!import.meta.env.VITE_SENTRY_DSN) { @@ -87,5 +160,7 @@ export default { initSentry(import.meta.env.VITE_SENTRY_DSN!, router, app) } }) + + subscribeToAppEventsForReporting() }, }