diff --git a/packages/frontend-2/composables/logging.ts b/packages/frontend-2/composables/logging.ts index 9acb0dfce..ab5e4367d 100644 --- a/packages/frontend-2/composables/logging.ts +++ b/packages/frontend-2/composables/logging.ts @@ -3,13 +3,41 @@ import { Optional } from '@speckle/shared' import { buildFakePinoLogger } from '~~/lib/core/helpers/observability' export const useLogger = () => { - let nuxtApp: Optional + return useNuxtApp().$logger +} + +/** + * Use when you need to be sure that the real structured pino logger is available + * (it isn't in some early startup contexts like apollo link setup) + */ +export const useStrictLogger = async ( + options?: Partial<{ dontNotifyFallback: boolean }> +) => { + const { dontNotifyFallback } = options || {} + + let nuxtApp: Optional = undefined try { nuxtApp = useNuxtApp() } catch (e) { - console.error('Nuxt app for logger not found! Falling back to fake logger...') - return buildFakePinoLogger() + // suppress 'nuxt is not available' } - return nuxtApp?.$logger + if (nuxtApp?.$logger) return nuxtApp?.$logger + + // Nuxt app not found in this scope + const err = new Error( + 'Nuxt app for logger not found! Initializing fallback structured logger...' + ) + + let logger: ReturnType + if (process.server) { + const { buildLogger } = await import('~/server/lib/core/helpers/observability') + logger = buildLogger('info', process.dev ? true : false) // no runtime config, so falling back to default settings + } else { + logger = buildFakePinoLogger() + } + + if (!dontNotifyFallback) logger.error(err) + + return logger } diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index e61a40964..02afe32ff 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -369,6 +369,7 @@ export type Commit = { authorAvatar?: Maybe; authorId?: Maybe; authorName?: Maybe; + branch?: Maybe; branchName?: Maybe; /** * The total number of comments for this commit. To actually get the comments, use the comments query and pass in a resource array consisting of of this commit's id. diff --git a/packages/frontend-2/lib/common/helpers/type.ts b/packages/frontend-2/lib/common/helpers/type.ts index 1dc820415..742de9f00 100644 --- a/packages/frontend-2/lib/common/helpers/type.ts +++ b/packages/frontend-2/lib/common/helpers/type.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { isObjectLike as lodashIsObjectLike } from 'lodash-es' import { SetNonNullable, SetRequired } from 'type-fest' export type NonUndefined = T extends undefined ? never : T @@ -15,3 +16,6 @@ export type AddParameters< TFunction extends (...args: any) => any, TParameters extends [...args: any] > = (...args: [...Parameters, ...TParameters]) => ReturnType + +export const isObjectLike = (value: unknown): value is Record => + lodashIsObjectLike(value) diff --git a/packages/frontend-2/lib/core/configs/apollo.ts b/packages/frontend-2/lib/core/configs/apollo.ts index 32ad108f4..bed6ea504 100644 --- a/packages/frontend-2/lib/core/configs/apollo.ts +++ b/packages/frontend-2/lib/core/configs/apollo.ts @@ -8,7 +8,7 @@ import { createUploadLink } from 'apollo-upload-client' import { WebSocketLink } from '@apollo/client/link/ws' import { getMainDefinition } from '@apollo/client/utilities' import { OperationDefinitionNode, Kind } from 'graphql' -import { CookieRef } from '#app' +import { CookieRef, NuxtApp } from '#app' import { Optional } from '@speckle/shared' import { useAuthCookie } from '~~/lib/auth/composables/auth' import { @@ -20,6 +20,7 @@ import { onError } from '@apollo/client/link/error' import { useNavigateToLogin, loginRoute } from '~~/lib/common/helpers/route' import { useAppErrorState } from '~~/lib/core/composables/appErrorState' import { isInvalidAuth } from '~~/lib/common/helpers/graphql' +import { omit } from 'lodash-es' const appName = 'frontend-2' @@ -263,19 +264,28 @@ function createLink(params: { httpEndpoint: string wsClient?: SubscriptionClient authToken: CookieRef> + nuxtApp: NuxtApp }): ApolloLink { - const { httpEndpoint, wsClient, authToken } = params + const { httpEndpoint, wsClient, authToken, nuxtApp } = params const goToLogin = useNavigateToLogin() const { registerError, isErrorState } = useAppErrorState() const errorLink = onError((res) => { - const logger = useLogger() - + const logger = nuxtApp.$logger const isSubTokenMissingError = (res.networkError?.message || '').includes( 'need a token to subscribe' ) - if (!isSubTokenMissingError) logger.error('Apollo Client error', res) + if (!isSubTokenMissingError) { + logger.error( + { + ...omit(res, ['forward']), + networkErrorMessage: res.networkError?.message, + gqlErrorMessages: res.graphQLErrors?.map((e) => e.message) + }, + 'Apollo Client error' + ) + } const { networkError } = res if (networkError && isInvalidAuth(networkError)) { @@ -348,6 +358,7 @@ const defaultConfigResolver: ApolloConfigResolver = async () => { const { public: { apiOrigin, speckleServerVersion = 'unknown' } } = useRuntimeConfig() + const nuxtApp = useNuxtApp() const httpEndpoint = `${apiOrigin}/graphql` const wsEndpoint = httpEndpoint.replace('http', 'ws') @@ -356,7 +367,7 @@ const defaultConfigResolver: ApolloConfigResolver = async () => { const wsClient = process.client ? await createWsClient({ wsEndpoint, authToken }) : undefined - const link = createLink({ httpEndpoint, wsClient, authToken }) + const link = createLink({ httpEndpoint, wsClient, authToken, nuxtApp }) return { // If we don't markRaw the cache, sometimes we get cryptic internal Apollo Client errors that essentially diff --git a/packages/frontend-2/lib/core/helpers/observability.ts b/packages/frontend-2/lib/core/helpers/observability.ts index 8d566cb60..f1be9f8ca 100644 --- a/packages/frontend-2/lib/core/helpers/observability.ts +++ b/packages/frontend-2/lib/core/helpers/observability.ts @@ -5,13 +5,21 @@ import { Observability } from '@speckle/shared' import { noop } from 'lodash-es' -export function buildFakePinoLogger() { +export function buildFakePinoLogger( + options?: Partial<{ onError: (...args: any[]) => void }> +) { + const errLogger = (...args: unknown[]) => { + const { onError } = options || {} + if (onError) onError(...args) + console.error(...args) + } + const logger = { debug: console.debug, info: console.info, warn: console.warn, - error: console.error, - fatal: console.error, + error: errLogger, + fatal: errLogger, trace: console.debug, silent: noop } as unknown as ReturnType diff --git a/packages/frontend-2/plugins/001-logger.ts b/packages/frontend-2/plugins/001-logger.ts index c0555c67a..da1ed2f95 100644 --- a/packages/frontend-2/plugins/001-logger.ts +++ b/packages/frontend-2/plugins/001-logger.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/restrict-template-expressions */ +import { isObjectLike } from '~~/lib/common/helpers/type' import { buildFakePinoLogger } from '~~/lib/core/helpers/observability' /** @@ -18,38 +20,60 @@ export default defineNuxtPlugin(async () => { let logger: ReturnType if (process.server) { - const { Observability } = await import('@speckle/shared') - logger = Observability.getLogger(logLevel, logPretty) + const { buildLogger } = await import('~/server/lib/core/helpers/observability') + logger = buildLogger(logLevel, logPretty) } else { - logger = buildFakePinoLogger() - // set up seq ingestion - if (!process.dev && logClientApiToken?.length && logClientApiEndpoint?.length) { + if (logClientApiToken?.length && logClientApiEndpoint?.length) { const seq = await import('seq-logging/browser') - const logger = new seq.Logger({ + const seqLogger = new seq.Logger({ serverUrl: logClientApiEndpoint, apiKey: logClientApiToken, onError: console.error }) - const errorListener = (event: ErrorEvent) => { - logger.emit({ + const errorListener = ( + event: ErrorEvent | PromiseRejectionEvent | string | Error | unknown + ) => { + const isUnhandledRejection = isObjectLike(event) && 'reason' in event + let err: Error + if (event instanceof Error) { + err = event + } else if (isObjectLike(event)) { + if ('reason' in event && event.reason instanceof Error) { + err = event.reason + } else if ('error' in event && event.error instanceof Error) { + err = event.error + } else { + err = new Error(`${JSON.stringify(event)}`) + } + } else { + err = new Error(`${event}`) + } + + seqLogger.emit({ timestamp: new Date(), level: 'error', messageTemplate: 'Client-side error: {errorMessage}', properties: { - errorMessage: event.message, + errorMessage: err.message, browser: true, frontendType: 'frontend-2', speckleServerVersion, - serverName + serverName, + isUnhandledRejection }, - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - exception: event.error instanceof Error ? event.error.stack : `${event.error}` + exception: err.stack }) } window.addEventListener('error', errorListener) + window.addEventListener('unhandledrejection', errorListener) + + logger = buildFakePinoLogger({ onError: errorListener }) + logger.debug('Set up seq ingestion...') + } else { + logger = buildFakePinoLogger() } } diff --git a/packages/frontend-2/server/lib/core/helpers/observability.ts b/packages/frontend-2/server/lib/core/helpers/observability.ts new file mode 100644 index 000000000..9071eb1a6 --- /dev/null +++ b/packages/frontend-2/server/lib/core/helpers/observability.ts @@ -0,0 +1,5 @@ +import { Observability } from '@speckle/shared' + +export function buildLogger(logLevel: string, logPretty: boolean) { + return Observability.getLogger(logLevel, logPretty) +}