From a9ccfc738db9a5354e3401de48c494aca295215e Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 1 May 2024 23:49:01 +0800 Subject: [PATCH] refactor: implement request id (#5813) * refactor: implement request id * refactor: fix tests * refactor: add unit tests --- .changeset/green-phones-visit.md | 5 ++ .changeset/grumpy-cougars-perform.md | 8 ++ packages/app-insights/src/node.ts | 14 ++- packages/core/src/app/init.ts | 26 +++++- packages/core/src/caches/index.ts | 8 +- packages/core/src/caches/utils.ts | 4 + packages/core/src/caches/well-known.ts | 4 +- .../src/env-set/check-alteration-state.ts | 3 +- packages/core/src/env-set/index.ts | 6 +- packages/core/src/event-listeners/index.ts | 7 +- packages/core/src/index.ts | 44 +--------- .../core/src/libraries/hook/index.test.ts | 2 + packages/core/src/libraries/hook/index.ts | 4 +- packages/core/src/libraries/jwt-customizer.ts | 23 +++-- packages/core/src/libraries/logto-config.ts | 6 +- packages/core/src/main.ts | 44 ++++++++++ .../core/src/middleware/koa-auth/index.ts | 4 +- .../core/src/middleware/koa-error-handler.ts | 12 ++- packages/core/src/middleware/koa-guard.ts | 15 +++- .../src/middleware/koa-oidc-error-handler.ts | 6 +- .../src/routes/interaction/actions/helpers.ts | 4 +- .../interaction/actions/submit-interaction.ts | 7 +- .../middleware/koa-interaction-hooks.ts | 8 +- .../core/src/routes/logto-config/index.ts | 7 +- .../logto-config/jwt-customizer.test.ts | 41 +++++---- .../src/routes/logto-config/jwt-customizer.ts | 11 +-- packages/core/src/routes/swagger/index.ts | 4 +- .../core/src/routes/swagger/utils/general.ts | 4 +- packages/core/src/routes/user-assets.ts | 4 +- packages/core/src/tenants/SystemContext.ts | 4 +- packages/core/src/tenants/Tenant.test.ts | 10 +-- packages/core/src/tenants/Tenant.ts | 14 ++- packages/core/src/tenants/index.ts | 9 +- packages/core/src/utils/cloudflare/utils.ts | 5 +- packages/core/src/utils/connectors/index.ts | 2 +- packages/core/src/utils/console.test.ts | 47 ++++++++++ packages/core/src/utils/console.ts | 55 +++++++++++- packages/core/src/utils/request.ts | 21 +++++ packages/core/src/utils/tenant.ts | 5 +- .../src/tests/api/health-check.test.ts | 5 ++ .../src/tests/api/well-known.test.ts | 5 ++ .../shared/src/node/env/ConsoleLog.test.ts | 87 +++++++++++++++++++ packages/shared/src/node/env/ConsoleLog.ts | 33 +++++-- 43 files changed, 498 insertions(+), 139 deletions(-) create mode 100644 .changeset/green-phones-visit.md create mode 100644 .changeset/grumpy-cougars-perform.md create mode 100644 packages/core/src/caches/utils.ts create mode 100644 packages/core/src/main.ts create mode 100644 packages/core/src/utils/console.test.ts create mode 100644 packages/core/src/utils/request.ts create mode 100644 packages/shared/src/node/env/ConsoleLog.test.ts diff --git a/.changeset/green-phones-visit.md b/.changeset/green-phones-visit.md new file mode 100644 index 000000000..2fc47bddb --- /dev/null +++ b/.changeset/green-phones-visit.md @@ -0,0 +1,5 @@ +--- +"@logto/app-insights": patch +--- + +allow additional telemetry for `trackException()` diff --git a/.changeset/grumpy-cougars-perform.md b/.changeset/grumpy-cougars-perform.md new file mode 100644 index 000000000..acc72b4b6 --- /dev/null +++ b/.changeset/grumpy-cougars-perform.md @@ -0,0 +1,8 @@ +--- +"@logto/core": patch +--- + +implement request ID for API requests + +- All requests will now include a request ID in the headers (`Logto-Core-Request-Id`) +- Terminal logs will now include the request ID as the prefix diff --git a/packages/app-insights/src/node.ts b/packages/app-insights/src/node.ts index 5ea63fb74..d95118809 100644 --- a/packages/app-insights/src/node.ts +++ b/packages/app-insights/src/node.ts @@ -1,8 +1,11 @@ import { trySafe } from '@silverhand/essentials'; import type { TelemetryClient } from 'applicationinsights'; +import { type ExceptionTelemetry } from 'applicationinsights/out/Declarations/Contracts/index.js'; import { normalizeError } from './normalize-error.js'; +export { type ExceptionTelemetry } from 'applicationinsights/out/Declarations/Contracts/index.js'; + class AppInsights { client?: TelemetryClient; @@ -29,9 +32,14 @@ class AppInsights { return true; } - /** The function is async to avoid blocking the main script and force the use of `await` or `void`. */ - async trackException(error: unknown) { - this.client?.trackException({ exception: normalizeError(error) }); + /** + * The function is async to avoid blocking the main script and force the use of `await` or `void`. + * + * @param error The error to track. It will be normalized for better telemetry. + * @param telemetry Additional telemetry to include in the exception. + */ + async trackException(error: unknown, telemetry?: Partial) { + this.client?.trackException({ exception: normalizeError(error), ...telemetry }); } } diff --git a/packages/core/src/app/init.ts b/packages/core/src/app/init.ts index 5ac044aff..642868071 100644 --- a/packages/core/src/app/init.ts +++ b/packages/core/src/app/init.ts @@ -2,17 +2,21 @@ import fs from 'node:fs/promises'; import http2 from 'node:http2'; import { appInsights } from '@logto/app-insights/node'; +import { ConsoleLog } from '@logto/shared'; import { toTitle, trySafe } from '@silverhand/essentials'; import chalk from 'chalk'; import type Koa from 'koa'; +import koaLogger from 'koa-logger'; +import { nanoid } from 'nanoid'; import { EnvSet } from '#src/env-set/index.js'; import { TenantNotFoundError, tenantPool } from '#src/tenants/index.js'; -import { consoleLog } from '#src/utils/console.js'; +import { buildAppInsightsTelemetry } from '#src/utils/request.js'; import { getTenantId } from '#src/utils/tenant.js'; const logListening = (type: 'core' | 'admin' = 'core') => { const urlSet = type === 'core' ? EnvSet.values.urlSet : EnvSet.values.adminUrlSet; + const consoleLog = new ConsoleLog(chalk.magenta(type)); for (const url of urlSet.deduplicated()) { consoleLog.info(chalk.bold(`${toTitle(type)} app is running at ${url.toString()}`)); @@ -22,6 +26,22 @@ const logListening = (type: 'core' | 'admin' = 'core') => { const serverTimeout = 120_000; export default async function initApp(app: Koa): Promise { + app.use(async (ctx, next) => { + const requestId = nanoid(16); + const consoleLog = new ConsoleLog(chalk.blue(requestId)); + ctx.requestId = requestId; + ctx.console = consoleLog; + + await koaLogger({ + transporter: (string) => { + consoleLog.plain(string); + }, + })(ctx, next); + + // Set the header in the end to avoid other middleware from overwriting it + ctx.set('Logto-Core-Request-Id', requestId); + }); + app.use(async (ctx, next) => { if (EnvSet.values.isDomainBasedMultiTenancy && ['/status', '/'].includes(ctx.URL.pathname)) { ctx.status = 204; @@ -43,7 +63,7 @@ export default async function initApp(app: Koa): Promise { const tenant = await trySafe(tenantPool.get(tenantId, customEndpoint), (error) => { ctx.status = error instanceof TenantNotFoundError ? 404 : 500; - void appInsights.trackException(error); + void appInsights.trackException(error, buildAppInsightsTelemetry(ctx)); }); if (!tenant) { @@ -56,7 +76,7 @@ export default async function initApp(app: Koa): Promise { tenant.requestEnd(); } catch (error: unknown) { tenant.requestEnd(); - void appInsights.trackException(error); + void appInsights.trackException(error, buildAppInsightsTelemetry(ctx)); throw error; } diff --git a/packages/core/src/caches/index.ts b/packages/core/src/caches/index.ts index 9e9e6bc5e..c9d0fdaa1 100644 --- a/packages/core/src/caches/index.ts +++ b/packages/core/src/caches/index.ts @@ -5,9 +5,9 @@ import { type Optional, conditional, yes, trySafe } from '@silverhand/essentials import { createClient, createCluster, type RedisClientType, type RedisClusterType } from 'redis'; import { EnvSet } from '#src/env-set/index.js'; -import { consoleLog } from '#src/utils/console.js'; import { type CacheStore } from './types.js'; +import { cacheConsole } from './utils.js'; abstract class RedisCacheBase implements CacheStore { readonly client?: RedisClientType | RedisClusterType; @@ -32,17 +32,17 @@ abstract class RedisCacheBase implements CacheStore { const pong = await this.ping(); if (pong === 'PONG') { - consoleLog.info('[CACHE] Connected to Redis'); + cacheConsole.info('Connected to Redis'); return; } } - consoleLog.warn('[CACHE] No Redis client initialized, skipping'); + cacheConsole.warn('No Redis client initialized, skipping'); } async disconnect() { if (this.client) { await this.client.disconnect(); - consoleLog.info('[CACHE] Disconnected from Redis'); + cacheConsole.info('Disconnected from Redis'); } } diff --git a/packages/core/src/caches/utils.ts b/packages/core/src/caches/utils.ts new file mode 100644 index 000000000..98c61d5ed --- /dev/null +++ b/packages/core/src/caches/utils.ts @@ -0,0 +1,4 @@ +import { ConsoleLog } from '@logto/shared'; +import chalk from 'chalk'; + +export const cacheConsole = new ConsoleLog(chalk.magenta('cache')); diff --git a/packages/core/src/caches/well-known.ts b/packages/core/src/caches/well-known.ts index 4686643d0..044d99b5e 100644 --- a/packages/core/src/caches/well-known.ts +++ b/packages/core/src/caches/well-known.ts @@ -3,9 +3,9 @@ import { type Optional, trySafe } from '@silverhand/essentials'; import { type ZodType, z } from 'zod'; import { type ConnectorWellKnown, connectorWellKnownGuard } from '#src/utils/connectors/types.js'; -import { consoleLog } from '#src/utils/console.js'; import { type CacheStore } from './types.js'; +import { cacheConsole } from './utils.js'; type WellKnownMap = { sie: SignInExperience; @@ -177,7 +177,7 @@ export class WellKnownCache { const cachedValue = await trySafe(kvCache.get(type, promiseKey)); if (cachedValue) { - consoleLog.info('[CACHE] Well-known cache hit for', type, promiseKey); + cacheConsole.info('Well-known cache hit for', type, promiseKey); return cachedValue; } diff --git a/packages/core/src/env-set/check-alteration-state.ts b/packages/core/src/env-set/check-alteration-state.ts index 42eaad7c1..4fc8d3c73 100644 --- a/packages/core/src/env-set/check-alteration-state.ts +++ b/packages/core/src/env-set/check-alteration-state.ts @@ -1,8 +1,9 @@ import { getAvailableAlterations } from '@logto/cli/lib/commands/database/alteration/index.js'; +import { ConsoleLog } from '@logto/shared'; import type { DatabasePool } from '@silverhand/slonik'; import chalk from 'chalk'; -import { consoleLog } from '#src/utils/console.js'; +const consoleLog = new ConsoleLog(chalk.magenta('db-alt')); export const checkAlterationState = async (pool: DatabasePool) => { const alterations = await getAvailableAlterations(pool); diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts index 20b395fd5..1aa82b48b 100644 --- a/packages/core/src/env-set/index.ts +++ b/packages/core/src/env-set/index.ts @@ -1,7 +1,8 @@ -import { GlobalValues } from '@logto/shared'; +import { ConsoleLog, GlobalValues } from '@logto/shared'; import type { Optional } from '@silverhand/essentials'; import { appendPath } from '@silverhand/essentials'; import type { DatabasePool } from '@silverhand/slonik'; +import chalk from 'chalk'; import { createLogtoConfigLibrary } from '#src/libraries/logto-config.js'; import { createLogtoConfigQueries } from '#src/queries/logto-config.js'; @@ -72,11 +73,12 @@ export class EnvSet { this.#pool = pool; + const consoleLog = new ConsoleLog(chalk.magenta('env-set')); const { getOidcConfigs } = createLogtoConfigLibrary({ logtoConfigs: createLogtoConfigQueries(pool), }); - const oidcConfigs = await getOidcConfigs(); + const oidcConfigs = await getOidcConfigs(consoleLog); const endpoint = customDomain ? new URL(customDomain) : getTenantEndpoint(this.tenantId, EnvSet.values); diff --git a/packages/core/src/event-listeners/index.ts b/packages/core/src/event-listeners/index.ts index 04790fd3c..402d1c929 100644 --- a/packages/core/src/event-listeners/index.ts +++ b/packages/core/src/event-listeners/index.ts @@ -1,12 +1,15 @@ +import { ConsoleLog } from '@logto/shared'; +import chalk from 'chalk'; import type Provider from 'oidc-provider'; import type Queries from '#src/tenants/Queries.js'; -import { consoleLog } from '#src/utils/console.js'; import { grantListener, grantRevocationListener } from './grant.js'; import { interactionEndedListener, interactionStartedListener } from './interaction.js'; import { recordActiveUsers } from './record-active-users.js'; +const consoleLog = new ConsoleLog(chalk.magenta('oidc')); + /** * @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/docs/README.md#im-getting-a-client-authentication-failed-error-with-no-details Getting auth error with no details?} * @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/docs/events.md OIDC Provider events} @@ -27,7 +30,7 @@ export const addOidcEventListeners = (provider: Provider, queries: Queries) => { provider.addListener('interaction.started', interactionStartedListener); provider.addListener('interaction.ended', interactionEndedListener); provider.addListener('server_error', (_, error) => { - consoleLog.error('OIDC Provider server_error:', error); + consoleLog.error('server_error:', error); }); // Record token usage. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b06f4d14c..93813a75d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,48 +1,6 @@ -import { trySafe } from '@silverhand/essentials'; import dotenv from 'dotenv'; import { findUp } from 'find-up'; -import Koa from 'koa'; - -import { checkAlterationState } from './env-set/check-alteration-state.js'; -import SystemContext from './tenants/SystemContext.js'; -import { consoleLog } from './utils/console.js'; dotenv.config({ path: await findUp('.env', {}) }); -const { appInsights } = await import('@logto/app-insights/node'); - -if (await appInsights.setup('core')) { - consoleLog.info('Initialized ApplicationInsights'); -} - -// Import after env has been configured -const { loadConnectorFactories } = await import('./utils/connectors/index.js'); -const { EnvSet } = await import('./env-set/index.js'); -const { redisCache } = await import('./caches/index.js'); -const { default: initI18n } = await import('./i18n/init.js'); -const { tenantPool, checkRowLevelSecurity } = await import('./tenants/index.js'); - -try { - const app = new Koa({ - proxy: EnvSet.values.trustProxyHeader, - }); - const sharedAdminPool = await EnvSet.sharedPool; - - await Promise.all([ - initI18n(), - redisCache.connect(), - loadConnectorFactories(), - checkRowLevelSecurity(sharedAdminPool), - checkAlterationState(sharedAdminPool), - SystemContext.shared.loadProviderConfigs(sharedAdminPool), - ]); - - // Import last until init completed - const { default: initApp } = await import('./app/init.js'); - await initApp(app); -} catch (error: unknown) { - consoleLog.error('Error while initializing app:'); - consoleLog.error(error); - - void Promise.all([trySafe(tenantPool.endAll()), trySafe(redisCache.disconnect())]); -} +await import('./main.js'); diff --git a/packages/core/src/libraries/hook/index.test.ts b/packages/core/src/libraries/hook/index.test.ts index 06155d024..033aec1d6 100644 --- a/packages/core/src/libraries/hook/index.test.ts +++ b/packages/core/src/libraries/hook/index.test.ts @@ -1,5 +1,6 @@ import type { Hook } from '@logto/schemas'; import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas'; +import { ConsoleLog } from '@logto/shared'; import { createMockUtils } from '@logto/shared/esm'; import RequestError from '#src/errors/RequestError/index.js'; @@ -73,6 +74,7 @@ describe('triggerInteractionHooks()', () => { jest.useFakeTimers().setSystemTime(100_000); await triggerInteractionHooks( + new ConsoleLog(), { event: InteractionEvent.SignIn, sessionId: 'some_jti', applicationId: 'some_client' }, { userId: '123' } ); diff --git a/packages/core/src/libraries/hook/index.ts b/packages/core/src/libraries/hook/index.ts index 0689fd7bf..c6a889607 100644 --- a/packages/core/src/libraries/hook/index.ts +++ b/packages/core/src/libraries/hook/index.ts @@ -7,14 +7,13 @@ import { type HookConfig, type HookTestErrorResponseData, } from '@logto/schemas'; -import { generateStandardId } from '@logto/shared'; +import { type ConsoleLog, generateStandardId } from '@logto/shared'; import { conditional, pick, trySafe } from '@silverhand/essentials'; import { HTTPError } from 'ky'; import RequestError from '#src/errors/RequestError/index.js'; import { LogEntry } from '#src/middleware/koa-audit-log.js'; import type Queries from '#src/tenants/Queries.js'; -import { consoleLog } from '#src/utils/console.js'; import { generateHookTestPayload, parseResponse, sendWebhookRequest } from './utils.js'; @@ -55,6 +54,7 @@ export const createHookLibrary = (queries: Queries) => { } = queries; const triggerInteractionHooks = async ( + consoleLog: ConsoleLog, interactionContext: InteractionHookContext, interactionResult: InteractionHookResult, userAgent?: string diff --git a/packages/core/src/libraries/jwt-customizer.ts b/packages/core/src/libraries/jwt-customizer.ts index fae9698cd..d8be39a76 100644 --- a/packages/core/src/libraries/jwt-customizer.ts +++ b/packages/core/src/libraries/jwt-customizer.ts @@ -5,6 +5,7 @@ import { type JwtCustomizerType, type JwtCustomizerUserContext, } from '@logto/schemas'; +import { type ConsoleLog } from '@logto/shared'; import { deduplicate, pick, pickState, assert } from '@silverhand/essentials'; import deepmerge from 'deepmerge'; @@ -94,14 +95,17 @@ export const createJwtCustomizerLibrary = ( * @params payload.value - JWT customizer value * @params payload.useCase - The use case of JWT customizer script, can be either `test` or `production`. */ - const deployJwtCustomizerScript = async (payload: { - key: T; - value: JwtCustomizerType[T]; - useCase: 'test' | 'production'; - }) => { + const deployJwtCustomizerScript = async ( + consoleLog: ConsoleLog, + payload: { + key: T; + value: JwtCustomizerType[T]; + useCase: 'test' | 'production'; + } + ) => { const [client, jwtCustomizers] = await Promise.all([ cloudConnection.getClient(), - getJwtCustomizers(), + getJwtCustomizers(consoleLog), ]); const customizerScriptsFromDatabase = getJwtCustomizerScripts(jwtCustomizers); @@ -127,10 +131,13 @@ export const createJwtCustomizerLibrary = ( }); }; - const undeployJwtCustomizerScript = async (key: T) => { + const undeployJwtCustomizerScript = async ( + consoleLog: ConsoleLog, + key: T + ) => { const [client, jwtCustomizers] = await Promise.all([ cloudConnection.getClient(), - getJwtCustomizers(), + getJwtCustomizers(consoleLog), ]); assert(jwtCustomizers[key], new RequestError({ code: 'entity.not_exists', key })); diff --git a/packages/core/src/libraries/logto-config.ts b/packages/core/src/libraries/logto-config.ts index 54fed67a6..2f7a55a92 100644 --- a/packages/core/src/libraries/logto-config.ts +++ b/packages/core/src/libraries/logto-config.ts @@ -8,12 +8,12 @@ import { jwtCustomizerConfigGuard, logtoOidcConfigGuard, } from '@logto/schemas'; +import { type ConsoleLog } from '@logto/shared'; import chalk from 'chalk'; import { ZodError, z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import type Queries from '#src/tenants/Queries.js'; -import { consoleLog } from '#src/utils/console.js'; export type LogtoConfigLibrary = ReturnType; @@ -24,7 +24,7 @@ export const createLogtoConfigLibrary = ({ upsertJwtCustomizer: queryUpsertJwtCustomizer, }, }: Pick) => { - const getOidcConfigs = async (): Promise => { + const getOidcConfigs = async (consoleLog: ConsoleLog): Promise => { try { const { rows } = await getRowsByKeys(Object.values(LogtoOidcConfigKey)); @@ -96,7 +96,7 @@ export const createLogtoConfigLibrary = ({ return z.object({ value: jwtCustomizerConfigGuard[key] }).parse(rows[0]).value; }; - const getJwtCustomizers = async (): Promise> => { + const getJwtCustomizers = async (consoleLog: ConsoleLog): Promise> => { try { const { rows } = await getRowsByKeys(Object.values(LogtoJwtTokenKey)); diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts new file mode 100644 index 000000000..3144800d6 --- /dev/null +++ b/packages/core/src/main.ts @@ -0,0 +1,44 @@ +import { ConsoleLog } from '@logto/shared'; +import { trySafe } from '@silverhand/essentials'; +import chalk from 'chalk'; +import Koa from 'koa'; + +import { redisCache } from './caches/index.js'; +import { checkAlterationState } from './env-set/check-alteration-state.js'; +import { EnvSet } from './env-set/index.js'; +import initI18n from './i18n/init.js'; +import SystemContext from './tenants/SystemContext.js'; +import { checkRowLevelSecurity, tenantPool } from './tenants/index.js'; +import { loadConnectorFactories } from './utils/connectors/index.js'; + +const { appInsights } = await import('@logto/app-insights/node'); +const consoleLog = new ConsoleLog(chalk.magenta('index')); + +if (await appInsights.setup('core')) { + consoleLog.info('Initialized ApplicationInsights'); +} + +try { + const app = new Koa({ + proxy: EnvSet.values.trustProxyHeader, + }); + const sharedAdminPool = await EnvSet.sharedPool; + + await Promise.all([ + initI18n(), + redisCache.connect(), + loadConnectorFactories(), + checkRowLevelSecurity(sharedAdminPool), + checkAlterationState(sharedAdminPool), + SystemContext.shared.loadProviderConfigs(sharedAdminPool), + ]); + + // Import last until init completed + const { default: initApp } = await import('./app/init.js'); + await initApp(app); +} catch (error: unknown) { + consoleLog.error('Error while initializing app:'); + consoleLog.error(error); + + void Promise.all([trySafe(tenantPool.endAll()), trySafe(redisCache.disconnect())]); +} diff --git a/packages/core/src/middleware/koa-auth/index.ts b/packages/core/src/middleware/koa-auth/index.ts index 1cb894570..826e784ef 100644 --- a/packages/core/src/middleware/koa-auth/index.ts +++ b/packages/core/src/middleware/koa-auth/index.ts @@ -11,7 +11,7 @@ import { z } from 'zod'; import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import assertThat from '#src/utils/assert-that.js'; -import { consoleLog } from '#src/utils/console.js'; +import { devConsole } from '#src/utils/console.js'; import { getAdminTenantTokenValidationSet } from './utils.js'; @@ -60,7 +60,7 @@ export const verifyBearerTokenFromRequest = async ( if ((!isProduction || isIntegrationTest) && userId) { // This log is distracting in integration tests. if (!isIntegrationTest) { - consoleLog.warn(`Found dev user ID ${userId}, skip token validation.`); + devConsole.warn(`Found dev user ID ${userId}, skip token validation.`); } return { diff --git a/packages/core/src/middleware/koa-error-handler.ts b/packages/core/src/middleware/koa-error-handler.ts index 28925f565..24a54770f 100644 --- a/packages/core/src/middleware/koa-error-handler.ts +++ b/packages/core/src/middleware/koa-error-handler.ts @@ -5,14 +5,22 @@ import { HttpError } from 'koa'; import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; -import { consoleLog } from '#src/utils/console.js'; +import { getConsoleLogFromContext } from '#src/utils/console.js'; +import { buildAppInsightsTelemetry } from '#src/utils/request.js'; +/** + * The middleware to handle errors. + * + * Note: A context-aware console log is required to be present in the context (i.e. `ctx.console`). + */ export default function koaErrorHandler(): Middleware< StateT, ContextT, BodyT | RequestErrorBody | { message: string } > { return async (ctx, next) => { + const consoleLog = getConsoleLogFromContext(ctx); + try { await next(); } catch (error: unknown) { @@ -21,7 +29,7 @@ export default function koaErrorHandler(): Middleware< } // Report all exceptions to ApplicationInsights - void appInsights.trackException(error); + void appInsights.trackException(error, buildAppInsightsTelemetry(ctx)); if (error instanceof RequestError) { ctx.status = error.status; diff --git a/packages/core/src/middleware/koa-guard.ts b/packages/core/src/middleware/koa-guard.ts index 339538103..75e87aa2a 100644 --- a/packages/core/src/middleware/koa-guard.ts +++ b/packages/core/src/middleware/koa-guard.ts @@ -9,7 +9,8 @@ import type { ZodType, ZodTypeDef } from 'zod'; import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import { ResponseBodyError, StatusCodeError } from '#src/errors/ServerError/index.js'; -import { consoleLog } from '#src/utils/console.js'; +import { getConsoleLogFromContext } from '#src/utils/console.js'; +import { buildAppInsightsTelemetry } from '#src/utils/request.js'; /** Configure what and how to guard. */ export type GuardConfig = { @@ -121,6 +122,11 @@ const tryParse = ( return parse(type, guard, data); }; +/** + * Guard middleware factory for request and response. + * + * Note: A context-aware console log is required to be present in the context (i.e. `ctx.console`). + */ export default function koaGuard< StateT, ContextT extends IRouterParamContext, @@ -170,6 +176,8 @@ export default function koaGuard< GuardResponseT > > = async function (ctx, next) { + const consoleLog = getConsoleLogFromContext(ctx); + /** * Assert the status code matches the value(s) in the config. If the config does not * specify a status code, it will not assert anything. @@ -191,7 +199,10 @@ export default function koaGuard< if (EnvSet.values.isProduction) { consoleLog.warn('Unexpected status code:', value, 'expected:', status); - void appInsights.trackException(new StatusCodeError(status, value)); + void appInsights.trackException( + new StatusCodeError(status, value), + buildAppInsightsTelemetry(ctx) + ); return; } diff --git a/packages/core/src/middleware/koa-oidc-error-handler.ts b/packages/core/src/middleware/koa-oidc-error-handler.ts index c208fa83e..911381852 100644 --- a/packages/core/src/middleware/koa-oidc-error-handler.ts +++ b/packages/core/src/middleware/koa-oidc-error-handler.ts @@ -5,7 +5,7 @@ import { errors } from 'oidc-provider'; import { z } from 'zod'; import { EnvSet } from '#src/env-set/index.js'; -import { consoleLog } from '#src/utils/console.js'; +import { getConsoleLogFromContext } from '#src/utils/console.js'; /** * Supplementary URIs for oidc-provider errors. @@ -18,6 +18,8 @@ const errorUris: Record = Object.freeze({ * Transform oidc-provider error to a format for the client. This is edited from oidc-provider's * own implementation. * + * Note: A context-aware console log is required to be present in the context (i.e. `ctx.console`). + * * @see {@link https://github.com/panva/node-oidc-provider/blob/37d0a6cfb3c618141a44cbb904ce45659438f821/lib/helpers/err_out.js | oidc-provider/lib/helpers/err_out.js} */ export const errorOut = ({ @@ -92,7 +94,7 @@ export default function koaOidcErrorHandler(): Middleware= 500)) { - consoleLog.error(error); + getConsoleLogFromContext(ctx).error(error); } } diff --git a/packages/core/src/routes/interaction/actions/helpers.ts b/packages/core/src/routes/interaction/actions/helpers.ts index d64a4a44d..9bc5629a5 100644 --- a/packages/core/src/routes/interaction/actions/helpers.ts +++ b/packages/core/src/routes/interaction/actions/helpers.ts @@ -1,5 +1,4 @@ import { defaults, parseAffiliateData } from '@logto/affiliate'; -import { consoleLog } from '@logto/cli/lib/utils.js'; import { type CreateUser, type User, adminTenantId } from '@logto/schemas'; import { conditional, trySafe } from '@silverhand/essentials'; import { type IRouterContext } from 'koa-router'; @@ -10,6 +9,7 @@ import { type ConnectorLibrary } from '#src/libraries/connector.js'; import { encryptUserPassword } from '#src/libraries/user.js'; import type Queries from '#src/tenants/Queries.js'; import type TenantContext from '#src/tenants/TenantContext.js'; +import { getConsoleLogFromContext } from '#src/utils/console.js'; import { type OmitAutoSetFields } from '#src/utils/sql.js'; import { @@ -146,6 +146,6 @@ export const postAffiliateLogs = async ( await client.post('/api/affiliate-logs', { body: { userId, ...affiliateData }, }); - consoleLog.info('Affiliate logs posted', userId); + getConsoleLogFromContext(ctx).info('Affiliate logs posted', userId); } }; diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts index 7f4583c56..0abf8690c 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -24,7 +24,8 @@ import { assignInteractionResults } from '#src/libraries/session.js'; import { encryptUserPassword } from '#src/libraries/user.js'; import type { LogEntry, WithLogContext } from '#src/middleware/koa-audit-log.js'; import type TenantContext from '#src/tenants/TenantContext.js'; -import { consoleLog } from '#src/utils/console.js'; +import { getConsoleLogFromContext } from '#src/utils/console.js'; +import { buildAppInsightsTelemetry } from '#src/utils/request.js'; import { getTenantId } from '#src/utils/tenant.js'; import type { WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js'; @@ -180,8 +181,8 @@ async function handleSubmitRegister( log?.append({ userId: id }); appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.Register) }); void trySafe(postAffiliateLogs(ctx, cloudConnection, id, tenantId), (error) => { - consoleLog.warn('Failed to post affiliate logs', error); - void appInsights.trackException(error); + getConsoleLogFromContext(ctx).warn('Failed to post affiliate logs', error); + void appInsights.trackException(error, buildAppInsightsTelemetry(ctx)); }); } diff --git a/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts b/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts index c1e344651..c8e7424f2 100644 --- a/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts +++ b/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts @@ -7,6 +7,7 @@ import { type InteractionHookResult, } from '#src/libraries/hook/index.js'; import type Libraries from '#src/tenants/Libraries.js'; +import { getConsoleLogFromContext } from '#src/utils/console.js'; import { getInteractionStorage } from '../utils/interaction.js'; @@ -64,7 +65,12 @@ export default function koaInteractionHooks< if (interactionHookResult) { // Hooks should not crash the app void trySafe( - triggerInteractionHooks(interactionHookContext, interactionHookResult, userAgent) + triggerInteractionHooks( + getConsoleLogFromContext(ctx), + interactionHookContext, + interactionHookResult, + userAgent + ) ); } }; diff --git a/packages/core/src/routes/logto-config/index.ts b/packages/core/src/routes/logto-config/index.ts index b7dabb493..7111fbce7 100644 --- a/packages/core/src/routes/logto-config/index.ts +++ b/packages/core/src/routes/logto-config/index.ts @@ -17,6 +17,7 @@ import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import { getConsoleLogFromContext } from '#src/utils/console.js'; import { exportJWK } from '#src/utils/jwks.js'; import type { ManagementApiRouter, RouterInitArgs } from '../types.js'; @@ -104,7 +105,7 @@ export default function logtoConfigRoutes( async (ctx, next) => { const { keyType } = ctx.guard.params; const configKey = getOidcConfigKeyDatabaseColumnName(keyType); - const configs = await getOidcConfigs(); + const configs = await getOidcConfigs(getConsoleLogFromContext(ctx)); // Remove actual values of the private keys from response ctx.body = await getRedactedOidcKeyResponse(configKey, configs[configKey]); @@ -125,7 +126,7 @@ export default function logtoConfigRoutes( async (ctx, next) => { const { keyType, keyId } = ctx.guard.params; const configKey = getOidcConfigKeyDatabaseColumnName(keyType); - const configs = await getOidcConfigs(); + const configs = await getOidcConfigs(getConsoleLogFromContext(ctx)); const existingKeys = configs[configKey]; if (existingKeys.length <= 1) { @@ -163,7 +164,7 @@ export default function logtoConfigRoutes( const { keyType } = ctx.guard.params; const { signingKeyAlgorithm } = ctx.guard.body; const configKey = getOidcConfigKeyDatabaseColumnName(keyType); - const configs = await getOidcConfigs(); + const configs = await getOidcConfigs(getConsoleLogFromContext(ctx)); const existingKeys = configs[configKey]; const newPrivateKey = diff --git a/packages/core/src/routes/logto-config/jwt-customizer.test.ts b/packages/core/src/routes/logto-config/jwt-customizer.test.ts index 56d560c50..0c5e3428c 100644 --- a/packages/core/src/routes/logto-config/jwt-customizer.test.ts +++ b/packages/core/src/routes/logto-config/jwt-customizer.test.ts @@ -3,6 +3,7 @@ import { LogtoJwtTokenKeyType, type JwtCustomizerTestRequestBody, } from '@logto/schemas'; +import { ConsoleLog } from '@logto/shared'; import { pickDefault } from '@logto/shared/esm'; import { pick } from '@silverhand/essentials'; @@ -60,11 +61,14 @@ describe('configs JWT customizer routes', () => { .put(`/configs/jwt-customizer/access-token`) .send(mockJwtCustomizerConfigForAccessToken.value); - expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({ - key: LogtoJwtTokenKey.AccessToken, - value: mockJwtCustomizerConfigForAccessToken.value, - useCase: 'production', - }); + expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith( + expect.any(ConsoleLog), + { + key: LogtoJwtTokenKey.AccessToken, + value: mockJwtCustomizerConfigForAccessToken.value, + useCase: 'production', + } + ); expect(mockLogtoConfigsLibrary.upsertJwtCustomizer).toHaveBeenCalledWith( LogtoJwtTokenKey.AccessToken, @@ -102,11 +106,14 @@ describe('configs JWT customizer routes', () => { .patch('/configs/jwt-customizer/access-token') .send(mockJwtCustomizerConfigForAccessToken.value); - expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({ - key: LogtoJwtTokenKey.AccessToken, - value: mockJwtCustomizerConfigForAccessToken.value, - useCase: 'production', - }); + expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith( + expect.any(ConsoleLog), + { + key: LogtoJwtTokenKey.AccessToken, + value: mockJwtCustomizerConfigForAccessToken.value, + useCase: 'production', + } + ); expect(mockLogtoConfigsLibrary.updateJwtCustomizer).toHaveBeenCalledWith( LogtoJwtTokenKey.AccessToken, @@ -141,6 +148,7 @@ describe('configs JWT customizer routes', () => { it('DELETE /configs/jwt-customizer/:tokenType should delete the record', async () => { const response = await routeRequester.delete('/configs/jwt-customizer/client-credentials'); expect(tenantContext.libraries.jwtCustomizers.undeployJwtCustomizerScript).toHaveBeenCalledWith( + expect.any(ConsoleLog), LogtoJwtTokenKey.ClientCredentials ); expect(logtoConfigQueries.deleteJwtCustomizer).toHaveBeenCalledWith( @@ -163,11 +171,14 @@ describe('configs JWT customizer routes', () => { const response = await routeRequester.post('/configs/jwt-customizer/test').send(payload); - expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({ - key: LogtoJwtTokenKey.ClientCredentials, - value: payload, - useCase: 'test', - }); + expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith( + expect.any(ConsoleLog), + { + key: LogtoJwtTokenKey.ClientCredentials, + value: payload, + useCase: 'test', + } + ); expect(mockCloudClient.post).toHaveBeenCalledWith('/api/services/custom-jwt', { body: payload, diff --git a/packages/core/src/routes/logto-config/jwt-customizer.ts b/packages/core/src/routes/logto-config/jwt-customizer.ts index ac8bd2473..f69cadaa6 100644 --- a/packages/core/src/routes/logto-config/jwt-customizer.ts +++ b/packages/core/src/routes/logto-config/jwt-customizer.ts @@ -15,6 +15,7 @@ import { EnvSet } from '#src/env-set/index.js'; import RequestError, { formatZodError } from '#src/errors/RequestError/index.js'; import koaGuard, { parse } from '#src/middleware/koa-guard.js'; import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; +import { getConsoleLogFromContext } from '#src/utils/console.js'; import type { ManagementApiRouter, RouterInitArgs } from '../types.js'; @@ -78,7 +79,7 @@ export default function logtoConfigJwtCustomizerRoutes { - const jwtCustomizer = await getJwtCustomizers(); + const jwtCustomizer = await getJwtCustomizers(getConsoleLogFromContext(ctx)); ctx.body = Object.values(LogtoJwtTokenKey) .filter((key) => jwtCustomizer[key]) .map((key) => ({ key, value: jwtCustomizer[key] })); @@ -194,7 +195,7 @@ export default function logtoConfigJwtCustomizerRoutes value.charAt(0).toUpperCase() + value.slice(1); @@ -166,7 +166,7 @@ export const validateSupplement = ( export const validateSwaggerDocument = (document: OpenAPIV3.Document) => { for (const [path, operations] of Object.entries(document.paths)) { if (path.startsWith('/api/interaction')) { - consoleLog.warn(`Path \`${path}\` is not documented. Do something!`); + devConsole.warn(`Path \`${path}\` is not documented. Do something!`); continue; } diff --git a/packages/core/src/routes/user-assets.ts b/packages/core/src/routes/user-assets.ts index 19c155bcc..cf1f7e637 100644 --- a/packages/core/src/routes/user-assets.ts +++ b/packages/core/src/routes/user-assets.ts @@ -15,7 +15,7 @@ import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import SystemContext from '#src/tenants/SystemContext.js'; import assertThat from '#src/utils/assert-that.js'; -import { consoleLog } from '#src/utils/console.js'; +import { getConsoleLogFromContext } from '#src/utils/console.js'; import { uploadFileGuard } from '#src/utils/storage/consts.js'; import { buildUploadFile } from '#src/utils/storage/index.js'; import { getTenantId } from '#src/utils/tenant.js'; @@ -92,7 +92,7 @@ export default function userAssetsRoutes( ctx.body = result; } catch (error: unknown) { - consoleLog.error(error); + getConsoleLogFromContext(ctx).error(error); throw new RequestError({ code: 'storage.upload_error', status: 500, diff --git a/packages/core/src/tenants/SystemContext.ts b/packages/core/src/tenants/SystemContext.ts index 2c9661aa9..49bd393ca 100644 --- a/packages/core/src/tenants/SystemContext.ts +++ b/packages/core/src/tenants/SystemContext.ts @@ -13,7 +13,7 @@ import type { CommonQueryMethods } from '@silverhand/slonik'; import { type ZodType } from 'zod'; import { createSystemsQuery } from '#src/queries/system.js'; -import { consoleLog } from '#src/utils/console.js'; +import { devConsole } from '#src/utils/console.js'; export default class SystemContext { static shared = new SystemContext(); @@ -70,7 +70,7 @@ export default class SystemContext { const result = guard.safeParse(record.value); if (!result.success) { - consoleLog.error(`Failed to parse ${key} config:`, result.error); + devConsole.error(`Failed to parse ${key} config:`, result.error); return; } diff --git a/packages/core/src/tenants/Tenant.test.ts b/packages/core/src/tenants/Tenant.test.ts index f549b55a5..c250a4de6 100644 --- a/packages/core/src/tenants/Tenant.test.ts +++ b/packages/core/src/tenants/Tenant.test.ts @@ -54,7 +54,7 @@ describe('Tenant', () => { }); it('should call middleware factories for user tenants', async () => { - await Tenant.create(defaultTenantId, new RedisCache()); + await Tenant.create({ id: defaultTenantId, redisCache: new RedisCache() }); for (const [, middleware, shouldCall] of userMiddlewareList) { if (shouldCall) { @@ -66,7 +66,7 @@ describe('Tenant', () => { }); it('should call middleware factories for the admin tenant', async () => { - await Tenant.create(adminTenantId, new RedisCache()); + await Tenant.create({ id: adminTenantId, redisCache: new RedisCache() }); for (const [, middleware, shouldCall] of adminMiddlewareList) { if (shouldCall) { @@ -80,7 +80,7 @@ describe('Tenant', () => { describe('Tenant `.run()`', () => { it('should return a function ', async () => { - const tenant = await Tenant.create(defaultTenantId, new RedisCache()); + const tenant = await Tenant.create({ id: defaultTenantId, redisCache: new RedisCache() }); expect(typeof tenant.run).toBe('function'); }); }); @@ -88,7 +88,7 @@ describe('Tenant `.run()`', () => { describe('Tenant cache health check', () => { it('should set expiration timestamp in redis', async () => { const redisCache = new RedisCache(); - const tenant = await Tenant.create(defaultTenantId, redisCache); + const tenant = await Tenant.create({ id: defaultTenantId, redisCache }); expect(typeof tenant.invalidateCache).toBe('function'); Sinon.stub(tenant.wellKnownCache, 'set').value(jest.fn()); @@ -102,7 +102,7 @@ describe('Tenant cache health check', () => { }); it('should be able to check the health of tenant cache', async () => { - const tenant = await Tenant.create(defaultTenantId, new RedisCache()); + const tenant = await Tenant.create({ id: defaultTenantId, redisCache: new RedisCache() }); expect(typeof tenant.checkHealth).toBe('function'); expect(await tenant.checkHealth()).toBe(true); diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index ece5ed4e4..5fb45590a 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -3,7 +3,6 @@ import type { MiddlewareType } from 'koa'; import Koa from 'koa'; import compose from 'koa-compose'; import koaCompress from 'koa-compress'; -import koaLogger from 'koa-logger'; import mount from 'koa-mount'; import type Provider from 'oidc-provider'; @@ -34,8 +33,18 @@ import Queries from './Queries.js'; import type TenantContext from './TenantContext.js'; import { getTenantDatabaseDsn } from './utils.js'; +/** Data for creating a tenant instance. */ +type CreateTenant = { + /** The unique identifier of the tenant. */ + id: string; + /** The cache store for the tenant. */ + redisCache: CacheStore; + /** The custom domain of the tenant, if applicable. */ + customDomain?: string; +}; + export default class Tenant implements TenantContext { - static async create(id: string, redisCache: CacheStore, customDomain?: string): Promise { + static async create({ id, redisCache, customDomain }: CreateTenant): Promise { // Treat the default database URL as the management URL const envSet = new EnvSet(id, await getTenantDatabaseDsn(id)); // Custom endpoint is used for building OIDC issuer URL when the request is a custom domain @@ -82,7 +91,6 @@ export default class Tenant implements TenantContext { // Init app const app = new Koa(); - app.use(koaLogger()); app.use(koaErrorHandler()); app.use(koaOidcErrorHandler()); app.use(koaSlonikErrorHandler()); diff --git a/packages/core/src/tenants/index.ts b/packages/core/src/tenants/index.ts index 5225a2d6c..cb39c2b44 100644 --- a/packages/core/src/tenants/index.ts +++ b/packages/core/src/tenants/index.ts @@ -1,12 +1,15 @@ +import { ConsoleLog } from '@logto/shared'; +import chalk from 'chalk'; import { LRUCache } from 'lru-cache'; import { redisCache } from '#src/caches/index.js'; import { EnvSet } from '#src/env-set/index.js'; -import { consoleLog } from '#src/utils/console.js'; import Tenant from './Tenant.js'; -export class TenantPool { +const consoleLog = new ConsoleLog(chalk.magenta('tenant')); + +class TenantPool { protected cache = new LRUCache>({ max: EnvSet.values.tenantPoolSize, dispose: async (entry) => { @@ -29,7 +32,7 @@ export class TenantPool { } consoleLog.info('Init tenant:', tenantId, customDomain); - const newTenantPromise = Tenant.create(tenantId, redisCache, customDomain); + const newTenantPromise = Tenant.create({ id: tenantId, redisCache, customDomain }); this.cache.set(cacheKey, newTenantPromise); return newTenantPromise; diff --git a/packages/core/src/utils/cloudflare/utils.ts b/packages/core/src/utils/cloudflare/utils.ts index 4a58bfce3..b6111d3e1 100644 --- a/packages/core/src/utils/cloudflare/utils.ts +++ b/packages/core/src/utils/cloudflare/utils.ts @@ -1,13 +1,16 @@ import { parseJson } from '@logto/connector-kit'; import { type CloudflareData, DomainStatus } from '@logto/schemas'; +import { ConsoleLog } from '@logto/shared'; +import chalk from 'chalk'; import { type Response } from 'got'; import { type ZodType } from 'zod'; import assertThat from '../assert-that.js'; -import { consoleLog } from '../console.js'; import { type HandleResponse, cloudflareResponseGuard } from './types.js'; +const consoleLog = new ConsoleLog(chalk.magenta('cf')); + const parseCloudflareResponse = (body: string) => { const result = cloudflareResponseGuard.safeParse(parseJson(body)); diff --git a/packages/core/src/utils/connectors/index.ts b/packages/core/src/utils/connectors/index.ts index 30d0e4070..868bbfb91 100644 --- a/packages/core/src/utils/connectors/index.ts +++ b/packages/core/src/utils/connectors/index.ts @@ -22,7 +22,7 @@ import RequestError from '#src/errors/RequestError/index.js'; import { type LogtoConnector } from './types.js'; -export const isPasswordlessLogtoConnector = ( +const isPasswordlessLogtoConnector = ( connector: LogtoConnector ): connector is LogtoConnector => connector.type !== ConnectorType.Social; diff --git a/packages/core/src/utils/console.test.ts b/packages/core/src/utils/console.test.ts new file mode 100644 index 000000000..4a98aafe4 --- /dev/null +++ b/packages/core/src/utils/console.test.ts @@ -0,0 +1,47 @@ +import { ConsoleLog } from '@logto/shared'; +import Sinon from 'sinon'; + +import { EnvSet } from '#src/env-set/index.js'; + +import { SilentConsoleLog, getConsoleLogFromContext, unknownConsole } from './console.js'; + +describe('console', () => { + afterEach(() => { + Sinon.restore(); + }); + + describe('getConsoleLogFromContext', () => { + it('should return the console log from the context', () => { + const context = { + console: new ConsoleLog('test'), + }; + const result = getConsoleLogFromContext(context); + + expect(result).toBe(context.console); + }); + + it('should throw an error if the context does not have a console log in development', () => { + Sinon.stub(EnvSet, 'values').get(() => ({ isProduction: false, isUnitTest: false })); + const context = {}; + const act = () => getConsoleLogFromContext(context); + + expect(act).toThrowError( + 'Failed to get console log from context, please provide a valid context.' + ); + }); + + it('should return a silent console log in unit test', () => { + const context = {}; + const result = getConsoleLogFromContext(context); + + expect(result).toBeInstanceOf(SilentConsoleLog); + }); + + it('should return the unknown console log in production', () => { + Sinon.stub(EnvSet, 'values').get(() => ({ isProduction: true, isUnitTest: false })); + const context = {}; + const result = getConsoleLogFromContext(context); + expect(result).toBe(unknownConsole); + }); + }); +}); diff --git a/packages/core/src/utils/console.ts b/packages/core/src/utils/console.ts index 3970eb772..fe5932277 100644 --- a/packages/core/src/utils/console.ts +++ b/packages/core/src/utils/console.ts @@ -1,3 +1,56 @@ import { ConsoleLog } from '@logto/shared'; +import { noop } from '@silverhand/essentials'; +import chalk from 'chalk'; -export const consoleLog: ConsoleLog = new ConsoleLog(); +import { EnvSet } from '#src/env-set/index.js'; + +export class SilentConsoleLog extends ConsoleLog { + plain = noop; + info = noop; + succeed = noop; + warn = noop; + error = noop; + fatal = () => { + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + }; +} + +/** The fallback console log with `unknown` prefix. */ +export const unknownConsole: ConsoleLog = new ConsoleLog(chalk.yellow('unknown')); + +/** + * The development console log with `dev` prefix. Usually you should use context-aware console log + * instead of this. + */ +export const devConsole: ConsoleLog = new ConsoleLog(chalk.magenta('dev')); + +/** + * Try to get the `ConsoleLog` instance from the context by checking if the `console` property is + * an instance of `ConsoleLog`. If it is not: + * + * - In production, return the default console log with `unknown` prefix. + * - In development or testing, throw an error. + * + * The in-context console log is used to provide a more context-aware logging experience. + */ +// eslint-disable-next-line @typescript-eslint/ban-types -- We need to accept any object as context +export const getConsoleLogFromContext = (context: object): ConsoleLog => { + if ('console' in context && context.console instanceof ConsoleLog) { + return context.console; + } + + // In production or unit testing, we should safely return an instance of `ConsoleLog` + if (!EnvSet.values.isProduction && !EnvSet.values.isUnitTest) { + throw new Error('Failed to get console log from context, please provide a valid context.'); + } + + if (EnvSet.values.isUnitTest) { + return new SilentConsoleLog(); + } + + unknownConsole.warn( + 'Failed to get console log from context, returning the unknown-prefixed `ConsoleLog`.' + ); + return unknownConsole; +}; diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts new file mode 100644 index 000000000..73c27ed8c --- /dev/null +++ b/packages/core/src/utils/request.ts @@ -0,0 +1,21 @@ +import { type ExceptionTelemetry } from '@logto/app-insights/node'; + +// eslint-disable-next-line @typescript-eslint/ban-types +const getRequestIdFromContext = (context: object): string | undefined => { + if ('requestId' in context && typeof context.requestId === 'string') { + return context.requestId; + } + + return undefined; +}; + +// eslint-disable-next-line @typescript-eslint/ban-types +export const buildAppInsightsTelemetry = (context: object): Partial => { + const requestId = getRequestIdFromContext(context); + + if (requestId) { + return { properties: { requestId } }; + } + + return {}; +}; diff --git a/packages/core/src/utils/tenant.ts b/packages/core/src/utils/tenant.ts index 9ac387c0a..b53402908 100644 --- a/packages/core/src/utils/tenant.ts +++ b/packages/core/src/utils/tenant.ts @@ -6,7 +6,8 @@ import { type CommonQueryMethods } from '@silverhand/slonik'; import { redisCache } from '#src/caches/index.js'; import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; import { createDomainsQueries } from '#src/queries/domains.js'; -import { consoleLog } from '#src/utils/console.js'; + +import { devConsole } from './console.js'; const normalizePathname = (pathname: string) => pathname + conditionalString(!pathname.endsWith('/') && '/'); @@ -105,7 +106,7 @@ export const getTenantId = async ( } if ((!isProduction || isIntegrationTest) && developmentTenantId) { - consoleLog.warn(`Found dev tenant ID ${developmentTenantId}.`); + devConsole.warn(`Found dev tenant ID ${developmentTenantId}.`); return [developmentTenantId, false]; } diff --git a/packages/integration-tests/src/tests/api/health-check.test.ts b/packages/integration-tests/src/tests/api/health-check.test.ts index f5577a91e..1172cd48c 100644 --- a/packages/integration-tests/src/tests/api/health-check.test.ts +++ b/packages/integration-tests/src/tests/api/health-check.test.ts @@ -4,4 +4,9 @@ describe('health check', () => { it('should have a health state', async () => { expect(await api.get('status')).toHaveProperty('status', 204); }); + + it('should return request id in headers', async () => { + const { headers } = await api.get('status'); + expect(headers.has('logto-core-request-id')).toBe(true); + }); }); diff --git a/packages/integration-tests/src/tests/api/well-known.test.ts b/packages/integration-tests/src/tests/api/well-known.test.ts index 4aaf7ce75..d26f3f46a 100644 --- a/packages/integration-tests/src/tests/api/well-known.test.ts +++ b/packages/integration-tests/src/tests/api/well-known.test.ts @@ -18,6 +18,11 @@ describe('.well-known api', () => { expect(response instanceof HTTPError && response.response.status === 404).toBe(true); }); + it('should return request id in headers', async () => { + const { headers } = await adminTenantApi.get(`.well-known/endpoints/123`); + expect(headers.has('logto-core-request-id')).toBe(true); + }); + it('get /.well-known/sign-in-exp for console', async () => { const response = await adminTenantApi.get('.well-known/sign-in-exp').json(); diff --git a/packages/shared/src/node/env/ConsoleLog.test.ts b/packages/shared/src/node/env/ConsoleLog.test.ts new file mode 100644 index 000000000..ec38104c1 --- /dev/null +++ b/packages/shared/src/node/env/ConsoleLog.test.ts @@ -0,0 +1,87 @@ +import { noop } from '@silverhand/essentials'; +import { describe, expect, it, vi } from 'vitest'; + +import ConsoleLog from './ConsoleLog.js'; + +describe('ConsoleLog', () => { + it('logs the plain message as is', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(noop); + new ConsoleLog().plain('message', 1, undefined, null); + + expect(logSpy).toHaveBeenCalledWith('message', 1, undefined, null); + }); + + it('logs the info message with an info prefix', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(noop); + new ConsoleLog().info('message', 1, null); + + expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/info/), 'message', 1, null); + }); + + it('logs the success message with an info and checkmark prefix', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(noop); + new ConsoleLog().succeed('message', 1, undefined, null); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringMatching(/info/), + expect.stringMatching(/✔/), + 'message', + 1, + undefined, + null + ); + }); + + it('logs the warn message with a warn prefix', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(noop); + new ConsoleLog().warn('message', { a: 1 }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringMatching(/warn/), 'message', { a: 1 }); + }); + + it('logs the error message with a error prefix', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(noop); + new ConsoleLog().error('message', { a: 1 }); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/error/), 'message', { + a: 1, + }); + }); + + it('logs the fatal message with a fatal prefix and exits the process', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(noop); + // @ts-expect-error process exit is mocked + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(noop); + new ConsoleLog().fatal('message', { a: 1 }); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/fatal/), 'message', { a: 1 }); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('logs the message with a custom prefix', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(noop); + new ConsoleLog('custom').plain('message', 1, null); + + expect(logSpy).toHaveBeenCalledWith('custom ', 'message', 1, null); + }); + + it('logs the message with a custom prefix and an info prefix', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(noop); + new ConsoleLog('custom').info('message', 1, null); + + expect(logSpy).toHaveBeenCalledWith( + 'custom ', + expect.stringMatching(/info/), + 'message', + 1, + null + ); + }); + + it('logs the message with a custom prefix and padding', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(noop); + new ConsoleLog('custom', 10).plain('message', 1, null); + + expect(logSpy).toHaveBeenCalledWith('custom ', 'message', 1, null); + }); +}); diff --git a/packages/shared/src/node/env/ConsoleLog.ts b/packages/shared/src/node/env/ConsoleLog.ts index 71e469520..4e5fe89d7 100644 --- a/packages/shared/src/node/env/ConsoleLog.ts +++ b/packages/shared/src/node/env/ConsoleLog.ts @@ -8,10 +8,24 @@ export default class ConsoleLog { fatal: chalk.bold(chalk.red('fatal')), }); - plain = console.log; + constructor( + /** A prefix to prepend to all log messages. */ + public readonly prefix?: string, + /** + * The number of spaces to pad the prefix. For example, if the prefix is `custom` and the + * padding is 8, the output will be `custom `. + * + * @default 8 + */ + public readonly padding = 8 + ) {} + + plain: typeof console.log = (...args) => { + console.log(...this.getArgs(args)); + }; info: typeof console.log = (...args) => { - console.log(ConsoleLog.prefixes.info, ...args); + this.plain(ConsoleLog.prefixes.info, ...args); }; succeed: typeof console.log = (...args) => { @@ -19,16 +33,25 @@ export default class ConsoleLog { }; warn: typeof console.log = (...args) => { - console.warn(ConsoleLog.prefixes.warn, ...args); + console.warn(...this.getArgs([ConsoleLog.prefixes.warn, ...args])); }; error: typeof console.log = (...args) => { - console.error(ConsoleLog.prefixes.error, ...args); + console.error(...this.getArgs([ConsoleLog.prefixes.error, ...args])); }; fatal: (...args: Parameters) => never = (...args) => { - console.error(ConsoleLog.prefixes.fatal, ...args); + console.error(...this.getArgs([ConsoleLog.prefixes.fatal, ...args])); // eslint-disable-next-line unicorn/no-process-exit process.exit(1); }; + + protected getArgs(args: Parameters) { + if (this.prefix) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return [this.prefix.padEnd(this.padding), ...args]; + } + + return args; + } }