From 9a43d505490f036e780bf8e7f79fc76bd20c2767 Mon Sep 17 00:00:00 2001 From: Theofanis Despoudis <328805+theodesp@users.noreply.github.com> Date: Mon, 11 Mar 2024 10:16:59 +0000 Subject: [PATCH] Feat: Added Error Logging Apollo Link (#1832) * Feat: Added Error Logging Apollo Link * Update errorLoggingLink.test.ts * Update errorLoggingLink.test.ts * Update errorLoggingLink.test.ts --- .changeset/soft-swans-chew.md | 5 + package-lock.json | 54 +++++--- packages/faustwp-core/package.json | 3 +- .../src/apollo/errorLoggingLink.ts | 73 +++++++++++ packages/faustwp-core/src/apollo/index.ts | 1 + packages/faustwp-core/src/client.ts | 23 ++-- .../tests/apollo/errorLoggingLink.test.ts | 116 ++++++++++++++++++ 7 files changed, 246 insertions(+), 29 deletions(-) create mode 100644 .changeset/soft-swans-chew.md create mode 100644 packages/faustwp-core/src/apollo/errorLoggingLink.ts create mode 100644 packages/faustwp-core/src/apollo/index.ts create mode 100644 packages/faustwp-core/tests/apollo/errorLoggingLink.test.ts diff --git a/.changeset/soft-swans-chew.md b/.changeset/soft-swans-chew.md new file mode 100644 index 000000000..ddda5388e --- /dev/null +++ b/.changeset/soft-swans-chew.md @@ -0,0 +1,5 @@ +--- +'@faustwp/core': patch +--- + +Implemented ErrorLoggingLink class to capture GraphQL errors and server errors, providing enhanced error handling and logging capabilities. diff --git a/package-lock.json b/package-lock.json index e00602473..9a8a0edbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,9 +51,9 @@ "dependencies": { "@apollo/client": "^3.8.0", "@apollo/experimental-nextjs-app-support": "^0.8.0", - "@faustwp/cli": "^2.0.0", - "@faustwp/core": "^2.1.2", - "@faustwp/experimental-app-router": "^0.2.2", + "@faustwp/cli": "^3.0.0", + "@faustwp/core": "^3.0.0", + "@faustwp/experimental-app-router": "^0.3.0", "graphql": "^16.7.1", "next": "^14.0.1", "react": "^18.2.0", @@ -328,9 +328,9 @@ "version": "0.2.0", "dependencies": { "@apollo/client": "^3.8.8", - "@faustwp/blocks": "3.0.0", - "@faustwp/cli": "^2.0.0", - "@faustwp/core": "^2.1.2", + "@faustwp/blocks": "4.0.0", + "@faustwp/cli": "^3.0.0", + "@faustwp/core": "^3.0.0", "classnames": "^2.3.1", "graphql": "^16.8.1", "next": "^14.0.3", @@ -339,7 +339,7 @@ "sass": "^1.54.9" }, "devDependencies": { - "@faustwp/block-editor-utils": "0.1.0", + "@faustwp/block-editor-utils": "0.2.0", "@wordpress/base-styles": "^4.41.0", "@wordpress/block-library": "^8.27.0", "@wordpress/scripts": "26.18.0", @@ -2429,8 +2429,8 @@ "version": "0.1.0", "dependencies": { "@apollo/client": "^3.6.6", - "@faustwp/cli": "^2.0.0", - "@faustwp/core": "^2.1.2", + "@faustwp/cli": "^3.0.0", + "@faustwp/core": "^3.0.0", "@wordpress/base-styles": "^4.36.0", "@wordpress/block-library": "^7.19.0", "classnames": "^2.3.1", @@ -8737,6 +8737,11 @@ "@types/node": "*" } }, + "node_modules/@types/zen-observable": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz", + "integrity": "sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.62.0", "dev": true, @@ -28673,7 +28678,8 @@ }, "node_modules/zen-observable-ts": { "version": "1.2.5", - "license": "MIT", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz", + "integrity": "sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==", "dependencies": { "zen-observable": "0.8.15" } @@ -28692,7 +28698,7 @@ }, "packages/block-editor-utils": { "name": "@faustwp/block-editor-utils", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@wordpress/block-editor": "^12.11.1", @@ -29501,7 +29507,7 @@ }, "packages/blocks": { "name": "@faustwp/blocks", - "version": "3.0.0", + "version": "4.0.0", "license": "MIT", "devDependencies": { "@testing-library/jest-dom": "^5.16.5", @@ -31022,13 +31028,13 @@ }, "packages/experimental-app-router": { "name": "@faustwp/experimental-app-router", - "version": "0.2.2", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@apollo/client": "^3.8.0", "@apollo/experimental-nextjs-app-support": "^0.8.0", - "@faustwp/cli": "^2.0.0", - "@faustwp/core": "^2.0.0", + "@faustwp/cli": "^3.0.0", + "@faustwp/core": "^3.0.0", "@testing-library/jest-dom": "^5.17.0", "@types/node": "^20.4.6", "concurrently": "^8.2.0", @@ -31595,7 +31601,7 @@ }, "packages/faustwp-cli": { "name": "@faustwp/cli", - "version": "2.0.0", + "version": "3.0.0", "license": "MIT", "dependencies": { "archiver": "^6.0.1", @@ -31761,7 +31767,7 @@ }, "packages/faustwp-core": { "name": "@faustwp/core", - "version": "2.1.2", + "version": "3.0.0", "license": "MIT", "dependencies": { "@wordpress/hooks": "^3.14.0", @@ -31773,7 +31779,8 @@ "isomorphic-fetch": "^3.0.0", "js-cookie": "^3.0.5", "js-sha256": "^0.9.0", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "zen-observable-ts": "^1.1.0" }, "devDependencies": { "@apollo/client": "^3.6.6", @@ -33191,6 +33198,15 @@ "node": ">=10" } }, + "packages/faustwp-core/node_modules/zen-observable-ts": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.1.0.tgz", + "integrity": "sha512-1h4zlLSqI2cRLPJUHJFL8bCWHhkpuXkF+dbGkRaWjgDIG26DmzyshUMrdV/rL3UnR+mhaX4fRq8LPouq0MYYIA==", + "dependencies": { + "@types/zen-observable": "0.8.3", + "zen-observable": "0.8.15" + } + }, "packages/next": { "name": "@faustjs/next", "version": "0.15.13", @@ -36039,7 +36055,7 @@ }, "plugins/faustwp": { "name": "@faustwp/wordpress-plugin", - "version": "1.2.1" + "version": "1.2.2" }, "packages/experimental-app-router/node_modules/@next/swc-android-arm-eabi": { "version": "12.3.4", diff --git a/packages/faustwp-core/package.json b/packages/faustwp-core/package.json index 5efc54808..7f7af10c4 100644 --- a/packages/faustwp-core/package.json +++ b/packages/faustwp-core/package.json @@ -41,7 +41,8 @@ "isomorphic-fetch": "^3.0.0", "js-cookie": "^3.0.5", "js-sha256": "^0.9.0", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "zen-observable-ts": "^1.1.0" }, "scripts": { "dev": "concurrently \"npm:watch-*\" --prefix-colors \"auto\"", diff --git a/packages/faustwp-core/src/apollo/errorLoggingLink.ts b/packages/faustwp-core/src/apollo/errorLoggingLink.ts new file mode 100644 index 000000000..07bcce33e --- /dev/null +++ b/packages/faustwp-core/src/apollo/errorLoggingLink.ts @@ -0,0 +1,73 @@ +import { + ApolloLink, + FetchResult, + NextLink, + Operation, + ServerError, +} from '@apollo/client'; +import { Observable } from 'zen-observable-ts'; +import { errorLog } from '../utils/log.js'; + +/** + * Checks if the given error is a server error. + * @param error The error to check. + * @returns A boolean indicating whether the error is a server error. + */ +function isServerError(error: unknown): error is ServerError { + if ( + typeof error === 'object' && + error !== null && + 'response' in error && + 'result' in error && + 'statusCode' in error + ) { + return true; + } + return false; +} + +/** + * Apollo Link that captures GraphQL errors and server errors, and prints them into the console. + */ +export class ErrorLoggingLink extends ApolloLink { + /** + * Intercepts each GraphQL operation request. + * @param operation The GraphQL operation being executed. + * @param forward The next link in the chain to delegate the operation to. + * @returns An Observable with the operation result or error. + */ + // eslint-disable-next-line class-methods-use-this + request( + operation: Operation, + forward: NextLink, + ): Observable | null { + return new Observable((observer) => { + const subscription = forward(operation).subscribe({ + next: (result) => { + // Check if there are GraphQL errors in the result + if (result.errors && result.errors.length > 0) { + errorLog('GraphQL errors:', result.errors); + } + observer.next(result); + }, + error: (error) => { + // Check if the error is a server error + if (isServerError(error)) { + errorLog('Server error:', error); + errorLog('Fetch result:', error.result); + } else { + errorLog('Network error:', error); + } + observer.error(error); + }, + complete: () => { + observer.complete(); + }, + }); + + return () => { + subscription.unsubscribe(); + }; + }); + } +} diff --git a/packages/faustwp-core/src/apollo/index.ts b/packages/faustwp-core/src/apollo/index.ts new file mode 100644 index 000000000..9a8f95880 --- /dev/null +++ b/packages/faustwp-core/src/apollo/index.ts @@ -0,0 +1 @@ +export * from './errorLoggingLink.js'; diff --git a/packages/faustwp-core/src/client.ts b/packages/faustwp-core/src/client.ts index c334adf1b..d63ecade0 100644 --- a/packages/faustwp-core/src/client.ts +++ b/packages/faustwp-core/src/client.ts @@ -1,6 +1,7 @@ import { ApolloClient, ApolloClientOptions, + ApolloLink, createHttpLink, InMemoryCache, InMemoryCacheConfig, @@ -16,6 +17,7 @@ import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries' import { sha256 } from 'js-sha256'; // eslint-disable-next-line import/extensions import { AppProps } from 'next/app'; +import { ErrorLoggingLink } from './apollo/errorLoggingLink.js'; import { getAccessToken } from './auth/index.js'; import { getConfig } from './config/index.js'; import { getGraphqlEndpoint } from './lib/getGraphqlEndpoint.js'; @@ -56,15 +58,18 @@ export function createApolloClient(authenticated = false) { {}, ) as InMemoryCacheConfig; - let linkChain = createHttpLink({ - uri: getGraphqlEndpoint(), - /** - * Only add this option if usePersistedQueries is not set/false. - * When persisted queries is enabled and this flag and useGETForHashedQueries - * are both set, there is a conflict and persisted queries does not work. - */ - useGETForQueries: useGETForQueries && !usePersistedQueries, - }); + let linkChain = ApolloLink.from([ + new ErrorLoggingLink(), + createHttpLink({ + uri: getGraphqlEndpoint(), + /** + * Only add this option if usePersistedQueries is not set/false. + * When persisted queries is enabled and this flag and useGETForHashedQueries + * are both set, there is a conflict and persisted queries does not work. + */ + useGETForQueries: useGETForQueries && !usePersistedQueries, + }), + ]); // If the user requested to use persisted queries, apply the link. if (usePersistedQueries) { diff --git a/packages/faustwp-core/tests/apollo/errorLoggingLink.test.ts b/packages/faustwp-core/tests/apollo/errorLoggingLink.test.ts new file mode 100644 index 000000000..00c6f57f0 --- /dev/null +++ b/packages/faustwp-core/tests/apollo/errorLoggingLink.test.ts @@ -0,0 +1,116 @@ +import { ApolloError } from '@apollo/client/core'; +import { Observable } from 'zen-observable-ts'; +import { ErrorLoggingLink } from '../../src/apollo/errorLoggingLink'; + +describe('ErrorLoggingLink', () => { + let link: ErrorLoggingLink; + let mockNextLink: jest.Mock; + + beforeEach(() => { + link = new ErrorLoggingLink(); + mockNextLink = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('logs GraphQL errors', (done) => { + const mockErrors = [ + new ApolloError({ errorMessage: 'Test GraphQL error' }), + ]; + const mockResult = { errors: mockErrors }; + const mockOperation = { query: {} }; + + mockNextLink.mockReturnValueOnce( + new Observable((observer) => { + observer.next(mockResult); + observer.complete(); + }), + ); + + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + link.request(mockOperation as any, mockNextLink as any).subscribe({ + next: (result) => { + // Check if there are GraphQL errors in the result + if (result.errors && result.errors.length > 0) { + expect(consoleLogSpy).toHaveBeenCalled(); + expect(consoleLogSpy.mock.calls[0][0]).toContain('GraphQL errors'); + expect(consoleLogSpy.mock.calls[0][1]).toEqual(mockErrors); + } else { + // We shouldn't reach here for an error-free result + expect(true).toBe(false); + } + consoleLogSpy.mockRestore(); + done(); + }, + error: () => { + // We shouldn't reach here + expect(true).toBe(false); + done(); + }, + }); + }, 1000); + + it('logs server errors along with fetch result', (done) => { + const mockServerError = new ApolloError({ + errorMessage: 'Test server error', + graphQLErrors: [], + networkError: null, // Ensure networkError is null to simulate a server error + }); + const mockOperation = { query: {} }; + + mockNextLink.mockReturnValueOnce( + new Observable((observer) => { + observer.error(mockServerError); + }), + ); + + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + link.request(mockOperation as any, mockNextLink as any).subscribe({ + next: () => { + // We shouldn't reach here + expect(true).toBe(false); + done(); + }, + error: () => { + expect(consoleLogSpy).toHaveBeenCalled(); + consoleLogSpy.mockRestore(); + done(); + }, + }); + }, 1000); + + it('logs network errors', (done) => { + const mockNetworkError = { + response: {}, + result: {}, + statusCode: 500, + }; + const mockOperation = { query: {} }; + + mockNextLink.mockReturnValueOnce( + new Observable((observer) => { + observer.error(mockNetworkError); // Emit a network error instead of a server error + }) + ); + + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + link.request(mockOperation as any, mockNextLink as any).subscribe({ + next: () => { + // We shouldn't reach here + expect(true).toBe(false); + done(); + }, + error: (error) => { + expect(consoleLogSpy).toHaveBeenCalled(); + expect(consoleLogSpy.mock.calls[0][0]).toContain('Server error'); + expect(error).toEqual(mockNetworkError); + done(); + }, + }); + }, 1000); +});