@directus/api 24.0.1 → 25.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/app.js +10 -4
  2. package/dist/auth/drivers/oauth2.js +2 -3
  3. package/dist/auth/drivers/openid.js +2 -3
  4. package/dist/cache.d.ts +2 -2
  5. package/dist/cache.js +20 -7
  6. package/dist/controllers/assets.js +2 -2
  7. package/dist/controllers/metrics.d.ts +2 -0
  8. package/dist/controllers/metrics.js +33 -0
  9. package/dist/controllers/server.js +1 -1
  10. package/dist/database/helpers/number/dialects/mssql.d.ts +2 -2
  11. package/dist/database/helpers/number/dialects/mssql.js +3 -3
  12. package/dist/database/helpers/number/dialects/oracle.d.ts +2 -2
  13. package/dist/database/helpers/number/dialects/oracle.js +2 -2
  14. package/dist/database/helpers/number/dialects/sqlite.d.ts +2 -2
  15. package/dist/database/helpers/number/dialects/sqlite.js +2 -2
  16. package/dist/database/helpers/number/types.d.ts +2 -2
  17. package/dist/database/helpers/number/types.js +2 -2
  18. package/dist/database/index.js +3 -0
  19. package/dist/metrics/index.d.ts +1 -0
  20. package/dist/metrics/index.js +1 -0
  21. package/dist/metrics/lib/create-metrics.d.ts +15 -0
  22. package/dist/metrics/lib/create-metrics.js +239 -0
  23. package/dist/metrics/lib/use-metrics.d.ts +17 -0
  24. package/dist/metrics/lib/use-metrics.js +15 -0
  25. package/dist/metrics/types/metric.d.ts +1 -0
  26. package/dist/metrics/types/metric.js +1 -0
  27. package/dist/middleware/respond.js +7 -1
  28. package/dist/operations/condition/index.js +7 -2
  29. package/dist/schedules/metrics.d.ts +7 -0
  30. package/dist/schedules/metrics.js +44 -0
  31. package/dist/services/assets.d.ts +6 -1
  32. package/dist/services/assets.js +8 -6
  33. package/dist/services/collections.js +31 -1
  34. package/dist/services/fields/build-collection-and-field-relations.d.ts +21 -0
  35. package/dist/services/fields/build-collection-and-field-relations.js +55 -0
  36. package/dist/services/fields/get-collection-meta-updates.d.ts +11 -0
  37. package/dist/services/fields/get-collection-meta-updates.js +72 -0
  38. package/dist/services/fields/get-collection-relation-list.d.ts +5 -0
  39. package/dist/services/fields/get-collection-relation-list.js +28 -0
  40. package/dist/services/fields.js +18 -13
  41. package/dist/services/graphql/errors/format.d.ts +6 -0
  42. package/dist/services/graphql/errors/format.js +14 -0
  43. package/dist/services/graphql/index.d.ts +5 -53
  44. package/dist/services/graphql/index.js +5 -2720
  45. package/dist/services/graphql/resolvers/mutation.d.ts +4 -0
  46. package/dist/services/graphql/resolvers/mutation.js +74 -0
  47. package/dist/services/graphql/resolvers/query.d.ts +8 -0
  48. package/dist/services/graphql/resolvers/query.js +87 -0
  49. package/dist/services/graphql/resolvers/system-admin.d.ts +5 -0
  50. package/dist/services/graphql/resolvers/system-admin.js +236 -0
  51. package/dist/services/graphql/resolvers/system-global.d.ts +7 -0
  52. package/dist/services/graphql/resolvers/system-global.js +435 -0
  53. package/dist/services/graphql/resolvers/system.d.ts +11 -0
  54. package/dist/services/graphql/resolvers/system.js +554 -0
  55. package/dist/services/graphql/schema/get-types.d.ts +12 -0
  56. package/dist/services/graphql/schema/get-types.js +223 -0
  57. package/dist/services/graphql/schema/index.d.ts +32 -0
  58. package/dist/services/graphql/schema/index.js +190 -0
  59. package/dist/services/graphql/schema/parse-args.d.ts +9 -0
  60. package/dist/services/graphql/schema/parse-args.js +35 -0
  61. package/dist/services/graphql/schema/parse-query.d.ts +7 -0
  62. package/dist/services/graphql/schema/parse-query.js +98 -0
  63. package/dist/services/graphql/schema/read.d.ts +12 -0
  64. package/dist/services/graphql/schema/read.js +653 -0
  65. package/dist/services/graphql/schema/write.d.ts +9 -0
  66. package/dist/services/graphql/schema/write.js +142 -0
  67. package/dist/services/graphql/subscription.d.ts +1 -1
  68. package/dist/services/graphql/subscription.js +7 -6
  69. package/dist/services/graphql/utils/aggrgate-query.d.ts +6 -0
  70. package/dist/services/graphql/utils/aggrgate-query.js +32 -0
  71. package/dist/services/graphql/utils/replace-fragments.d.ts +6 -0
  72. package/dist/services/graphql/utils/replace-fragments.js +21 -0
  73. package/dist/services/graphql/utils/replace-funcs.d.ts +5 -0
  74. package/dist/services/graphql/utils/replace-funcs.js +21 -0
  75. package/dist/services/graphql/utils/sanitize-gql-schema.d.ts +1 -1
  76. package/dist/services/graphql/utils/sanitize-gql-schema.js +5 -5
  77. package/dist/services/items.js +0 -2
  78. package/dist/services/meta.js +27 -84
  79. package/dist/services/users.d.ts +4 -0
  80. package/dist/services/users.js +23 -1
  81. package/dist/utils/apply-query.d.ts +1 -1
  82. package/dist/utils/apply-query.js +58 -21
  83. package/dist/utils/freeze-schema.d.ts +3 -0
  84. package/dist/utils/freeze-schema.js +31 -0
  85. package/dist/utils/get-accountability-for-token.js +1 -0
  86. package/dist/utils/get-milliseconds.js +1 -1
  87. package/dist/utils/get-schema.js +10 -5
  88. package/dist/utils/permissions-cachable.d.ts +8 -0
  89. package/dist/utils/permissions-cachable.js +38 -0
  90. package/dist/utils/sanitize-schema.d.ts +1 -1
  91. package/dist/websocket/messages.d.ts +6 -6
  92. package/package.json +23 -20
package/dist/app.js CHANGED
@@ -9,8 +9,8 @@ import { createRequire } from 'node:module';
9
9
  import path from 'path';
10
10
  import qs from 'qs';
11
11
  import { registerAuthProviders } from './auth.js';
12
- import activityRouter from './controllers/activity.js';
13
12
  import accessRouter from './controllers/access.js';
13
+ import activityRouter from './controllers/activity.js';
14
14
  import assetsRouter from './controllers/assets.js';
15
15
  import authRouter from './controllers/auth.js';
16
16
  import collectionsRouter from './controllers/collections.js';
@@ -23,6 +23,7 @@ import flowsRouter from './controllers/flows.js';
23
23
  import foldersRouter from './controllers/folders.js';
24
24
  import graphqlRouter from './controllers/graphql.js';
25
25
  import itemsRouter from './controllers/items.js';
26
+ import metricsRouter from './controllers/metrics.js';
26
27
  import notFoundHandler from './controllers/not-found.js';
27
28
  import notificationsRouter from './controllers/notifications.js';
28
29
  import operationsRouter from './controllers/operations.js';
@@ -43,9 +44,6 @@ import usersRouter from './controllers/users.js';
43
44
  import utilsRouter from './controllers/utils.js';
44
45
  import versionsRouter from './controllers/versions.js';
45
46
  import webhooksRouter from './controllers/webhooks.js';
46
- import retentionSchedule from './schedules/retention.js';
47
- import telemetrySchedule from './schedules/telemetry.js';
48
- import tusSchedule from './schedules/tus.js';
49
47
  import { isInstalled, validateDatabaseConnection, validateDatabaseExtensions, validateMigrations, } from './database/index.js';
50
48
  import emitter from './emitter.js';
51
49
  import { getExtensionManager } from './extensions/index.js';
@@ -60,6 +58,10 @@ import rateLimiterGlobal from './middleware/rate-limiter-global.js';
60
58
  import rateLimiter from './middleware/rate-limiter-ip.js';
61
59
  import sanitizeQuery from './middleware/sanitize-query.js';
62
60
  import schema from './middleware/schema.js';
61
+ import metricsSchedule from './schedules/metrics.js';
62
+ import retentionSchedule from './schedules/retention.js';
63
+ import telemetrySchedule from './schedules/telemetry.js';
64
+ import tusSchedule from './schedules/tus.js';
63
65
  import { getConfigFromEnv } from './utils/get-config-from-env.js';
64
66
  import { Url } from './utils/url.js';
65
67
  import { validateStorage } from './utils/validate-storage.js';
@@ -223,6 +225,9 @@ export default async function createApp() {
223
225
  app.use('/flows', flowsRouter);
224
226
  app.use('/folders', foldersRouter);
225
227
  app.use('/items', itemsRouter);
228
+ if (env['METRICS_ENABLED'] === true) {
229
+ app.use('/metrics', metricsRouter);
230
+ }
226
231
  app.use('/notifications', notificationsRouter);
227
232
  app.use('/operations', operationsRouter);
228
233
  app.use('/panels', panelsRouter);
@@ -251,6 +256,7 @@ export default async function createApp() {
251
256
  await retentionSchedule();
252
257
  await telemetrySchedule();
253
258
  await tusSchedule();
259
+ await metricsSchedule();
254
260
  await emitter.emitInit('app.after', { app });
255
261
  return app;
256
262
  }
@@ -19,6 +19,7 @@ import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
19
19
  import { getIPFromReq } from '../../utils/get-ip-from-req.js';
20
20
  import { getSecret } from '../../utils/get-secret.js';
21
21
  import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
22
+ import { verifyJWT } from '../../utils/jwt.js';
22
23
  import { Url } from '../../utils/url.js';
23
24
  import { LocalAuthDriver } from './local.js';
24
25
  export class OAuth2AuthDriver extends LocalAuthDriver {
@@ -254,9 +255,7 @@ export function createOAuth2AuthRouter(providerName) {
254
255
  const logger = useLogger();
255
256
  let tokenData;
256
257
  try {
257
- tokenData = jwt.verify(req.cookies[`oauth2.${providerName}`], getSecret(), {
258
- issuer: 'directus',
259
- });
258
+ tokenData = verifyJWT(req.cookies[`oauth2.${providerName}`], getSecret());
260
259
  }
261
260
  catch (e) {
262
261
  logger.warn(e, `[OAuth2] Couldn't verify OAuth2 cookie`);
@@ -19,6 +19,7 @@ import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
19
19
  import { getIPFromReq } from '../../utils/get-ip-from-req.js';
20
20
  import { getSecret } from '../../utils/get-secret.js';
21
21
  import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
22
+ import { verifyJWT } from '../../utils/jwt.js';
22
23
  import { Url } from '../../utils/url.js';
23
24
  import { LocalAuthDriver } from './local.js';
24
25
  export class OpenIDAuthDriver extends LocalAuthDriver {
@@ -306,9 +307,7 @@ export function createOpenIDAuthRouter(providerName) {
306
307
  const logger = useLogger();
307
308
  let tokenData;
308
309
  try {
309
- tokenData = jwt.verify(req.cookies[`openid.${providerName}`], getSecret(), {
310
- issuer: 'directus',
311
- });
310
+ tokenData = verifyJWT(req.cookies[`openid.${providerName}`], getSecret());
312
311
  }
313
312
  catch (e) {
314
313
  logger.warn(e, `[OpenID] Couldn't verify OpenID cookie`);
package/dist/cache.d.ts CHANGED
@@ -13,7 +13,7 @@ export declare function clearSystemCache(opts?: {
13
13
  }): Promise<void>;
14
14
  export declare function setSystemCache(key: string, value: any, ttl?: number): Promise<void>;
15
15
  export declare function getSystemCache(key: string): Promise<Record<string, any>>;
16
- export declare function setLocalSchemaCache(schema: SchemaOverview): Promise<void>;
17
- export declare function getLocalSchemaCache(): Promise<SchemaOverview | undefined>;
16
+ export declare function setMemorySchemaCache(schema: SchemaOverview): void;
17
+ export declare function getMemorySchemaCache(): Readonly<SchemaOverview> | undefined;
18
18
  export declare function setCacheValue(cache: Keyv, key: string, value: Record<string, any> | Record<string, any>[], ttl?: number): Promise<void>;
19
19
  export declare function getCacheValue(cache: Keyv, key: string): Promise<any>;
package/dist/cache.js CHANGED
@@ -9,14 +9,16 @@ import { getConfigFromEnv } from './utils/get-config-from-env.js';
9
9
  import { getMilliseconds } from './utils/get-milliseconds.js';
10
10
  import { validateEnv } from './utils/validate-env.js';
11
11
  import { createRequire } from 'node:module';
12
+ import { freezeSchema, unfreezeSchema } from './utils/freeze-schema.js';
12
13
  const logger = useLogger();
13
14
  const env = useEnv();
14
15
  const require = createRequire(import.meta.url);
15
16
  let cache = null;
16
17
  let systemCache = null;
17
- let localSchemaCache = null;
18
18
  let lockCache = null;
19
19
  let messengerSubscribed = false;
20
+ let localSchemaCache = null;
21
+ let memorySchemaCache = null;
20
22
  const messenger = useBus();
21
23
  if (redisConfigAvailable() && !messengerSubscribed) {
22
24
  messengerSubscribed = true;
@@ -25,6 +27,7 @@ if (redisConfigAvailable() && !messengerSubscribed) {
25
27
  await cache.clear();
26
28
  }
27
29
  await localSchemaCache?.clear();
30
+ memorySchemaCache = null;
28
31
  });
29
32
  }
30
33
  export function getCache() {
@@ -61,6 +64,7 @@ export async function clearSystemCache(opts) {
61
64
  await lockCache.delete('system-cache-lock');
62
65
  }
63
66
  await localSchemaCache.clear();
67
+ memorySchemaCache = null;
64
68
  // Since a lot of cached permission function rely on the schema it needs to be cleared as well
65
69
  await clearPermissionCache();
66
70
  messenger.publish('schemaChanged', { autoPurgeCache: opts?.autoPurgeCache });
@@ -75,13 +79,22 @@ export async function getSystemCache(key) {
75
79
  const { systemCache } = getCache();
76
80
  return await getCacheValue(systemCache, key);
77
81
  }
78
- export async function setLocalSchemaCache(schema) {
79
- const { localSchemaCache } = getCache();
80
- await localSchemaCache.set('schema', schema);
82
+ export function setMemorySchemaCache(schema) {
83
+ if (Object.isFrozen(schema)) {
84
+ memorySchemaCache = schema;
85
+ }
86
+ else {
87
+ memorySchemaCache = freezeSchema(schema);
88
+ }
81
89
  }
82
- export async function getLocalSchemaCache() {
83
- const { localSchemaCache } = getCache();
84
- return await localSchemaCache.get('schema');
90
+ export function getMemorySchemaCache() {
91
+ if (env['CACHE_SCHEMA_FREEZE_ENABLED']) {
92
+ return memorySchemaCache ?? undefined;
93
+ }
94
+ else if (memorySchemaCache) {
95
+ return unfreezeSchema(memorySchemaCache);
96
+ }
97
+ return undefined;
85
98
  }
86
99
  export async function setCacheValue(cache, key, value, ttl) {
87
100
  const compressed = await compress(value);
@@ -151,7 +151,7 @@ asyncHandler(async (req, res) => {
151
151
  }
152
152
  }
153
153
  }
154
- const { stream, file, stat } = await service.getAsset(id, { transformationParams, acceptFormat }, range);
154
+ const { stream, file, stat } = await service.getAsset(id, { transformationParams, acceptFormat }, range, true);
155
155
  const filename = req.params['filename'] ?? file.filename_download;
156
156
  res.attachment(filename);
157
157
  res.setHeader('Content-Type', file.type);
@@ -180,7 +180,7 @@ asyncHandler(async (req, res) => {
180
180
  res.setHeader('Content-Length', stat.size);
181
181
  return res.end();
182
182
  }
183
- stream
183
+ (await stream())
184
184
  .on('error', (error) => {
185
185
  logger.error(error, `Couldn't stream file ${file.id} to the client`);
186
186
  if (!res.headersSent) {
@@ -0,0 +1,2 @@
1
+ declare const router: import("express-serve-static-core").Router;
2
+ export default router;
@@ -0,0 +1,33 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { ForbiddenError } from '@directus/errors';
3
+ import { Router } from 'express';
4
+ import { useMetrics } from '../metrics/index.js';
5
+ import asyncHandler from '../utils/async-handler.js';
6
+ const env = useEnv();
7
+ const router = Router();
8
+ const metrics = useMetrics();
9
+ router.get('/', asyncHandler(async (req, _res, next) => {
10
+ if (req.accountability?.admin === true) {
11
+ return next();
12
+ }
13
+ // support Bearer Token of type `Metrics`
14
+ const metricTokens = env['METRICS_TOKENS'];
15
+ if (!req.headers || !req.headers.authorization || !metricTokens) {
16
+ throw new ForbiddenError();
17
+ }
18
+ const parts = req.headers.authorization.split(' ');
19
+ if (parts.length !== 2 || parts[0].toLowerCase() !== 'metrics') {
20
+ throw new ForbiddenError();
21
+ }
22
+ if (metricTokens.find((mt) => mt.toString() === parts[1]) !== undefined) {
23
+ return next();
24
+ }
25
+ throw new ForbiddenError();
26
+ }), asyncHandler(async (_req, res) => {
27
+ res.set('Content-Type', 'text/plain');
28
+ // Don't cache anything by default
29
+ res.setHeader('Cache-Control', 'no-cache');
30
+ res.setHeader('Vary', 'Origin, Cache-Control');
31
+ return res.send(await metrics?.readAll());
32
+ }));
33
+ export default router;
@@ -1,6 +1,6 @@
1
+ import { RouteNotFoundError } from '@directus/errors';
1
2
  import { format } from 'date-fns';
2
3
  import { Router } from 'express';
3
- import { RouteNotFoundError } from '@directus/errors';
4
4
  import { respond } from '../middleware/respond.js';
5
5
  import { ServerService } from '../services/server.js';
6
6
  import { SpecificationService } from '../services/specifications.js';
@@ -1,7 +1,7 @@
1
+ import type { NumericValue } from '@directus/types';
1
2
  import type { Knex } from 'knex';
2
3
  import { NumberDatabaseHelper, type NumberInfo } from '../types.js';
3
- import type { NumericValue } from '@directus/types';
4
4
  export declare class NumberHelperMSSQL extends NumberDatabaseHelper {
5
- addSearchCondition(dbQuery: Knex.QueryBuilder, collection: string, name: string, value: NumericValue): Knex.QueryBuilder;
5
+ addSearchCondition(dbQuery: Knex.QueryBuilder, collection: string, name: string, value: NumericValue, logical: 'and' | 'or'): Knex.QueryBuilder;
6
6
  isNumberValid(value: NumericValue, info: NumberInfo): boolean;
7
7
  }
@@ -1,9 +1,9 @@
1
+ import { NumberDatabaseHelper } from '../types.js';
1
2
  import { maybeStringifyBigInt } from '../utils/maybe-stringify-big-int.js';
2
3
  import { numberInRange } from '../utils/number-in-range.js';
3
- import { NumberDatabaseHelper } from '../types.js';
4
4
  export class NumberHelperMSSQL extends NumberDatabaseHelper {
5
- addSearchCondition(dbQuery, collection, name, value) {
6
- return dbQuery.orWhere({ [`${collection}.${name}`]: maybeStringifyBigInt(value) });
5
+ addSearchCondition(dbQuery, collection, name, value, logical) {
6
+ return dbQuery[logical].where({ [`${collection}.${name}`]: maybeStringifyBigInt(value) });
7
7
  }
8
8
  isNumberValid(value, info) {
9
9
  return numberInRange(value, info);
@@ -1,6 +1,6 @@
1
+ import type { NumericValue } from '@directus/types';
1
2
  import type { Knex } from 'knex';
2
3
  import { NumberDatabaseHelper } from '../types.js';
3
- import type { NumericValue } from '@directus/types';
4
4
  export declare class NumberHelperOracle extends NumberDatabaseHelper {
5
- addSearchCondition(dbQuery: Knex.QueryBuilder, collection: string, name: string, value: NumericValue): Knex.QueryBuilder;
5
+ addSearchCondition(dbQuery: Knex.QueryBuilder, collection: string, name: string, value: NumericValue, logical: 'and' | 'or'): Knex.QueryBuilder;
6
6
  }
@@ -1,7 +1,7 @@
1
1
  import { NumberDatabaseHelper } from '../types.js';
2
2
  import { maybeStringifyBigInt } from '../utils/maybe-stringify-big-int.js';
3
3
  export class NumberHelperOracle extends NumberDatabaseHelper {
4
- addSearchCondition(dbQuery, collection, name, value) {
5
- return dbQuery.orWhere({ [`${collection}.${name}`]: maybeStringifyBigInt(value) });
4
+ addSearchCondition(dbQuery, collection, name, value, logical) {
5
+ return dbQuery[logical].where({ [`${collection}.${name}`]: maybeStringifyBigInt(value) });
6
6
  }
7
7
  }
@@ -1,6 +1,6 @@
1
+ import type { NumericValue } from '@directus/types';
1
2
  import type { Knex } from 'knex';
2
3
  import { NumberDatabaseHelper } from '../types.js';
3
- import type { NumericValue } from '@directus/types';
4
4
  export declare class NumberHelperSQLite extends NumberDatabaseHelper {
5
- addSearchCondition(dbQuery: Knex.QueryBuilder, collection: string, name: string, value: NumericValue): Knex.QueryBuilder;
5
+ addSearchCondition(dbQuery: Knex.QueryBuilder, collection: string, name: string, value: NumericValue, logical: 'and' | 'or'): Knex.QueryBuilder;
6
6
  }
@@ -1,7 +1,7 @@
1
1
  import { NumberDatabaseHelper } from '../types.js';
2
2
  import { maybeStringifyBigInt } from '../utils/maybe-stringify-big-int.js';
3
3
  export class NumberHelperSQLite extends NumberDatabaseHelper {
4
- addSearchCondition(dbQuery, collection, name, value) {
5
- return dbQuery.orWhere({ [`${collection}.${name}`]: maybeStringifyBigInt(value) });
4
+ addSearchCondition(dbQuery, collection, name, value, logical) {
5
+ return dbQuery[logical].where({ [`${collection}.${name}`]: maybeStringifyBigInt(value) });
6
6
  }
7
7
  }
@@ -1,12 +1,12 @@
1
+ import type { NumericType, NumericValue } from '@directus/types';
1
2
  import type { Knex } from 'knex';
2
3
  import { DatabaseHelper } from '../types.js';
3
- import type { NumericType, NumericValue } from '@directus/types';
4
4
  export type NumberInfo = {
5
5
  type: NumericType;
6
6
  precision: number | null;
7
7
  scale: number | null;
8
8
  };
9
9
  export declare abstract class NumberDatabaseHelper extends DatabaseHelper {
10
- addSearchCondition(dbQuery: Knex.QueryBuilder, collection: string, name: string, value: NumericValue): Knex.QueryBuilder;
10
+ addSearchCondition(dbQuery: Knex.QueryBuilder, collection: string, name: string, value: NumericValue, logical: 'and' | 'or'): Knex.QueryBuilder;
11
11
  isNumberValid(_value: NumericValue, _info: NumberInfo): boolean;
12
12
  }
@@ -1,7 +1,7 @@
1
1
  import { DatabaseHelper } from '../types.js';
2
2
  export class NumberDatabaseHelper extends DatabaseHelper {
3
- addSearchCondition(dbQuery, collection, name, value) {
4
- return dbQuery.orWhere({ [`${collection}.${name}`]: value });
3
+ addSearchCondition(dbQuery, collection, name, value, logical) {
4
+ return dbQuery[logical].where({ [`${collection}.${name}`]: value });
5
5
  }
6
6
  isNumberValid(_value, _info) {
7
7
  return true;
@@ -11,6 +11,7 @@ import { performance } from 'perf_hooks';
11
11
  import { promisify } from 'util';
12
12
  import { getExtensionsPath } from '../extensions/lib/get-extensions-path.js';
13
13
  import { useLogger } from '../logger/index.js';
14
+ import { useMetrics } from '../metrics/index.js';
14
15
  import { getConfigFromEnv } from '../utils/get-config-from-env.js';
15
16
  import { validateEnv } from '../utils/validate-env.js';
16
17
  import { getHelpers } from './helpers/index.js';
@@ -25,6 +26,7 @@ export function getDatabase() {
25
26
  }
26
27
  const env = useEnv();
27
28
  const logger = useLogger();
29
+ const metrics = useMetrics();
28
30
  const { client, version, searchPath, connectionString, pool: poolConfig = {}, ...connectionConfig } = getConfigFromEnv('DB_', ['DB_EXCLUDE_TABLES']);
29
31
  const requiredEnvVars = ['DB_CLIENT'];
30
32
  switch (client) {
@@ -148,6 +150,7 @@ export function getDatabase() {
148
150
  if (time) {
149
151
  delta = performance.now() - time;
150
152
  times.delete(queryInfo.__knexUid);
153
+ metrics?.getDatabaseResponseMetric()?.observe(delta);
151
154
  }
152
155
  // eslint-disable-next-line no-nested-ternary
153
156
  const bindings = queryInfo.bindings
@@ -0,0 +1 @@
1
+ export * from './lib/use-metrics.js';
@@ -0,0 +1 @@
1
+ export * from './lib/use-metrics.js';
@@ -0,0 +1,15 @@
1
+ import type { MetricObjectWithValues, MetricValue } from 'prom-client';
2
+ import { Counter, Histogram } from 'prom-client';
3
+ export declare function createMetrics(): {
4
+ getDatabaseErrorMetric: () => Counter | null;
5
+ getDatabaseResponseMetric: () => Histogram | null;
6
+ getCacheErrorMetric: () => Counter | null;
7
+ getRedisErrorMetric: () => Counter | null;
8
+ getStorageErrorMetric: (location: string) => Counter | null;
9
+ aggregate: (data: {
10
+ pid: number;
11
+ metrics: MetricObjectWithValues<MetricValue<string>>[];
12
+ }) => Promise<void>;
13
+ generate: () => Promise<void>;
14
+ readAll: () => Promise<string>;
15
+ };
@@ -0,0 +1,239 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { toArray } from '@directus/utils';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { Readable } from 'node:stream';
5
+ import { promisify } from 'node:util';
6
+ import pm2 from 'pm2';
7
+ import { AggregatorRegistry, Counter, Histogram, register } from 'prom-client';
8
+ import { getCache } from '../../cache.js';
9
+ import { hasDatabaseConnection } from '../../database/index.js';
10
+ import { redisConfigAvailable, useRedis } from '../../redis/index.js';
11
+ import { getStorage } from '../../storage/index.js';
12
+ const isPM2 = 'PM2_HOME' in process.env;
13
+ const METRICS_SYNC_PACKET = 'directus:metrics---data-sync';
14
+ const listApps = promisify(pm2.list.bind(pm2));
15
+ const sendDataToProcessId = promisify(pm2.sendDataToProcessId.bind(pm2));
16
+ export function createMetrics() {
17
+ const env = useEnv();
18
+ const services = env['METRICS_SERVICES'] ?? [];
19
+ const aggregates = new Map();
20
+ /**
21
+ * Listen for PM2 metric data sync messages and add them to the aggregate
22
+ */
23
+ if (isPM2) {
24
+ process.on('message', (packet) => {
25
+ if (!packet.data || packet.topic !== METRICS_SYNC_PACKET)
26
+ return;
27
+ aggregate(packet.data);
28
+ });
29
+ }
30
+ async function generate() {
31
+ const checkId = randomUUID();
32
+ await Promise.all([
33
+ trackDatabaseMetric(),
34
+ trackCacheMetric(checkId),
35
+ trackRedisMetric(checkId),
36
+ trackStorageMetric(checkId),
37
+ ]);
38
+ /**
39
+ * Push generated metrics to all pm2 instances
40
+ */
41
+ if (isPM2) {
42
+ try {
43
+ const apps = await listApps();
44
+ const data = await register.getMetricsAsJSON();
45
+ const syncs = [];
46
+ for (const app of apps) {
47
+ if (app.pm_id === undefined || app.pid === 0 || app.name !== 'directus') {
48
+ continue;
49
+ }
50
+ syncs.push(sendDataToProcessId(app.pm_id, {
51
+ data: { pid: process.pid, metrics: data },
52
+ topic: METRICS_SYNC_PACKET,
53
+ }));
54
+ }
55
+ await Promise.allSettled(syncs);
56
+ }
57
+ catch {
58
+ // ignore
59
+ }
60
+ }
61
+ }
62
+ /**
63
+ * Add PM2 synced metric to the aggregate store.
64
+ * Subsequent syncs for the given instance will override previous value.
65
+ */
66
+ async function aggregate(data) {
67
+ aggregates.set(data.pid, data.metrics);
68
+ }
69
+ async function readAll() {
70
+ /**
71
+ * In a PM2 context we must aggregate the metrics across instances ensuring
72
+ * only currently active instances are added to the aggregate
73
+ */
74
+ if (isPM2 && aggregates.size !== 0) {
75
+ const apps = await listApps();
76
+ const aggregate = [];
77
+ for (const app of apps) {
78
+ if (aggregates.has(app.pid)) {
79
+ aggregate.push(aggregates.get(app.pid));
80
+ }
81
+ }
82
+ if (aggregate.length !== 0) {
83
+ return AggregatorRegistry.aggregate(aggregate).metrics();
84
+ }
85
+ }
86
+ return register.metrics();
87
+ }
88
+ function getDatabaseErrorMetric() {
89
+ if (services.includes('database') === false) {
90
+ return null;
91
+ }
92
+ const client = env['DB_CLIENT'];
93
+ let metric = register.getSingleMetric(`directus_db_${client}_connection_errors`);
94
+ if (!metric) {
95
+ metric = new Counter({
96
+ name: `directus_db_${client}_connection_errors`,
97
+ help: `${client} Database connection error count`,
98
+ });
99
+ }
100
+ return metric;
101
+ }
102
+ function getDatabaseResponseMetric() {
103
+ if (services.includes('database') === false) {
104
+ return null;
105
+ }
106
+ const client = env['DB_CLIENT'];
107
+ let metric = register.getSingleMetric(`directus_db_${client}_response_time_ms`);
108
+ if (!metric) {
109
+ metric = new Histogram({
110
+ name: `directus_db_${client}_response_time_ms`,
111
+ help: `${client} Database connection response time`,
112
+ buckets: [1, 10, 20, 40, 60, 80, 100, 200, 500, 750, 1000],
113
+ });
114
+ }
115
+ return metric;
116
+ }
117
+ function getCacheErrorMetric() {
118
+ if (services.includes('cache') === false || env['CACHE_ENABLED'] !== true) {
119
+ return null;
120
+ }
121
+ if (env['CACHE_STORE'] === 'redis' && redisConfigAvailable() !== true) {
122
+ return null;
123
+ }
124
+ let metric = register.getSingleMetric(`directus_cache_${env['CACHE_STORE']}_connection_errors`);
125
+ if (!metric) {
126
+ metric = new Counter({
127
+ name: `directus_cache_${env['CACHE_STORE']}_connection_errors`,
128
+ help: 'Cache connection error count',
129
+ });
130
+ }
131
+ return metric;
132
+ }
133
+ function getRedisErrorMetric() {
134
+ if (services.includes('redis') === false || redisConfigAvailable() !== true) {
135
+ return null;
136
+ }
137
+ let metric = register.getSingleMetric('directus_redis_connection_errors');
138
+ if (!metric) {
139
+ metric = new Counter({
140
+ name: `directus_redis_connection_errors`,
141
+ help: 'Redis connection error count',
142
+ });
143
+ }
144
+ return metric;
145
+ }
146
+ function getStorageErrorMetric(location) {
147
+ if (services.includes('storage') === false) {
148
+ return null;
149
+ }
150
+ let metric = register.getSingleMetric(`directus_storage_${location}_connection_errors`);
151
+ if (!metric) {
152
+ metric = new Counter({
153
+ name: `directus_storage_${location}_connection_errors`,
154
+ help: `${location} storage connection error count`,
155
+ });
156
+ }
157
+ return metric;
158
+ }
159
+ async function trackDatabaseMetric() {
160
+ const metric = getDatabaseErrorMetric();
161
+ if (metric === null) {
162
+ return;
163
+ }
164
+ try {
165
+ if (!(await hasDatabaseConnection())) {
166
+ metric.inc();
167
+ }
168
+ }
169
+ catch {
170
+ metric.inc();
171
+ }
172
+ }
173
+ async function trackCacheMetric(checkId) {
174
+ const metric = getCacheErrorMetric();
175
+ if (metric === null) {
176
+ return;
177
+ }
178
+ const { cache } = getCache();
179
+ if (!cache) {
180
+ return;
181
+ }
182
+ try {
183
+ await cache.set(`metrics-${checkId}`, '1', 5);
184
+ await cache.delete(`metrics-${checkId}`);
185
+ }
186
+ catch {
187
+ metric.inc();
188
+ }
189
+ }
190
+ async function trackRedisMetric(checkId) {
191
+ const metric = getRedisErrorMetric();
192
+ if (metric === null) {
193
+ return;
194
+ }
195
+ const redis = useRedis();
196
+ try {
197
+ await redis.set(`metrics-${checkId}`, '1');
198
+ await redis.del(`metrics-${checkId}`);
199
+ }
200
+ catch {
201
+ metric.inc();
202
+ }
203
+ }
204
+ async function trackStorageMetric(checkId) {
205
+ if (services.includes('storage') === false) {
206
+ return;
207
+ }
208
+ const storage = await getStorage();
209
+ for (const location of toArray(env['STORAGE_LOCATIONS'])) {
210
+ const disk = storage.location(location);
211
+ const metric = getStorageErrorMetric(location);
212
+ if (metric === null) {
213
+ continue;
214
+ }
215
+ try {
216
+ await disk.write(`metric-${checkId}`, Readable.from(['check']));
217
+ const fileStream = await disk.read(`metric-${checkId}`);
218
+ await new Promise((resolve) => fileStream.on('data', async () => {
219
+ fileStream.destroy();
220
+ await disk.delete(`metric-${checkId}`);
221
+ return resolve(null);
222
+ }));
223
+ }
224
+ catch {
225
+ metric.inc();
226
+ }
227
+ }
228
+ }
229
+ return {
230
+ getDatabaseErrorMetric,
231
+ getDatabaseResponseMetric,
232
+ getCacheErrorMetric,
233
+ getRedisErrorMetric,
234
+ getStorageErrorMetric,
235
+ aggregate,
236
+ generate,
237
+ readAll,
238
+ };
239
+ }
@@ -0,0 +1,17 @@
1
+ import { createMetrics } from './create-metrics.js';
2
+ export declare const _cache: {
3
+ metrics: ReturnType<typeof createMetrics> | undefined;
4
+ };
5
+ export declare const useMetrics: () => {
6
+ getDatabaseErrorMetric: () => import("prom-client").Counter | null;
7
+ getDatabaseResponseMetric: () => import("prom-client").Histogram | null;
8
+ getCacheErrorMetric: () => import("prom-client").Counter | null;
9
+ getRedisErrorMetric: () => import("prom-client").Counter | null;
10
+ getStorageErrorMetric: (location: string) => import("prom-client").Counter | null;
11
+ aggregate: (data: {
12
+ pid: number;
13
+ metrics: import("prom-client").MetricObjectWithValues<import("prom-client").MetricValue<string>>[];
14
+ }) => Promise<void>;
15
+ generate: () => Promise<void>;
16
+ readAll: () => Promise<string>;
17
+ } | undefined;
@@ -0,0 +1,15 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { toBoolean } from '@directus/utils';
3
+ import { createMetrics } from './create-metrics.js';
4
+ export const _cache = { metrics: undefined };
5
+ export const useMetrics = () => {
6
+ const env = useEnv();
7
+ if (!toBoolean(env['METRICS_ENABLED'])) {
8
+ return;
9
+ }
10
+ if (_cache.metrics) {
11
+ return _cache.metrics;
12
+ }
13
+ _cache.metrics = createMetrics();
14
+ return _cache.metrics;
15
+ };
@@ -0,0 +1 @@
1
+ export type MetricService = 'database' | 'cache' | 'redis' | 'storage';
@@ -0,0 +1 @@
1
+ export {};