Skip to content

Commit 62dfcd1

Browse files
authored
feat(orm): add $diagnostics() for cache stats and slow query tracking (#2481)
2 parents bf6c9a4 + 14b55e3 commit 62dfcd1

7 files changed

Lines changed: 420 additions & 49 deletions

File tree

packages/orm/src/client/client-impl.ts

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import {
1010
Transaction,
1111
type KyselyProps,
1212
} from 'kysely';
13+
import z from 'zod';
1314
import type { ProcedureDef, SchemaDef } from '../schema';
1415
import type { AnyKysely } from '../utils/kysely-utils';
1516
import type { UnwrapTuplePromises } from '../utils/type-utils';
17+
import { formatError } from '../utils/zod-utils';
1618
import type {
1719
AuthType,
1820
ClientConstructor,
@@ -31,6 +33,7 @@ import { FindOperationHandler } from './crud/operations/find';
3133
import { GroupByOperationHandler } from './crud/operations/group-by';
3234
import { UpdateOperationHandler } from './crud/operations/update';
3335
import { InputValidator } from './crud/validator';
36+
import type { Diagnostics, QueryInfo } from './diagnostics';
3437
import { createConfigError, createNotFoundError, createNotSupportedError } from './errors';
3538
import { ZenStackDriver } from './executor/zenstack-driver';
3639
import { ZenStackQueryExecutor } from './executor/zenstack-query-executor';
@@ -60,6 +63,7 @@ export class ClientImpl {
6063
readonly kyselyProps: KyselyProps;
6164
private auth: AuthType<SchemaDef> | undefined;
6265
inputValidator: InputValidator<SchemaDef>;
66+
readonly slowQueries: QueryInfo[] = [];
6367

6468
constructor(
6569
private readonly schema: SchemaDef,
@@ -75,10 +79,7 @@ export class ClientImpl {
7579
...this.$options.functions,
7680
};
7781

78-
if (!baseClient && !options.skipValidationForComputedFields) {
79-
// validate computed fields configuration once for the root client
80-
this.validateComputedFieldsConfig();
81-
}
82+
this.validateOptions(baseClient, options);
8283

8384
// here we use kysely's props constructor so we can pass a custom query executor
8485
if (baseClient) {
@@ -96,6 +97,7 @@ export class ClientImpl {
9697
};
9798
this.kyselyRaw = baseClient.kyselyRaw;
9899
this.auth = baseClient.auth;
100+
this.slowQueries = baseClient.slowQueries;
99101
} else {
100102
const driver = new ZenStackDriver(options.dialect.createDriver(), new Log(this.$options.log ?? []));
101103
const compiler = options.dialect.createQueryCompiler();
@@ -127,37 +129,30 @@ export class ClientImpl {
127129
return createClientProxy(this);
128130
}
129131

130-
get $qb() {
131-
return this.kysely;
132-
}
133-
134-
get $qbRaw() {
135-
return this.kyselyRaw;
136-
}
137-
138-
get $zod() {
139-
return this.inputValidator.zodFactory;
140-
}
141-
142-
get isTransaction() {
143-
return this.kysely.isTransaction;
144-
}
132+
private validateOptions(baseClient: ClientImpl | undefined, options: ClientOptions<SchemaDef>) {
133+
if (!baseClient && !options.skipValidationForComputedFields) {
134+
// validate computed fields configuration once for the root client
135+
this.validateComputedFieldsConfig(options);
136+
}
145137

146-
/**
147-
* Create a new client with a new query executor.
148-
*/
149-
withExecutor(executor: QueryExecutor) {
150-
return new ClientImpl(this.schema, this.$options, this, executor);
138+
if (options.diagnostics) {
139+
const diagnosticsSchema = z.object({
140+
slowQueryThresholdMs: z.number().nonnegative().optional(),
141+
slowQueryMaxRecords: z.int().nonnegative().or(z.literal(Infinity)).optional(),
142+
});
143+
const parseResult = diagnosticsSchema.safeParse(options.diagnostics);
144+
if (!parseResult.success) {
145+
throw createConfigError(`Invalid diagnostics configuration: ${formatError(parseResult.error)}`);
146+
}
147+
}
151148
}
152149

153150
/**
154151
* Validates that all computed fields in the schema have corresponding configurations.
155152
*/
156-
private validateComputedFieldsConfig() {
153+
private validateComputedFieldsConfig(options: ClientOptions<SchemaDef>) {
157154
const computedFieldsConfig =
158-
'computedFields' in this.$options
159-
? (this.$options.computedFields as Record<string, any> | undefined)
160-
: undefined;
155+
'computedFields' in options ? (options.computedFields as Record<string, any> | undefined) : undefined;
161156

162157
for (const [modelName, modelDef] of Object.entries(this.$schema.models)) {
163158
if (modelDef.computedFields) {
@@ -183,6 +178,29 @@ export class ClientImpl {
183178
}
184179
}
185180

181+
get $qb() {
182+
return this.kysely;
183+
}
184+
185+
get $qbRaw() {
186+
return this.kyselyRaw;
187+
}
188+
189+
get $zod() {
190+
return this.inputValidator.zodFactory;
191+
}
192+
193+
get isTransaction() {
194+
return this.kysely.isTransaction;
195+
}
196+
197+
/**
198+
* Create a new client with a new query executor.
199+
*/
200+
withExecutor(executor: QueryExecutor) {
201+
return new ClientImpl(this.schema, this.$options, this, executor);
202+
}
203+
186204
// overload for interactive transaction
187205
$transaction<T>(
188206
callback: (tx: ClientContract<SchemaDef>) => Promise<T>,
@@ -422,6 +440,13 @@ export class ClientImpl {
422440
return this.$setOptions(newOptions);
423441
}
424442

443+
async $diagnostics(): Promise<Diagnostics> {
444+
return {
445+
zodCache: this.inputValidator.zodFactory.cacheStats,
446+
slowQueries: this.slowQueries.map((q) => ({ ...q })),
447+
};
448+
}
449+
425450
$executeRaw(query: TemplateStringsArray, ...values: any[]) {
426451
return createZenStackPromise(async () => {
427452
const result = await sql(query, ...values).execute(this.kysely);

packages/orm/src/client/contract.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import type {
3939
UpdateManyArgs,
4040
UpsertArgs,
4141
} from './crud-types';
42+
import type { Diagnostics } from './diagnostics';
4243
import type { ClientOptions, QueryOptions } from './options';
4344
import type { ExtClientMembersBase, ExtQueryArgsBase, RuntimePlugin } from './plugin';
4445
import type { ZenStackPromise } from './promise';
@@ -211,6 +212,11 @@ export type ClientContract<
211212
* @private
212213
*/
213214
$pushSchema(): Promise<void>;
215+
216+
/**
217+
* Returns diagnostics information such as cache and slow query statistics.
218+
*/
219+
$diagnostics(): Promise<Diagnostics>;
214220
} & {
215221
[Key in GetSlicedModels<Schema, Options> as Uncapitalize<Key>]: ModelOperations<Schema, Key, Options, ExtQueryArgs>;
216222
} & ProcedureOperations<Schema, Options> &
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Zod schema cache statistics.
3+
*/
4+
export interface ZodCacheStats {
5+
/**
6+
* Number of cached Zod schemas.
7+
*/
8+
size: number;
9+
10+
/**
11+
* Keys of the cached Zod schemas.
12+
*/
13+
keys: string[];
14+
}
15+
16+
/**
17+
* Information about a query, used for diagnostics.
18+
*/
19+
export interface QueryInfo {
20+
/**
21+
* Time when the query started.
22+
*/
23+
startedAt: Date;
24+
25+
/**
26+
* Duration of the query in milliseconds.
27+
*/
28+
durationMs: number;
29+
30+
/**
31+
* SQL statement of the query.
32+
*/
33+
sql: string;
34+
}
35+
36+
/**
37+
* ZenStackClient diagnostics.
38+
*/
39+
export interface Diagnostics {
40+
/**
41+
* Statistics about the Zod schemas (used for query args validation) cache.
42+
*/
43+
zodCache: ZodCacheStats;
44+
45+
/**
46+
* Slow queries.
47+
*/
48+
slowQueries: QueryInfo[];
49+
}

packages/orm/src/client/executor/zenstack-query-executor.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
} from 'kysely';
3232
import { match } from 'ts-pattern';
3333
import type { ModelDef, SchemaDef, TypeDefDef } from '../../schema';
34-
import { type ClientImpl } from '../client-impl';
34+
import type { ClientImpl } from '../client-impl';
3535
import { TransactionIsolationLevel, type ClientContract } from '../contract';
3636
import { getCrudDialect } from '../crud/dialects';
3737
import type { BaseCrudDialect } from '../crud/dialects/base-dialect';
@@ -70,6 +70,8 @@ type CallAfterMutationHooksArgs = {
7070
afterMutationEntities?: Record<string, unknown>[];
7171
};
7272

73+
const DEFAULT_MAX_SLOW_RECORDS = 100;
74+
7375
export class ZenStackQueryExecutor extends DefaultQueryExecutor {
7476
// #region constructor, fields and props
7577

@@ -673,8 +675,17 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie
673675
compiledQuery = { ...compiledQuery, parameters: parameters };
674676
}
675677

678+
const trackSlowQuery = this.options.diagnostics !== undefined;
679+
const startTimestamp = trackSlowQuery ? performance.now() : undefined;
680+
const startedAt = trackSlowQuery ? new Date() : undefined;
681+
676682
try {
677683
const result = await connection.executeQuery<any>(compiledQuery);
684+
685+
if (startTimestamp !== undefined) {
686+
this.trackSlowQuery(compiledQuery, startTimestamp, startedAt!);
687+
}
688+
678689
return this.ensureProperQueryResult(compiledQuery.query, result);
679690
} catch (err) {
680691
throw createDBQueryError(
@@ -686,6 +697,38 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie
686697
}
687698
}
688699

700+
private trackSlowQuery(compiledQuery: CompiledQuery, startTimestamp: number, startedAt: Date) {
701+
const durationMs = performance.now() - startTimestamp;
702+
const thresholdMs = this.options.diagnostics?.slowQueryThresholdMs;
703+
if (thresholdMs === undefined || durationMs < thresholdMs) {
704+
return;
705+
}
706+
707+
const slowQueries = this.client.slowQueries;
708+
const maxRecords = this.options.diagnostics?.slowQueryMaxRecords ?? DEFAULT_MAX_SLOW_RECORDS;
709+
if (maxRecords <= 0) {
710+
return;
711+
}
712+
713+
const queryInfo = { startedAt, durationMs, sql: compiledQuery.sql };
714+
715+
if (slowQueries.length >= maxRecords) {
716+
// find and remove the entry with the lowest duration
717+
let minIndex = 0;
718+
for (let i = 1; i < slowQueries.length; i++) {
719+
if (slowQueries[i]!.durationMs < slowQueries[minIndex]!.durationMs) {
720+
minIndex = i;
721+
}
722+
}
723+
// only replace if the new query is slower than the minimum
724+
if (durationMs > slowQueries[minIndex]!.durationMs) {
725+
slowQueries[minIndex] = queryInfo;
726+
}
727+
} else {
728+
slowQueries.push(queryInfo);
729+
}
730+
}
731+
689732
private ensureProperQueryResult(query: RootOperationNode, result: QueryResult<any>) {
690733
let finalResult = result;
691734

packages/orm/src/client/options.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,8 @@ export type ClientOptions<Schema extends SchemaDef> = QueryOptions<Schema> & {
178178
plugins?: AnyPlugin[];
179179

180180
/**
181-
* Logging configuration.
181+
* Logging configuration. Extends Kysely's log config with a `'warning'` level
182+
* for ZenStack-specific diagnostics (e.g., slow query warnings).
182183
*/
183184
log?: KyselyConfig['log'];
184185

@@ -211,9 +212,25 @@ export type ClientOptions<Schema extends SchemaDef> = QueryOptions<Schema> & {
211212
useCompactAliasNames?: boolean;
212213

213214
/**
214-
* Whether to skip validation for computed fields.
215+
* Whether to skip validation for whether all computed fields are properly defined.
215216
*/
216217
skipValidationForComputedFields?: boolean;
218+
219+
/**
220+
* Diagnostics related options.
221+
*/
222+
diagnostics?: {
223+
/**
224+
* Threshold in milliseconds for determining slow queries. If not specified, no query will be considered slow.
225+
*/
226+
slowQueryThresholdMs?: number;
227+
228+
/**
229+
* Maximum number of slow query records to keep in memory. Defaults to `100`. When the number is exceeded, the
230+
* entry with the lowest duration will be removed. Set to `Infinity` to keep unlimited records.
231+
*/
232+
slowQueryMaxRecords?: number;
233+
};
217234
} & (HasComputedFields<Schema> extends true
218235
? {
219236
/**

0 commit comments

Comments
 (0)