0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

feat(core): get hook execution stats (#3882)

This commit is contained in:
Xiao Yijun 2023-05-24 08:28:39 +08:00 committed by GitHub
parent 02eee1956f
commit 166c6f7da0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 168 additions and 15 deletions

View file

@ -3,6 +3,8 @@ import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import { got } from 'got';
import { mockHook } from '#src/__mocks__/hook.js';
import type { Interaction } from './hook.js';
const { jest } = import.meta;
@ -41,10 +43,12 @@ const post = jest
.mockImplementation(jest.fn(async () => ({ statusCode: 200, body: '{"message":"ok"}' })));
const insertLog = jest.fn();
const mockHookState = { requestCount: 100, successCount: 10 };
const getHookExecutionStatsByHookId = jest.fn().mockResolvedValue(mockHookState);
const findAllHooks = jest.fn().mockResolvedValue([hook]);
const { createHookLibrary } = await import('./hook.js');
const { triggerInteractionHooksIfNeeded } = createHookLibrary(
const { triggerInteractionHooksIfNeeded, attachExecutionStatsToHook } = createHookLibrary(
new MockQueries({
// @ts-expect-error
users: { findUserById: () => ({ id: 'user_id', username: 'user', extraField: 'not_ok' }) },
@ -52,7 +56,7 @@ const { triggerInteractionHooksIfNeeded } = createHookLibrary(
// @ts-expect-error
findApplicationById: async () => ({ id: 'app_id', extraField: 'not_ok' }),
},
logs: { insertLog },
logs: { insertLog, getHookExecutionStatsByHookId },
hooks: { findAllHooks },
})
);
@ -116,3 +120,10 @@ describe('triggerInteractionHooksIfNeeded()', () => {
jest.useRealTimers();
});
});
describe('attachExecutionStatsToHook', () => {
it('should attach execution stats to a hook', async () => {
const result = await attachExecutionStatsToHook(mockHook);
expect(result).toEqual({ ...mockHook, executionStats: mockHookState });
});
});

View file

@ -4,6 +4,8 @@ import {
InteractionEvent,
LogResult,
userInfoSelectFields,
type Hook,
type HookResponse,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { conditional, pick, trySafe } from '@silverhand/essentials';
@ -33,7 +35,7 @@ export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
export const createHookLibrary = (queries: Queries) => {
const {
applications: { findApplicationById },
logs: { insertLog },
logs: { insertLog, getHookExecutionStatsByHookId },
// TODO: @gao should we use the library function thus we can pass full userinfo to the payload?
users: { findUserById },
hooks: { findAllHooks },
@ -127,5 +129,13 @@ export const createHookLibrary = (queries: Queries) => {
);
};
return { triggerInteractionHooksIfNeeded };
const attachExecutionStatsToHook = async (hook: Hook): Promise<HookResponse> => ({
...hook,
executionStats: await getHookExecutionStatsByHookId(hook.id),
});
return {
triggerInteractionHooksIfNeeded,
attachExecutionStatsToHook,
};
};

View file

@ -1,6 +1,7 @@
import type { Log } from '@logto/schemas';
import type { HookExecutionStats, Log } from '@logto/schemas';
import { token, Logs } from '@logto/schemas';
import { conditionalSql, convertToIdentifiers } from '@logto/shared';
import { subDays } from 'date-fns';
import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik';
@ -86,6 +87,17 @@ export const createLogQueries = (pool: CommonQueryMethods) => {
and ${fields.payload}->>'result' = 'Success'
`);
const getHookExecutionStatsByHookId = async (hookId: string) => {
const startTimeExclusive = subDays(new Date(), 1).getTime();
return pool.one<HookExecutionStats>(sql`
select count(*) as request_count,
count(case when ${fields.payload}->>'result' = 'Success' then 1 end) as success_count
from ${table}
where ${fields.createdAt} > to_timestamp(${startTimeExclusive}::double precision / 1000)
and ${fields.payload}->>'hookId' = ${hookId}
`);
};
return {
insertLog,
countLogs,
@ -93,5 +105,6 @@ export const createLogQueries = (pool: CommonQueryMethods) => {
findLogById,
getDailyActiveUserCountsByTimeInterval,
countActiveUsersByTimeInterval,
getHookExecutionStatsByHookId,
};
};

View file

@ -53,6 +53,11 @@ const mockLog: Log = {
createdAt: 123,
};
const mockExecutionStats = {
requestCount: 1,
successCount: 1,
};
const logs = {
countLogs: jest.fn().mockResolvedValue({
count: 1,
@ -62,7 +67,23 @@ const logs = {
const { countLogs, findLogs } = logs;
const tenantContext = new MockTenant(undefined, { hooks, logs });
const mockQueries = {
hooks,
logs,
};
const attachExecutionStatsToHook = jest.fn().mockImplementation((hook) => ({
...hook,
executionStats: mockExecutionStats,
}));
const mockLibraries = {
hooks: {
attachExecutionStatsToHook,
},
};
const tenantContext = new MockTenant(undefined, mockQueries, undefined, mockLibraries);
const hookRoutes = await pickDefault(import('./hook.js'));
@ -80,6 +101,17 @@ describe('hook routes', () => {
expect(response.header).not.toHaveProperty('total-number');
});
it('GET /hooks?includeExecutionStats', async () => {
const response = await hookRequest.get('/hooks?includeExecutionStats=true');
expect(attachExecutionStatsToHook).toHaveBeenCalledTimes(mockHookList.length);
expect(response.body).toEqual(
mockHookList.map((hook) => ({
...hook,
executionStats: mockExecutionStats,
}))
);
});
it('GET /hooks/:id', async () => {
const hookIdInMockList = mockHookList[0]?.id ?? '';
const response = await hookRequest.get(`/hooks/${hookIdInMockList}`);
@ -87,6 +119,16 @@ describe('hook routes', () => {
expect(response.body.id).toBe(hookIdInMockList);
});
it('GET /hooks/:id?includeExecutionStats', async () => {
const hookIdInMockList = mockHookList[0]?.id ?? '';
const response = await hookRequest.get(`/hooks/${hookIdInMockList}?includeExecutionStats=true`);
expect(attachExecutionStatsToHook).toHaveBeenCalledWith(mockHookList[0]);
expect(response.body).toEqual({
...mockHookList[0],
executionStats: mockExecutionStats,
});
});
it('GET /hooks/:id/recent-logs should call countLogs and findLogs with correct parameters', async () => {
jest.useFakeTimers().setSystemTime(100_000);

View file

@ -1,6 +1,6 @@
import { Hooks, Logs, hookEventsGuard } from '@logto/schemas';
import { Hooks, Logs, hookEventsGuard, hookResponseGuard } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { conditional, deduplicate } from '@silverhand/essentials';
import { conditional, deduplicate, yes } from '@silverhand/essentials';
import { subDays } from 'date-fns';
import { z } from 'zod';
@ -16,18 +16,34 @@ const nonemptyUniqueHookEventsGuard = hookEventsGuard
.transform((events) => deduplicate(events));
export default function hookRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>
...[router, { queries, libraries }]: RouterInitArgs<T>
) {
const {
hooks: { findAllHooks, findHookById, insertHook, updateHookById, deleteHookById },
logs: { countLogs, findLogs },
} = queries;
const {
hooks: { attachExecutionStatsToHook },
} = libraries;
router.get(
'/hooks',
koaGuard({ response: Hooks.guard.array(), status: 200 }),
koaGuard({
query: z.object({ includeExecutionStats: z.string().optional() }),
response: hookResponseGuard.partial({ executionStats: true }).array(),
status: 200,
}),
async (ctx, next) => {
ctx.body = await findAllHooks();
const {
query: { includeExecutionStats },
} = ctx.guard;
const hooks = await findAllHooks();
ctx.body = yes(includeExecutionStats)
? await Promise.all(hooks.map(async (hook) => attachExecutionStatsToHook(hook)))
: hooks;
return next();
}
@ -37,15 +53,19 @@ export default function hookRoutes<T extends AuthedRouter>(
'/hooks/:id',
koaGuard({
params: z.object({ id: z.string() }),
response: Hooks.guard,
query: z.object({ includeExecutionStats: z.string().optional() }),
response: hookResponseGuard.partial({ executionStats: true }),
status: [200, 404],
}),
async (ctx, next) => {
const {
params: { id },
query: { includeExecutionStats },
} = ctx.guard;
ctx.body = await findHookById(id);
const hook = await findHookById(id);
ctx.body = includeExecutionStats ? await attachExecutionStatsToHook(hook) : hook;
return next();
}

View file

@ -1,7 +1,7 @@
import { createHmac } from 'node:crypto';
import { type RequestListener } from 'node:http';
import type { Hook, HookConfig, Log, LogKey } from '@logto/schemas';
import type { Hook, HookConfig, HookResponse, Log, LogKey } from '@logto/schemas';
import { HookEvent, SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas';
import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.js';
@ -336,4 +336,46 @@ describe('hooks', () => {
await deleteUser(id);
});
it('should get hook execution stats correctly', async () => {
const createdHook = await authedAdminApi
.post('hooks', { json: createPayload(HookEvent.PostRegister, 'http://localhost:9999') })
.json<Hook>();
const hooksWithExecutionStats = await authedAdminApi
.get('hooks?includeExecutionStats=true')
.json<HookResponse[]>();
for (const hook of hooksWithExecutionStats) {
expect(hook.executionStats).toBeTruthy();
}
// Init session and submit
const { username, password } = generateNewUserProfile({ username: true, password: true });
const client = await initClient();
await client.send(putInteraction, {
event: InteractionEvent.Register,
profile: {
username,
password,
},
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await waitFor(500); // Wait for hooks execution
const hookWithExecutionStats = await authedAdminApi
.get(`hooks/${createdHook.id}?includeExecutionStats=true`)
.json<HookResponse>();
const { executionStats } = hookWithExecutionStats;
expect(executionStats).toBeTruthy();
expect(executionStats.requestCount).toBe(1);
expect(executionStats.successCount).toBe(1);
await authedAdminApi.delete(`hooks/${createdHook.id}`);
await deleteUser(id);
});
});

View file

@ -1,4 +1,6 @@
import { type Application, type User } from '../db-entries/index.js';
import { z } from 'zod';
import { Hooks, type Application, type User } from '../db-entries/index.js';
import { type HookEvent } from '../foundations/index.js';
import type { userInfoSelectFields } from './user.js';
@ -13,3 +15,16 @@ export type HookEventPayload = {
user?: Pick<User, (typeof userInfoSelectFields)[number]>;
application?: Pick<Application, 'id' | 'type' | 'name' | 'description'>;
} & Record<string, unknown>;
const hookExecutionStatsGuard = z.object({
successCount: z.number(),
requestCount: z.number(),
});
export type HookExecutionStats = z.infer<typeof hookExecutionStatsGuard>;
export const hookResponseGuard = Hooks.guard.extend({
executionStats: hookExecutionStatsGuard,
});
export type HookResponse = z.infer<typeof hookResponseGuard>;