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:
parent
5adf3dfad7
commit
a9ccfc738d
43 changed files with 498 additions and 139 deletions
5
.changeset/green-phones-visit.md
Normal file
5
.changeset/green-phones-visit.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/app-insights": patch
|
||||
---
|
||||
|
||||
allow additional telemetry for `trackException()`
|
8
.changeset/grumpy-cougars-perform.md
Normal file
8
.changeset/grumpy-cougars-perform.md
Normal 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
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
4
packages/core/src/caches/utils.ts
Normal file
4
packages/core/src/caches/utils.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { ConsoleLog } from '@logto/shared';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export const cacheConsole = new ConsoleLog(chalk.magenta('cache'));
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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' }
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
key: T;
|
||||
value: JwtCustomizerType[T];
|
||||
useCase: 'test' | 'production';
|
||||
}) => {
|
||||
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 }));
|
||||
|
|
|
@ -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
44
packages/core/src/main.ts
Normal 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())]);
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -3,6 +3,7 @@ import {
|
|||
LogtoJwtTokenKeyType,
|
||||
type JwtCustomizerTestRequestBody,
|
||||
} from '@logto/schemas';
|
||||
import { ConsoleLog } from '@logto/shared';
|
||||
import { pickDefault } from '@logto/shared/esm';
|
||||
import { pick } from '@silverhand/essentials';
|
||||
|
||||
|
@ -60,11 +61,14 @@ describe('configs JWT customizer routes', () => {
|
|||
.put(`/configs/jwt-customizer/access-token`)
|
||||
.send(mockJwtCustomizerConfigForAccessToken.value);
|
||||
|
||||
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({
|
||||
key: LogtoJwtTokenKey.AccessToken,
|
||||
value: mockJwtCustomizerConfigForAccessToken.value,
|
||||
useCase: 'production',
|
||||
});
|
||||
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith(
|
||||
expect.any(ConsoleLog),
|
||||
{
|
||||
key: LogtoJwtTokenKey.AccessToken,
|
||||
value: mockJwtCustomizerConfigForAccessToken.value,
|
||||
useCase: 'production',
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockLogtoConfigsLibrary.upsertJwtCustomizer).toHaveBeenCalledWith(
|
||||
LogtoJwtTokenKey.AccessToken,
|
||||
|
@ -102,11 +106,14 @@ describe('configs JWT customizer routes', () => {
|
|||
.patch('/configs/jwt-customizer/access-token')
|
||||
.send(mockJwtCustomizerConfigForAccessToken.value);
|
||||
|
||||
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({
|
||||
key: LogtoJwtTokenKey.AccessToken,
|
||||
value: mockJwtCustomizerConfigForAccessToken.value,
|
||||
useCase: 'production',
|
||||
});
|
||||
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith(
|
||||
expect.any(ConsoleLog),
|
||||
{
|
||||
key: LogtoJwtTokenKey.AccessToken,
|
||||
value: mockJwtCustomizerConfigForAccessToken.value,
|
||||
useCase: 'production',
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockLogtoConfigsLibrary.updateJwtCustomizer).toHaveBeenCalledWith(
|
||||
LogtoJwtTokenKey.AccessToken,
|
||||
|
@ -141,6 +148,7 @@ describe('configs JWT customizer routes', () => {
|
|||
it('DELETE /configs/jwt-customizer/:tokenType should delete the record', async () => {
|
||||
const response = await routeRequester.delete('/configs/jwt-customizer/client-credentials');
|
||||
expect(tenantContext.libraries.jwtCustomizers.undeployJwtCustomizerScript).toHaveBeenCalledWith(
|
||||
expect.any(ConsoleLog),
|
||||
LogtoJwtTokenKey.ClientCredentials
|
||||
);
|
||||
expect(logtoConfigQueries.deleteJwtCustomizer).toHaveBeenCalledWith(
|
||||
|
@ -163,11 +171,14 @@ describe('configs JWT customizer routes', () => {
|
|||
|
||||
const response = await routeRequester.post('/configs/jwt-customizer/test').send(payload);
|
||||
|
||||
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({
|
||||
key: LogtoJwtTokenKey.ClientCredentials,
|
||||
value: payload,
|
||||
useCase: 'test',
|
||||
});
|
||||
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith(
|
||||
expect.any(ConsoleLog),
|
||||
{
|
||||
key: LogtoJwtTokenKey.ClientCredentials,
|
||||
value: payload,
|
||||
useCase: 'test',
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockCloudClient.post).toHaveBeenCalledWith('/api/services/custom-jwt', {
|
||||
body: payload,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
47
packages/core/src/utils/console.test.ts
Normal file
47
packages/core/src/utils/console.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
||||
|
|
21
packages/core/src/utils/request.ts
Normal file
21
packages/core/src/utils/request.ts
Normal 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 {};
|
||||
};
|
|
@ -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];
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>();
|
||||
|
||||
|
|
87
packages/shared/src/node/env/ConsoleLog.test.ts
vendored
Normal file
87
packages/shared/src/node/env/ConsoleLog.test.ts
vendored
Normal 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);
|
||||
});
|
||||
});
|
33
packages/shared/src/node/env/ConsoleLog.ts
vendored
33
packages/shared/src/node/env/ConsoleLog.ts
vendored
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue