0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

refactor: implement request id (#5813)

* refactor: implement request id

* refactor: fix tests

* refactor: add unit tests
This commit is contained in:
Gao Sun 2024-05-01 23:49:01 +08:00 committed by GitHub
parent 5adf3dfad7
commit a9ccfc738d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 498 additions and 139 deletions

View file

@ -0,0 +1,5 @@
---
"@logto/app-insights": patch
---
allow additional telemetry for `trackException()`

View file

@ -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

View file

@ -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<ExceptionTelemetry>) {
this.client?.trackException({ exception: normalizeError(error), ...telemetry });
}
}

View file

@ -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<void> {
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<void> {
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<void> {
tenant.requestEnd();
} catch (error: unknown) {
tenant.requestEnd();
void appInsights.trackException(error);
void appInsights.trackException(error, buildAppInsightsTelemetry(ctx));
throw error;
}

View file

@ -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');
}
}

View file

@ -0,0 +1,4 @@
import { ConsoleLog } from '@logto/shared';
import chalk from 'chalk';
export const cacheConsole = new ConsoleLog(chalk.magenta('cache'));

View file

@ -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;
}

View file

@ -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);

View file

@ -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);

View file

@ -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.

View file

@ -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');

View file

@ -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' }
);

View file

@ -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

View file

@ -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 <T extends LogtoJwtTokenKey>(payload: {
const deployJwtCustomizerScript = async <T extends LogtoJwtTokenKey>(
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 <T extends LogtoJwtTokenKey>(key: T) => {
const undeployJwtCustomizerScript = async <T extends LogtoJwtTokenKey>(
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 }));

View file

@ -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<typeof createLogtoConfigLibrary>;
@ -24,7 +24,7 @@ export const createLogtoConfigLibrary = ({
upsertJwtCustomizer: queryUpsertJwtCustomizer,
},
}: Pick<Queries, 'logtoConfigs'>) => {
const getOidcConfigs = async (): Promise<LogtoOidcConfigType> => {
const getOidcConfigs = async (consoleLog: ConsoleLog): Promise<LogtoOidcConfigType> => {
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<Partial<JwtCustomizerType>> => {
const getJwtCustomizers = async (consoleLog: ConsoleLog): Promise<Partial<JwtCustomizerType>> => {
try {
const { rows } = await getRowsByKeys(Object.values(LogtoJwtTokenKey));

44
packages/core/src/main.ts Normal file
View file

@ -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())]);
}

View file

@ -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 {

View file

@ -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<StateT, ContextT, BodyT>(): 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<StateT, ContextT, BodyT>(): Middleware<
}
// Report all exceptions to ApplicationInsights
void appInsights.trackException(error);
void appInsights.trackException(error, buildAppInsightsTelemetry(ctx));
if (error instanceof RequestError) {
ctx.status = error.status;

View file

@ -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<QueryT, BodyT, ParametersT, ResponseT, FilesT> = {
@ -121,6 +122,11 @@ const tryParse = <Output, Definition extends ZodTypeDef, Input>(
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;
}

View file

@ -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<string, string> = 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<StateT, ContextT>(): Middleware<Stat
ctx.body = errorOut(error);
if (!EnvSet.values.isUnitTest && (!EnvSet.values.isProduction || ctx.status >= 500)) {
consoleLog.error(error);
getConsoleLogFromContext(ctx).error(error);
}
}

View file

@ -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);
}
};

View file

@ -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));
});
}

View file

@ -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
)
);
}
};

View file

@ -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<T extends ManagementApiRouter>(
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<T extends ManagementApiRouter>(
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<T extends ManagementApiRouter>(
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 =

View file

@ -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({
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({
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({
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,

View file

@ -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<T extends ManagementApiRo
// Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully.
if (!isIntegrationTest) {
await deployJwtCustomizerScript({
await deployJwtCustomizerScript(getConsoleLogFromContext(ctx), {
key,
value: body,
useCase: 'production',
@ -122,7 +123,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
// Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully.
if (!isIntegrationTest) {
await deployJwtCustomizerScript({
await deployJwtCustomizerScript(getConsoleLogFromContext(ctx), {
key,
value: body,
useCase: 'production',
@ -142,7 +143,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
status: [200],
}),
async (ctx, next) => {
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<T extends ManagementApiRo
// Undeploy the script first to avoid the case where the JWT customizer was deleted from DB but worker script not updated successfully.
if (!isIntegrationTest) {
await undeployJwtCustomizerScript(tokenKey);
await undeployJwtCustomizerScript(getConsoleLogFromContext(ctx), tokenKey);
}
await deleteJwtCustomizer(tokenKey);
@ -219,7 +220,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
const { body } = ctx.guard;
// Deploy the test script
await deployJwtCustomizerScript({
await deployJwtCustomizerScript(getConsoleLogFromContext(ctx), {
key:
body.tokenType === LogtoJwtTokenKeyType.AccessToken
? LogtoJwtTokenKey.AccessToken

View file

@ -16,7 +16,7 @@ import { isGuardMiddleware } from '#src/middleware/koa-guard.js';
import { isPaginationMiddleware } from '#src/middleware/koa-pagination.js';
import { type DeepPartial } from '#src/test-utils/tenant.js';
import assertThat from '#src/utils/assert-that.js';
import { consoleLog } from '#src/utils/console.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';
import { translationSchemas, zodTypeToSwagger } from '#src/utils/zod.js';
import type { AnonymousRouter } from '../types.js';
@ -241,7 +241,7 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
);
if (EnvSet.values.isUnitTest) {
consoleLog.warn('Skip validating swagger document in unit test.');
getConsoleLogFromContext(ctx).warn('Skip validating swagger document in unit test.');
}
// Don't throw for integrity check in production as it has no benefit.
else if (!EnvSet.values.isProduction || EnvSet.values.isIntegrationTest) {

View file

@ -8,7 +8,7 @@ import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import { type DeepPartial } from '#src/test-utils/tenant.js';
import { consoleLog } from '#src/utils/console.js';
import { devConsole } from '#src/utils/console.js';
const capitalize = (value: string) => 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;
}

View file

@ -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<T extends ManagementApiRouter>(
ctx.body = result;
} catch (error: unknown) {
consoleLog.error(error);
getConsoleLogFromContext(ctx).error(error);
throw new RequestError({
code: 'storage.upload_error',
status: 500,

View file

@ -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;
}

View file

@ -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);

View file

@ -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<Tenant> {
static async create({ id, redisCache, customDomain }: CreateTenant): Promise<Tenant> {
// 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());

View file

@ -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<string, Promise<Tenant>>({
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;

View file

@ -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));

View file

@ -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<EmailConnector | SmsConnector> =>
connector.type !== ConnectorType.Social;

View file

@ -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);
});
});
});

View file

@ -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;
};

View file

@ -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<ExceptionTelemetry> => {
const requestId = getRequestIdFromContext(context);
if (requestId) {
return { properties: { requestId } };
}
return {};
};

View file

@ -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];
}

View file

@ -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);
});
});

View file

@ -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<SignInExperience>();

View file

@ -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);
});
});

View file

@ -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<typeof console.log>) => 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<typeof console.log>) {
if (this.prefix) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return [this.prefix.padEnd(this.padding), ...args];
}
return args;
}
}