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 { trySafe } from '@silverhand/essentials';
import type { TelemetryClient } from 'applicationinsights'; import type { TelemetryClient } from 'applicationinsights';
import { type ExceptionTelemetry } from 'applicationinsights/out/Declarations/Contracts/index.js';
import { normalizeError } from './normalize-error.js'; import { normalizeError } from './normalize-error.js';
export { type ExceptionTelemetry } from 'applicationinsights/out/Declarations/Contracts/index.js';
class AppInsights { class AppInsights {
client?: TelemetryClient; client?: TelemetryClient;
@ -29,9 +32,14 @@ class AppInsights {
return true; return true;
} }
/** The function is async to avoid blocking the main script and force the use of `await` or `void`. */ /**
async trackException(error: unknown) { * The function is async to avoid blocking the main script and force the use of `await` or `void`.
this.client?.trackException({ exception: normalizeError(error) }); *
* @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 http2 from 'node:http2';
import { appInsights } from '@logto/app-insights/node'; import { appInsights } from '@logto/app-insights/node';
import { ConsoleLog } from '@logto/shared';
import { toTitle, trySafe } from '@silverhand/essentials'; import { toTitle, trySafe } from '@silverhand/essentials';
import chalk from 'chalk'; import chalk from 'chalk';
import type Koa from 'koa'; import type Koa from 'koa';
import koaLogger from 'koa-logger';
import { nanoid } from 'nanoid';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
import { TenantNotFoundError, tenantPool } from '#src/tenants/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'; import { getTenantId } from '#src/utils/tenant.js';
const logListening = (type: 'core' | 'admin' = 'core') => { const logListening = (type: 'core' | 'admin' = 'core') => {
const urlSet = type === 'core' ? EnvSet.values.urlSet : EnvSet.values.adminUrlSet; const urlSet = type === 'core' ? EnvSet.values.urlSet : EnvSet.values.adminUrlSet;
const consoleLog = new ConsoleLog(chalk.magenta(type));
for (const url of urlSet.deduplicated()) { for (const url of urlSet.deduplicated()) {
consoleLog.info(chalk.bold(`${toTitle(type)} app is running at ${url.toString()}`)); 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; const serverTimeout = 120_000;
export default async function initApp(app: Koa): Promise<void> { 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) => { app.use(async (ctx, next) => {
if (EnvSet.values.isDomainBasedMultiTenancy && ['/status', '/'].includes(ctx.URL.pathname)) { if (EnvSet.values.isDomainBasedMultiTenancy && ['/status', '/'].includes(ctx.URL.pathname)) {
ctx.status = 204; 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) => { const tenant = await trySafe(tenantPool.get(tenantId, customEndpoint), (error) => {
ctx.status = error instanceof TenantNotFoundError ? 404 : 500; ctx.status = error instanceof TenantNotFoundError ? 404 : 500;
void appInsights.trackException(error); void appInsights.trackException(error, buildAppInsightsTelemetry(ctx));
}); });
if (!tenant) { if (!tenant) {
@ -56,7 +76,7 @@ export default async function initApp(app: Koa): Promise<void> {
tenant.requestEnd(); tenant.requestEnd();
} catch (error: unknown) { } catch (error: unknown) {
tenant.requestEnd(); tenant.requestEnd();
void appInsights.trackException(error); void appInsights.trackException(error, buildAppInsightsTelemetry(ctx));
throw error; 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 { createClient, createCluster, type RedisClientType, type RedisClusterType } from 'redis';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
import { consoleLog } from '#src/utils/console.js';
import { type CacheStore } from './types.js'; import { type CacheStore } from './types.js';
import { cacheConsole } from './utils.js';
abstract class RedisCacheBase implements CacheStore { abstract class RedisCacheBase implements CacheStore {
readonly client?: RedisClientType | RedisClusterType; readonly client?: RedisClientType | RedisClusterType;
@ -32,17 +32,17 @@ abstract class RedisCacheBase implements CacheStore {
const pong = await this.ping(); const pong = await this.ping();
if (pong === 'PONG') { if (pong === 'PONG') {
consoleLog.info('[CACHE] Connected to Redis'); cacheConsole.info('Connected to Redis');
return; return;
} }
} }
consoleLog.warn('[CACHE] No Redis client initialized, skipping'); cacheConsole.warn('No Redis client initialized, skipping');
} }
async disconnect() { async disconnect() {
if (this.client) { if (this.client) {
await this.client.disconnect(); 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 ZodType, z } from 'zod';
import { type ConnectorWellKnown, connectorWellKnownGuard } from '#src/utils/connectors/types.js'; import { type ConnectorWellKnown, connectorWellKnownGuard } from '#src/utils/connectors/types.js';
import { consoleLog } from '#src/utils/console.js';
import { type CacheStore } from './types.js'; import { type CacheStore } from './types.js';
import { cacheConsole } from './utils.js';
type WellKnownMap = { type WellKnownMap = {
sie: SignInExperience; sie: SignInExperience;
@ -177,7 +177,7 @@ export class WellKnownCache {
const cachedValue = await trySafe(kvCache.get(type, promiseKey)); const cachedValue = await trySafe(kvCache.get(type, promiseKey));
if (cachedValue) { if (cachedValue) {
consoleLog.info('[CACHE] Well-known cache hit for', type, promiseKey); cacheConsole.info('Well-known cache hit for', type, promiseKey);
return cachedValue; return cachedValue;
} }

View file

@ -1,8 +1,9 @@
import { getAvailableAlterations } from '@logto/cli/lib/commands/database/alteration/index.js'; import { getAvailableAlterations } from '@logto/cli/lib/commands/database/alteration/index.js';
import { ConsoleLog } from '@logto/shared';
import type { DatabasePool } from '@silverhand/slonik'; import type { DatabasePool } from '@silverhand/slonik';
import chalk from 'chalk'; 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) => { export const checkAlterationState = async (pool: DatabasePool) => {
const alterations = await getAvailableAlterations(pool); 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 type { Optional } from '@silverhand/essentials';
import { appendPath } from '@silverhand/essentials'; import { appendPath } from '@silverhand/essentials';
import type { DatabasePool } from '@silverhand/slonik'; import type { DatabasePool } from '@silverhand/slonik';
import chalk from 'chalk';
import { createLogtoConfigLibrary } from '#src/libraries/logto-config.js'; import { createLogtoConfigLibrary } from '#src/libraries/logto-config.js';
import { createLogtoConfigQueries } from '#src/queries/logto-config.js'; import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
@ -72,11 +73,12 @@ export class EnvSet {
this.#pool = pool; this.#pool = pool;
const consoleLog = new ConsoleLog(chalk.magenta('env-set'));
const { getOidcConfigs } = createLogtoConfigLibrary({ const { getOidcConfigs } = createLogtoConfigLibrary({
logtoConfigs: createLogtoConfigQueries(pool), logtoConfigs: createLogtoConfigQueries(pool),
}); });
const oidcConfigs = await getOidcConfigs(); const oidcConfigs = await getOidcConfigs(consoleLog);
const endpoint = customDomain const endpoint = customDomain
? new URL(customDomain) ? new URL(customDomain)
: getTenantEndpoint(this.tenantId, EnvSet.values); : 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 Provider from 'oidc-provider';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import { consoleLog } from '#src/utils/console.js';
import { grantListener, grantRevocationListener } from './grant.js'; import { grantListener, grantRevocationListener } from './grant.js';
import { interactionEndedListener, interactionStartedListener } from './interaction.js'; import { interactionEndedListener, interactionStartedListener } from './interaction.js';
import { recordActiveUsers } from './record-active-users.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/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} * @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.started', interactionStartedListener);
provider.addListener('interaction.ended', interactionEndedListener); provider.addListener('interaction.ended', interactionEndedListener);
provider.addListener('server_error', (_, error) => { provider.addListener('server_error', (_, error) => {
consoleLog.error('OIDC Provider server_error:', error); consoleLog.error('server_error:', error);
}); });
// Record token usage. // Record token usage.

View file

@ -1,48 +1,6 @@
import { trySafe } from '@silverhand/essentials';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { findUp } from 'find-up'; 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', {}) }); dotenv.config({ path: await findUp('.env', {}) });
const { appInsights } = await import('@logto/app-insights/node'); await import('./main.js');
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())]);
}

View file

@ -1,5 +1,6 @@
import type { Hook } from '@logto/schemas'; import type { Hook } from '@logto/schemas';
import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas'; import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas';
import { ConsoleLog } from '@logto/shared';
import { createMockUtils } from '@logto/shared/esm'; import { createMockUtils } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
@ -73,6 +74,7 @@ describe('triggerInteractionHooks()', () => {
jest.useFakeTimers().setSystemTime(100_000); jest.useFakeTimers().setSystemTime(100_000);
await triggerInteractionHooks( await triggerInteractionHooks(
new ConsoleLog(),
{ event: InteractionEvent.SignIn, sessionId: 'some_jti', applicationId: 'some_client' }, { event: InteractionEvent.SignIn, sessionId: 'some_jti', applicationId: 'some_client' },
{ userId: '123' } { userId: '123' }
); );

View file

@ -7,14 +7,13 @@ import {
type HookConfig, type HookConfig,
type HookTestErrorResponseData, type HookTestErrorResponseData,
} from '@logto/schemas'; } from '@logto/schemas';
import { generateStandardId } from '@logto/shared'; import { type ConsoleLog, generateStandardId } from '@logto/shared';
import { conditional, pick, trySafe } from '@silverhand/essentials'; import { conditional, pick, trySafe } from '@silverhand/essentials';
import { HTTPError } from 'ky'; import { HTTPError } from 'ky';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import { LogEntry } from '#src/middleware/koa-audit-log.js'; import { LogEntry } from '#src/middleware/koa-audit-log.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import { consoleLog } from '#src/utils/console.js';
import { generateHookTestPayload, parseResponse, sendWebhookRequest } from './utils.js'; import { generateHookTestPayload, parseResponse, sendWebhookRequest } from './utils.js';
@ -55,6 +54,7 @@ export const createHookLibrary = (queries: Queries) => {
} = queries; } = queries;
const triggerInteractionHooks = async ( const triggerInteractionHooks = async (
consoleLog: ConsoleLog,
interactionContext: InteractionHookContext, interactionContext: InteractionHookContext,
interactionResult: InteractionHookResult, interactionResult: InteractionHookResult,
userAgent?: string userAgent?: string

View file

@ -5,6 +5,7 @@ import {
type JwtCustomizerType, type JwtCustomizerType,
type JwtCustomizerUserContext, type JwtCustomizerUserContext,
} from '@logto/schemas'; } from '@logto/schemas';
import { type ConsoleLog } from '@logto/shared';
import { deduplicate, pick, pickState, assert } from '@silverhand/essentials'; import { deduplicate, pick, pickState, assert } from '@silverhand/essentials';
import deepmerge from 'deepmerge'; import deepmerge from 'deepmerge';
@ -94,14 +95,17 @@ export const createJwtCustomizerLibrary = (
* @params payload.value - JWT customizer value * @params payload.value - JWT customizer value
* @params payload.useCase - The use case of JWT customizer script, can be either `test` or `production`. * @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>(
key: T; consoleLog: ConsoleLog,
value: JwtCustomizerType[T]; payload: {
useCase: 'test' | 'production'; key: T;
}) => { value: JwtCustomizerType[T];
useCase: 'test' | 'production';
}
) => {
const [client, jwtCustomizers] = await Promise.all([ const [client, jwtCustomizers] = await Promise.all([
cloudConnection.getClient(), cloudConnection.getClient(),
getJwtCustomizers(), getJwtCustomizers(consoleLog),
]); ]);
const customizerScriptsFromDatabase = getJwtCustomizerScripts(jwtCustomizers); 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([ const [client, jwtCustomizers] = await Promise.all([
cloudConnection.getClient(), cloudConnection.getClient(),
getJwtCustomizers(), getJwtCustomizers(consoleLog),
]); ]);
assert(jwtCustomizers[key], new RequestError({ code: 'entity.not_exists', key })); assert(jwtCustomizers[key], new RequestError({ code: 'entity.not_exists', key }));

View file

@ -8,12 +8,12 @@ import {
jwtCustomizerConfigGuard, jwtCustomizerConfigGuard,
logtoOidcConfigGuard, logtoOidcConfigGuard,
} from '@logto/schemas'; } from '@logto/schemas';
import { type ConsoleLog } from '@logto/shared';
import chalk from 'chalk'; import chalk from 'chalk';
import { ZodError, z } from 'zod'; import { ZodError, z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import { consoleLog } from '#src/utils/console.js';
export type LogtoConfigLibrary = ReturnType<typeof createLogtoConfigLibrary>; export type LogtoConfigLibrary = ReturnType<typeof createLogtoConfigLibrary>;
@ -24,7 +24,7 @@ export const createLogtoConfigLibrary = ({
upsertJwtCustomizer: queryUpsertJwtCustomizer, upsertJwtCustomizer: queryUpsertJwtCustomizer,
}, },
}: Pick<Queries, 'logtoConfigs'>) => { }: Pick<Queries, 'logtoConfigs'>) => {
const getOidcConfigs = async (): Promise<LogtoOidcConfigType> => { const getOidcConfigs = async (consoleLog: ConsoleLog): Promise<LogtoOidcConfigType> => {
try { try {
const { rows } = await getRowsByKeys(Object.values(LogtoOidcConfigKey)); const { rows } = await getRowsByKeys(Object.values(LogtoOidcConfigKey));
@ -96,7 +96,7 @@ export const createLogtoConfigLibrary = ({
return z.object({ value: jwtCustomizerConfigGuard[key] }).parse(rows[0]).value; 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 { try {
const { rows } = await getRowsByKeys(Object.values(LogtoJwtTokenKey)); 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 { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import assertThat from '#src/utils/assert-that.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'; import { getAdminTenantTokenValidationSet } from './utils.js';
@ -60,7 +60,7 @@ export const verifyBearerTokenFromRequest = async (
if ((!isProduction || isIntegrationTest) && userId) { if ((!isProduction || isIntegrationTest) && userId) {
// This log is distracting in integration tests. // This log is distracting in integration tests.
if (!isIntegrationTest) { if (!isIntegrationTest) {
consoleLog.warn(`Found dev user ID ${userId}, skip token validation.`); devConsole.warn(`Found dev user ID ${userId}, skip token validation.`);
} }
return { return {

View file

@ -5,14 +5,22 @@ import { HttpError } from 'koa';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/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< export default function koaErrorHandler<StateT, ContextT, BodyT>(): Middleware<
StateT, StateT,
ContextT, ContextT,
BodyT | RequestErrorBody | { message: string } BodyT | RequestErrorBody | { message: string }
> { > {
return async (ctx, next) => { return async (ctx, next) => {
const consoleLog = getConsoleLogFromContext(ctx);
try { try {
await next(); await next();
} catch (error: unknown) { } catch (error: unknown) {
@ -21,7 +29,7 @@ export default function koaErrorHandler<StateT, ContextT, BodyT>(): Middleware<
} }
// Report all exceptions to ApplicationInsights // Report all exceptions to ApplicationInsights
void appInsights.trackException(error); void appInsights.trackException(error, buildAppInsightsTelemetry(ctx));
if (error instanceof RequestError) { if (error instanceof RequestError) {
ctx.status = error.status; 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 { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import { ResponseBodyError, StatusCodeError } from '#src/errors/ServerError/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. */ /** Configure what and how to guard. */
export type GuardConfig<QueryT, BodyT, ParametersT, ResponseT, FilesT> = { export type GuardConfig<QueryT, BodyT, ParametersT, ResponseT, FilesT> = {
@ -121,6 +122,11 @@ const tryParse = <Output, Definition extends ZodTypeDef, Input>(
return parse(type, guard, data); 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< export default function koaGuard<
StateT, StateT,
ContextT extends IRouterParamContext, ContextT extends IRouterParamContext,
@ -170,6 +176,8 @@ export default function koaGuard<
GuardResponseT GuardResponseT
> >
> = async function (ctx, next) { > = async function (ctx, next) {
const consoleLog = getConsoleLogFromContext(ctx);
/** /**
* Assert the status code matches the value(s) in the config. If the config does not * 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. * specify a status code, it will not assert anything.
@ -191,7 +199,10 @@ export default function koaGuard<
if (EnvSet.values.isProduction) { if (EnvSet.values.isProduction) {
consoleLog.warn('Unexpected status code:', value, 'expected:', status); 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; return;
} }

View file

@ -5,7 +5,7 @@ import { errors } from 'oidc-provider';
import { z } from 'zod'; import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js'; 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. * 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 * Transform oidc-provider error to a format for the client. This is edited from oidc-provider's
* own implementation. * 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} * @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 = ({ export const errorOut = ({
@ -92,7 +94,7 @@ export default function koaOidcErrorHandler<StateT, ContextT>(): Middleware<Stat
ctx.body = errorOut(error); ctx.body = errorOut(error);
if (!EnvSet.values.isUnitTest && (!EnvSet.values.isProduction || ctx.status >= 500)) { 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 { defaults, parseAffiliateData } from '@logto/affiliate';
import { consoleLog } from '@logto/cli/lib/utils.js';
import { type CreateUser, type User, adminTenantId } from '@logto/schemas'; import { type CreateUser, type User, adminTenantId } from '@logto/schemas';
import { conditional, trySafe } from '@silverhand/essentials'; import { conditional, trySafe } from '@silverhand/essentials';
import { type IRouterContext } from 'koa-router'; 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 { encryptUserPassword } from '#src/libraries/user.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.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 { type OmitAutoSetFields } from '#src/utils/sql.js';
import { import {
@ -146,6 +146,6 @@ export const postAffiliateLogs = async (
await client.post('/api/affiliate-logs', { await client.post('/api/affiliate-logs', {
body: { userId, ...affiliateData }, 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 { encryptUserPassword } from '#src/libraries/user.js';
import type { LogEntry, WithLogContext } from '#src/middleware/koa-audit-log.js'; import type { LogEntry, WithLogContext } from '#src/middleware/koa-audit-log.js';
import type TenantContext from '#src/tenants/TenantContext.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 { getTenantId } from '#src/utils/tenant.js';
import type { WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js'; import type { WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js';
@ -180,8 +181,8 @@ async function handleSubmitRegister(
log?.append({ userId: id }); log?.append({ userId: id });
appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.Register) }); appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.Register) });
void trySafe(postAffiliateLogs(ctx, cloudConnection, id, tenantId), (error) => { void trySafe(postAffiliateLogs(ctx, cloudConnection, id, tenantId), (error) => {
consoleLog.warn('Failed to post affiliate logs', error); getConsoleLogFromContext(ctx).warn('Failed to post affiliate logs', error);
void appInsights.trackException(error); void appInsights.trackException(error, buildAppInsightsTelemetry(ctx));
}); });
} }

View file

@ -7,6 +7,7 @@ import {
type InteractionHookResult, type InteractionHookResult,
} from '#src/libraries/hook/index.js'; } from '#src/libraries/hook/index.js';
import type Libraries from '#src/tenants/Libraries.js'; import type Libraries from '#src/tenants/Libraries.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';
import { getInteractionStorage } from '../utils/interaction.js'; import { getInteractionStorage } from '../utils/interaction.js';
@ -64,7 +65,12 @@ export default function koaInteractionHooks<
if (interactionHookResult) { if (interactionHookResult) {
// Hooks should not crash the app // Hooks should not crash the app
void trySafe( 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 RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';
import { exportJWK } from '#src/utils/jwks.js'; import { exportJWK } from '#src/utils/jwks.js';
import type { ManagementApiRouter, RouterInitArgs } from '../types.js'; import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
@ -104,7 +105,7 @@ export default function logtoConfigRoutes<T extends ManagementApiRouter>(
async (ctx, next) => { async (ctx, next) => {
const { keyType } = ctx.guard.params; const { keyType } = ctx.guard.params;
const configKey = getOidcConfigKeyDatabaseColumnName(keyType); const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
const configs = await getOidcConfigs(); const configs = await getOidcConfigs(getConsoleLogFromContext(ctx));
// Remove actual values of the private keys from response // Remove actual values of the private keys from response
ctx.body = await getRedactedOidcKeyResponse(configKey, configs[configKey]); ctx.body = await getRedactedOidcKeyResponse(configKey, configs[configKey]);
@ -125,7 +126,7 @@ export default function logtoConfigRoutes<T extends ManagementApiRouter>(
async (ctx, next) => { async (ctx, next) => {
const { keyType, keyId } = ctx.guard.params; const { keyType, keyId } = ctx.guard.params;
const configKey = getOidcConfigKeyDatabaseColumnName(keyType); const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
const configs = await getOidcConfigs(); const configs = await getOidcConfigs(getConsoleLogFromContext(ctx));
const existingKeys = configs[configKey]; const existingKeys = configs[configKey];
if (existingKeys.length <= 1) { if (existingKeys.length <= 1) {
@ -163,7 +164,7 @@ export default function logtoConfigRoutes<T extends ManagementApiRouter>(
const { keyType } = ctx.guard.params; const { keyType } = ctx.guard.params;
const { signingKeyAlgorithm } = ctx.guard.body; const { signingKeyAlgorithm } = ctx.guard.body;
const configKey = getOidcConfigKeyDatabaseColumnName(keyType); const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
const configs = await getOidcConfigs(); const configs = await getOidcConfigs(getConsoleLogFromContext(ctx));
const existingKeys = configs[configKey]; const existingKeys = configs[configKey];
const newPrivateKey = const newPrivateKey =

View file

@ -3,6 +3,7 @@ import {
LogtoJwtTokenKeyType, LogtoJwtTokenKeyType,
type JwtCustomizerTestRequestBody, type JwtCustomizerTestRequestBody,
} from '@logto/schemas'; } from '@logto/schemas';
import { ConsoleLog } from '@logto/shared';
import { pickDefault } from '@logto/shared/esm'; import { pickDefault } from '@logto/shared/esm';
import { pick } from '@silverhand/essentials'; import { pick } from '@silverhand/essentials';
@ -60,11 +61,14 @@ describe('configs JWT customizer routes', () => {
.put(`/configs/jwt-customizer/access-token`) .put(`/configs/jwt-customizer/access-token`)
.send(mockJwtCustomizerConfigForAccessToken.value); .send(mockJwtCustomizerConfigForAccessToken.value);
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({ expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith(
key: LogtoJwtTokenKey.AccessToken, expect.any(ConsoleLog),
value: mockJwtCustomizerConfigForAccessToken.value, {
useCase: 'production', key: LogtoJwtTokenKey.AccessToken,
}); value: mockJwtCustomizerConfigForAccessToken.value,
useCase: 'production',
}
);
expect(mockLogtoConfigsLibrary.upsertJwtCustomizer).toHaveBeenCalledWith( expect(mockLogtoConfigsLibrary.upsertJwtCustomizer).toHaveBeenCalledWith(
LogtoJwtTokenKey.AccessToken, LogtoJwtTokenKey.AccessToken,
@ -102,11 +106,14 @@ describe('configs JWT customizer routes', () => {
.patch('/configs/jwt-customizer/access-token') .patch('/configs/jwt-customizer/access-token')
.send(mockJwtCustomizerConfigForAccessToken.value); .send(mockJwtCustomizerConfigForAccessToken.value);
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({ expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith(
key: LogtoJwtTokenKey.AccessToken, expect.any(ConsoleLog),
value: mockJwtCustomizerConfigForAccessToken.value, {
useCase: 'production', key: LogtoJwtTokenKey.AccessToken,
}); value: mockJwtCustomizerConfigForAccessToken.value,
useCase: 'production',
}
);
expect(mockLogtoConfigsLibrary.updateJwtCustomizer).toHaveBeenCalledWith( expect(mockLogtoConfigsLibrary.updateJwtCustomizer).toHaveBeenCalledWith(
LogtoJwtTokenKey.AccessToken, LogtoJwtTokenKey.AccessToken,
@ -141,6 +148,7 @@ describe('configs JWT customizer routes', () => {
it('DELETE /configs/jwt-customizer/:tokenType should delete the record', async () => { it('DELETE /configs/jwt-customizer/:tokenType should delete the record', async () => {
const response = await routeRequester.delete('/configs/jwt-customizer/client-credentials'); const response = await routeRequester.delete('/configs/jwt-customizer/client-credentials');
expect(tenantContext.libraries.jwtCustomizers.undeployJwtCustomizerScript).toHaveBeenCalledWith( expect(tenantContext.libraries.jwtCustomizers.undeployJwtCustomizerScript).toHaveBeenCalledWith(
expect.any(ConsoleLog),
LogtoJwtTokenKey.ClientCredentials LogtoJwtTokenKey.ClientCredentials
); );
expect(logtoConfigQueries.deleteJwtCustomizer).toHaveBeenCalledWith( expect(logtoConfigQueries.deleteJwtCustomizer).toHaveBeenCalledWith(
@ -163,11 +171,14 @@ describe('configs JWT customizer routes', () => {
const response = await routeRequester.post('/configs/jwt-customizer/test').send(payload); const response = await routeRequester.post('/configs/jwt-customizer/test').send(payload);
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({ expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith(
key: LogtoJwtTokenKey.ClientCredentials, expect.any(ConsoleLog),
value: payload, {
useCase: 'test', key: LogtoJwtTokenKey.ClientCredentials,
}); value: payload,
useCase: 'test',
}
);
expect(mockCloudClient.post).toHaveBeenCalledWith('/api/services/custom-jwt', { expect(mockCloudClient.post).toHaveBeenCalledWith('/api/services/custom-jwt', {
body: payload, 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 RequestError, { formatZodError } from '#src/errors/RequestError/index.js';
import koaGuard, { parse } from '#src/middleware/koa-guard.js'; import koaGuard, { parse } from '#src/middleware/koa-guard.js';
import koaQuotaGuard from '#src/middleware/koa-quota-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'; 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. // Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully.
if (!isIntegrationTest) { if (!isIntegrationTest) {
await deployJwtCustomizerScript({ await deployJwtCustomizerScript(getConsoleLogFromContext(ctx), {
key, key,
value: body, value: body,
useCase: 'production', 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. // Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully.
if (!isIntegrationTest) { if (!isIntegrationTest) {
await deployJwtCustomizerScript({ await deployJwtCustomizerScript(getConsoleLogFromContext(ctx), {
key, key,
value: body, value: body,
useCase: 'production', useCase: 'production',
@ -142,7 +143,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
status: [200], status: [200],
}), }),
async (ctx, next) => { async (ctx, next) => {
const jwtCustomizer = await getJwtCustomizers(); const jwtCustomizer = await getJwtCustomizers(getConsoleLogFromContext(ctx));
ctx.body = Object.values(LogtoJwtTokenKey) ctx.body = Object.values(LogtoJwtTokenKey)
.filter((key) => jwtCustomizer[key]) .filter((key) => jwtCustomizer[key])
.map((key) => ({ key, value: 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. // Undeploy the script first to avoid the case where the JWT customizer was deleted from DB but worker script not updated successfully.
if (!isIntegrationTest) { if (!isIntegrationTest) {
await undeployJwtCustomizerScript(tokenKey); await undeployJwtCustomizerScript(getConsoleLogFromContext(ctx), tokenKey);
} }
await deleteJwtCustomizer(tokenKey); await deleteJwtCustomizer(tokenKey);
@ -219,7 +220,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
const { body } = ctx.guard; const { body } = ctx.guard;
// Deploy the test script // Deploy the test script
await deployJwtCustomizerScript({ await deployJwtCustomizerScript(getConsoleLogFromContext(ctx), {
key: key:
body.tokenType === LogtoJwtTokenKeyType.AccessToken body.tokenType === LogtoJwtTokenKeyType.AccessToken
? LogtoJwtTokenKey.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 { isPaginationMiddleware } from '#src/middleware/koa-pagination.js';
import { type DeepPartial } from '#src/test-utils/tenant.js'; import { type DeepPartial } from '#src/test-utils/tenant.js';
import assertThat from '#src/utils/assert-that.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 { translationSchemas, zodTypeToSwagger } from '#src/utils/zod.js';
import type { AnonymousRouter } from '../types.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) { 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. // Don't throw for integrity check in production as it has no benefit.
else if (!EnvSet.values.isProduction || EnvSet.values.isIntegrationTest) { 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 { EnvSet } from '#src/env-set/index.js';
import { type DeepPartial } from '#src/test-utils/tenant.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); const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
@ -166,7 +166,7 @@ export const validateSupplement = (
export const validateSwaggerDocument = (document: OpenAPIV3.Document) => { export const validateSwaggerDocument = (document: OpenAPIV3.Document) => {
for (const [path, operations] of Object.entries(document.paths)) { for (const [path, operations] of Object.entries(document.paths)) {
if (path.startsWith('/api/interaction')) { if (path.startsWith('/api/interaction')) {
consoleLog.warn(`Path \`${path}\` is not documented. Do something!`); devConsole.warn(`Path \`${path}\` is not documented. Do something!`);
continue; continue;
} }

View file

@ -15,7 +15,7 @@ import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import SystemContext from '#src/tenants/SystemContext.js'; import SystemContext from '#src/tenants/SystemContext.js';
import assertThat from '#src/utils/assert-that.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 { uploadFileGuard } from '#src/utils/storage/consts.js';
import { buildUploadFile } from '#src/utils/storage/index.js'; import { buildUploadFile } from '#src/utils/storage/index.js';
import { getTenantId } from '#src/utils/tenant.js'; import { getTenantId } from '#src/utils/tenant.js';
@ -92,7 +92,7 @@ export default function userAssetsRoutes<T extends ManagementApiRouter>(
ctx.body = result; ctx.body = result;
} catch (error: unknown) { } catch (error: unknown) {
consoleLog.error(error); getConsoleLogFromContext(ctx).error(error);
throw new RequestError({ throw new RequestError({
code: 'storage.upload_error', code: 'storage.upload_error',
status: 500, status: 500,

View file

@ -13,7 +13,7 @@ import type { CommonQueryMethods } from '@silverhand/slonik';
import { type ZodType } from 'zod'; import { type ZodType } from 'zod';
import { createSystemsQuery } from '#src/queries/system.js'; 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 { export default class SystemContext {
static shared = new SystemContext(); static shared = new SystemContext();
@ -70,7 +70,7 @@ export default class SystemContext {
const result = guard.safeParse(record.value); const result = guard.safeParse(record.value);
if (!result.success) { if (!result.success) {
consoleLog.error(`Failed to parse ${key} config:`, result.error); devConsole.error(`Failed to parse ${key} config:`, result.error);
return; return;
} }

View file

@ -54,7 +54,7 @@ describe('Tenant', () => {
}); });
it('should call middleware factories for user tenants', async () => { 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) { for (const [, middleware, shouldCall] of userMiddlewareList) {
if (shouldCall) { if (shouldCall) {
@ -66,7 +66,7 @@ describe('Tenant', () => {
}); });
it('should call middleware factories for the admin tenant', async () => { 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) { for (const [, middleware, shouldCall] of adminMiddlewareList) {
if (shouldCall) { if (shouldCall) {
@ -80,7 +80,7 @@ describe('Tenant', () => {
describe('Tenant `.run()`', () => { describe('Tenant `.run()`', () => {
it('should return a function ', async () => { 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'); expect(typeof tenant.run).toBe('function');
}); });
}); });
@ -88,7 +88,7 @@ describe('Tenant `.run()`', () => {
describe('Tenant cache health check', () => { describe('Tenant cache health check', () => {
it('should set expiration timestamp in redis', async () => { it('should set expiration timestamp in redis', async () => {
const redisCache = new RedisCache(); 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'); expect(typeof tenant.invalidateCache).toBe('function');
Sinon.stub(tenant.wellKnownCache, 'set').value(jest.fn()); 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 () => { 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(typeof tenant.checkHealth).toBe('function');
expect(await tenant.checkHealth()).toBe(true); expect(await tenant.checkHealth()).toBe(true);

View file

@ -3,7 +3,6 @@ import type { MiddlewareType } from 'koa';
import Koa from 'koa'; import Koa from 'koa';
import compose from 'koa-compose'; import compose from 'koa-compose';
import koaCompress from 'koa-compress'; import koaCompress from 'koa-compress';
import koaLogger from 'koa-logger';
import mount from 'koa-mount'; import mount from 'koa-mount';
import type Provider from 'oidc-provider'; import type Provider from 'oidc-provider';
@ -34,8 +33,18 @@ import Queries from './Queries.js';
import type TenantContext from './TenantContext.js'; import type TenantContext from './TenantContext.js';
import { getTenantDatabaseDsn } from './utils.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 { 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 // Treat the default database URL as the management URL
const envSet = new EnvSet(id, await getTenantDatabaseDsn(id)); const envSet = new EnvSet(id, await getTenantDatabaseDsn(id));
// Custom endpoint is used for building OIDC issuer URL when the request is a custom domain // 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 // Init app
const app = new Koa(); const app = new Koa();
app.use(koaLogger());
app.use(koaErrorHandler()); app.use(koaErrorHandler());
app.use(koaOidcErrorHandler()); app.use(koaOidcErrorHandler());
app.use(koaSlonikErrorHandler()); app.use(koaSlonikErrorHandler());

View file

@ -1,12 +1,15 @@
import { ConsoleLog } from '@logto/shared';
import chalk from 'chalk';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { redisCache } from '#src/caches/index.js'; import { redisCache } from '#src/caches/index.js';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
import { consoleLog } from '#src/utils/console.js';
import Tenant from './Tenant.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>>({ protected cache = new LRUCache<string, Promise<Tenant>>({
max: EnvSet.values.tenantPoolSize, max: EnvSet.values.tenantPoolSize,
dispose: async (entry) => { dispose: async (entry) => {
@ -29,7 +32,7 @@ export class TenantPool {
} }
consoleLog.info('Init tenant:', tenantId, customDomain); 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); this.cache.set(cacheKey, newTenantPromise);
return newTenantPromise; return newTenantPromise;

View file

@ -1,13 +1,16 @@
import { parseJson } from '@logto/connector-kit'; import { parseJson } from '@logto/connector-kit';
import { type CloudflareData, DomainStatus } from '@logto/schemas'; import { type CloudflareData, DomainStatus } from '@logto/schemas';
import { ConsoleLog } from '@logto/shared';
import chalk from 'chalk';
import { type Response } from 'got'; import { type Response } from 'got';
import { type ZodType } from 'zod'; import { type ZodType } from 'zod';
import assertThat from '../assert-that.js'; import assertThat from '../assert-that.js';
import { consoleLog } from '../console.js';
import { type HandleResponse, cloudflareResponseGuard } from './types.js'; import { type HandleResponse, cloudflareResponseGuard } from './types.js';
const consoleLog = new ConsoleLog(chalk.magenta('cf'));
const parseCloudflareResponse = (body: string) => { const parseCloudflareResponse = (body: string) => {
const result = cloudflareResponseGuard.safeParse(parseJson(body)); 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'; import { type LogtoConnector } from './types.js';
export const isPasswordlessLogtoConnector = ( const isPasswordlessLogtoConnector = (
connector: LogtoConnector connector: LogtoConnector
): connector is LogtoConnector<EmailConnector | SmsConnector> => ): connector is LogtoConnector<EmailConnector | SmsConnector> =>
connector.type !== ConnectorType.Social; 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 { 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 { redisCache } from '#src/caches/index.js';
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
import { createDomainsQueries } from '#src/queries/domains.js'; import { createDomainsQueries } from '#src/queries/domains.js';
import { consoleLog } from '#src/utils/console.js';
import { devConsole } from './console.js';
const normalizePathname = (pathname: string) => const normalizePathname = (pathname: string) =>
pathname + conditionalString(!pathname.endsWith('/') && '/'); pathname + conditionalString(!pathname.endsWith('/') && '/');
@ -105,7 +106,7 @@ export const getTenantId = async (
} }
if ((!isProduction || isIntegrationTest) && developmentTenantId) { if ((!isProduction || isIntegrationTest) && developmentTenantId) {
consoleLog.warn(`Found dev tenant ID ${developmentTenantId}.`); devConsole.warn(`Found dev tenant ID ${developmentTenantId}.`);
return [developmentTenantId, false]; return [developmentTenantId, false];
} }

View file

@ -4,4 +4,9 @@ describe('health check', () => {
it('should have a health state', async () => { it('should have a health state', async () => {
expect(await api.get('status')).toHaveProperty('status', 204); 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); 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 () => { it('get /.well-known/sign-in-exp for console', async () => {
const response = await adminTenantApi.get('.well-known/sign-in-exp').json<SignInExperience>(); 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')), 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) => { info: typeof console.log = (...args) => {
console.log(ConsoleLog.prefixes.info, ...args); this.plain(ConsoleLog.prefixes.info, ...args);
}; };
succeed: typeof console.log = (...args) => { succeed: typeof console.log = (...args) => {
@ -19,16 +33,25 @@ export default class ConsoleLog {
}; };
warn: typeof console.log = (...args) => { warn: typeof console.log = (...args) => {
console.warn(ConsoleLog.prefixes.warn, ...args); console.warn(...this.getArgs([ConsoleLog.prefixes.warn, ...args]));
}; };
error: typeof console.log = (...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) => { 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 // eslint-disable-next-line unicorn/no-process-exit
process.exit(1); 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;
}
} }