@verdeect/kit 0.0.0-stage → 1.0.0-rc.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 (216) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +804 -2
  3. package/alerts/verdeect-kit.rules.yml +133 -0
  4. package/dist/cli/commands/crypto.d.ts +9 -0
  5. package/dist/cli/commands/crypto.js +87 -0
  6. package/dist/cli/commands/db.d.ts +11 -0
  7. package/dist/cli/commands/db.js +151 -0
  8. package/dist/cli/commands/openapi.d.ts +9 -0
  9. package/dist/cli/commands/openapi.js +144 -0
  10. package/dist/cli/commands/rabbitmq.d.ts +11 -0
  11. package/dist/cli/commands/rabbitmq.js +124 -0
  12. package/dist/cli/commands/redis.d.ts +11 -0
  13. package/dist/cli/commands/redis.js +119 -0
  14. package/dist/cli/main.d.ts +2 -0
  15. package/dist/cli/main.js +31 -0
  16. package/dist/config/cli-schemas.d.ts +29 -0
  17. package/dist/config/cli-schemas.js +15 -0
  18. package/dist/config/define-env.d.ts +21 -0
  19. package/dist/config/define-env.js +43 -0
  20. package/dist/config/fragments.d.ts +105 -0
  21. package/dist/config/fragments.js +86 -0
  22. package/dist/config/helpers.d.ts +26 -0
  23. package/dist/config/helpers.js +62 -0
  24. package/dist/config/index.d.ts +7 -0
  25. package/dist/config/index.js +7 -0
  26. package/dist/config/kit-config.module.d.ts +20 -0
  27. package/dist/config/kit-config.module.js +23 -0
  28. package/dist/config/parse-env.d.ts +14 -0
  29. package/dist/config/parse-env.js +28 -0
  30. package/dist/crypto/cipher.d.ts +61 -0
  31. package/dist/crypto/cipher.js +136 -0
  32. package/dist/crypto/index.d.ts +2 -0
  33. package/dist/crypto/index.js +2 -0
  34. package/dist/database/batches.d.ts +42 -0
  35. package/dist/database/batches.js +49 -0
  36. package/dist/database/db.d.ts +93 -0
  37. package/dist/database/db.js +234 -0
  38. package/dist/database/if-newer.d.ts +9 -0
  39. package/dist/database/if-newer.js +15 -0
  40. package/dist/database/index.d.ts +17 -0
  41. package/dist/database/index.js +13 -0
  42. package/dist/database/kit-database.module.d.ts +47 -0
  43. package/dist/database/kit-database.module.js +181 -0
  44. package/dist/database/migrations.d.ts +42 -0
  45. package/dist/database/migrations.js +142 -0
  46. package/dist/database/pg.d.ts +20 -0
  47. package/dist/database/pg.js +19 -0
  48. package/dist/database/prepare-database.d.ts +39 -0
  49. package/dist/database/prepare-database.js +75 -0
  50. package/dist/database/run-exclusive.d.ts +19 -0
  51. package/dist/database/run-exclusive.js +62 -0
  52. package/dist/database/tenant.d.ts +34 -0
  53. package/dist/database/tenant.js +28 -0
  54. package/dist/http/api/api.decorator.d.ts +18 -0
  55. package/dist/http/api/api.decorator.js +9 -0
  56. package/dist/http/api/openapi-document.d.ts +40 -0
  57. package/dist/http/api/openapi-document.js +452 -0
  58. package/dist/http/bootstrap-service.d.ts +33 -0
  59. package/dist/http/bootstrap-service.js +107 -0
  60. package/dist/http/create-http-app.d.ts +38 -0
  61. package/dist/http/create-http-app.js +137 -0
  62. package/dist/http/errors.d.ts +118 -0
  63. package/dist/http/errors.js +94 -0
  64. package/dist/http/index.d.ts +12 -0
  65. package/dist/http/index.js +8 -0
  66. package/dist/http/kit-exception.filter.d.ts +18 -0
  67. package/dist/http/kit-exception.filter.js +151 -0
  68. package/dist/http/kit-http-core.module.d.ts +19 -0
  69. package/dist/http/kit-http-core.module.js +122 -0
  70. package/dist/http/pagination.d.ts +30 -0
  71. package/dist/http/pagination.js +73 -0
  72. package/dist/http/zod-validation.pipe.d.ts +15 -0
  73. package/dist/http/zod-validation.pipe.js +47 -0
  74. package/dist/idempotency/idempotency.interceptor.d.ts +55 -0
  75. package/dist/idempotency/idempotency.interceptor.js +317 -0
  76. package/dist/idempotency/idempotent.decorator.d.ts +7 -0
  77. package/dist/idempotency/idempotent.decorator.js +16 -0
  78. package/dist/idempotency/index.d.ts +7 -0
  79. package/dist/idempotency/index.js +6 -0
  80. package/dist/idempotency/kit-idempotency.module.d.ts +47 -0
  81. package/dist/idempotency/kit-idempotency.module.js +173 -0
  82. package/dist/idempotency/peer.d.ts +1 -0
  83. package/dist/idempotency/peer.js +4 -0
  84. package/dist/idempotency/table.d.ts +203 -0
  85. package/dist/idempotency/table.js +86 -0
  86. package/dist/internal/cleanup-result.d.ts +7 -0
  87. package/dist/internal/cleanup-result.js +12 -0
  88. package/dist/internal/ids.d.ts +13 -0
  89. package/dist/internal/ids.js +41 -0
  90. package/dist/internal/metadata.d.ts +12 -0
  91. package/dist/internal/metadata.js +3 -0
  92. package/dist/internal/outbox-policy.d.ts +8 -0
  93. package/dist/internal/outbox-policy.js +10 -0
  94. package/dist/internal/peer.d.ts +16 -0
  95. package/dist/internal/peer.js +56 -0
  96. package/dist/internal/pg-errors.d.ts +21 -0
  97. package/dist/internal/pg-errors.js +115 -0
  98. package/dist/internal/preparsed-env.d.ts +4 -0
  99. package/dist/internal/preparsed-env.js +14 -0
  100. package/dist/internal/rabbitmq-definitions.d.ts +85 -0
  101. package/dist/internal/rabbitmq-definitions.js +73 -0
  102. package/dist/internal/redis-acl.d.ts +21 -0
  103. package/dist/internal/redis-acl.js +30 -0
  104. package/dist/internal/service-name.d.ts +4 -0
  105. package/dist/internal/service-name.js +10 -0
  106. package/dist/internal/sql-identifier.d.ts +5 -0
  107. package/dist/internal/sql-identifier.js +12 -0
  108. package/dist/internal/tokens.d.ts +10 -0
  109. package/dist/internal/tokens.js +12 -0
  110. package/dist/internal/tx-context.d.ts +5 -0
  111. package/dist/internal/tx-context.js +9 -0
  112. package/dist/messaging/cleanup.d.ts +49 -0
  113. package/dist/messaging/cleanup.js +129 -0
  114. package/dist/messaging/config.d.ts +43 -0
  115. package/dist/messaging/config.js +8 -0
  116. package/dist/messaging/define-event.d.ts +29 -0
  117. package/dist/messaging/define-event.js +38 -0
  118. package/dist/messaging/envelope.d.ts +66 -0
  119. package/dist/messaging/envelope.js +49 -0
  120. package/dist/messaging/errors.d.ts +45 -0
  121. package/dist/messaging/errors.js +63 -0
  122. package/dist/messaging/inbox.d.ts +4 -0
  123. package/dist/messaging/inbox.js +9 -0
  124. package/dist/messaging/index.d.ts +13 -0
  125. package/dist/messaging/index.js +10 -0
  126. package/dist/messaging/kit-messaging.module.d.ts +39 -0
  127. package/dist/messaging/kit-messaging.module.js +309 -0
  128. package/dist/messaging/outbox-relay.d.ts +67 -0
  129. package/dist/messaging/outbox-relay.js +260 -0
  130. package/dist/messaging/outbox-store.d.ts +75 -0
  131. package/dist/messaging/outbox-store.js +126 -0
  132. package/dist/messaging/peer.d.ts +1 -0
  133. package/dist/messaging/peer.js +5 -0
  134. package/dist/messaging/rabbit-connection.d.ts +144 -0
  135. package/dist/messaging/rabbit-connection.js +472 -0
  136. package/dist/messaging/redelivery.d.ts +6 -0
  137. package/dist/messaging/redelivery.js +17 -0
  138. package/dist/messaging/subscriber.d.ts +102 -0
  139. package/dist/messaging/subscriber.js +412 -0
  140. package/dist/messaging/tables.d.ts +315 -0
  141. package/dist/messaging/tables.js +72 -0
  142. package/dist/messaging/topology.d.ts +56 -0
  143. package/dist/messaging/topology.js +56 -0
  144. package/dist/observability/fail-startup.d.ts +14 -0
  145. package/dist/observability/fail-startup.js +51 -0
  146. package/dist/observability/health-registry.d.ts +34 -0
  147. package/dist/observability/health-registry.js +81 -0
  148. package/dist/observability/index.d.ts +16 -0
  149. package/dist/observability/index.js +10 -0
  150. package/dist/observability/lifecycle.d.ts +52 -0
  151. package/dist/observability/lifecycle.js +151 -0
  152. package/dist/observability/logger-options.d.ts +54 -0
  153. package/dist/observability/logger-options.js +91 -0
  154. package/dist/observability/metrics-server.d.ts +33 -0
  155. package/dist/observability/metrics-server.js +108 -0
  156. package/dist/observability/metrics.d.ts +30 -0
  157. package/dist/observability/metrics.js +53 -0
  158. package/dist/observability/redaction.d.ts +23 -0
  159. package/dist/observability/redaction.js +65 -0
  160. package/dist/observability/sanitize-error.d.ts +47 -0
  161. package/dist/observability/sanitize-error.js +194 -0
  162. package/dist/observability/sentry.d.ts +64 -0
  163. package/dist/observability/sentry.js +233 -0
  164. package/dist/redis/channels.d.ts +34 -0
  165. package/dist/redis/channels.js +128 -0
  166. package/dist/redis/client.d.ts +42 -0
  167. package/dist/redis/client.js +85 -0
  168. package/dist/redis/index.d.ts +7 -0
  169. package/dist/redis/index.js +7 -0
  170. package/dist/redis/kit-redis.module.d.ts +18 -0
  171. package/dist/redis/kit-redis.module.js +131 -0
  172. package/dist/redis/peer.d.ts +1 -0
  173. package/dist/redis/peer.js +4 -0
  174. package/dist/redis/rate-limiter.d.ts +49 -0
  175. package/dist/redis/rate-limiter.js +139 -0
  176. package/dist/testing/count-queries.d.ts +30 -0
  177. package/dist/testing/count-queries.js +58 -0
  178. package/dist/testing/create-test-app.d.ts +26 -0
  179. package/dist/testing/create-test-app.js +32 -0
  180. package/dist/testing/expect-matches-api.d.ts +8 -0
  181. package/dist/testing/expect-matches-api.js +49 -0
  182. package/dist/testing/expect-problem.d.ts +24 -0
  183. package/dist/testing/expect-problem.js +23 -0
  184. package/dist/testing/images.d.ts +10 -0
  185. package/dist/testing/images.js +14 -0
  186. package/dist/testing/index.d.ts +23 -0
  187. package/dist/testing/index.js +17 -0
  188. package/dist/testing/launch-command.d.ts +52 -0
  189. package/dist/testing/launch-command.js +510 -0
  190. package/dist/testing/outbox-coverage.d.ts +26 -0
  191. package/dist/testing/outbox-coverage.js +98 -0
  192. package/dist/testing/rabbitmq-admin.d.ts +81 -0
  193. package/dist/testing/rabbitmq-admin.js +146 -0
  194. package/dist/testing/redis-worker.d.ts +10 -0
  195. package/dist/testing/redis-worker.js +15 -0
  196. package/dist/testing/repo-conventions.d.ts +10 -0
  197. package/dist/testing/repo-conventions.js +524 -0
  198. package/dist/testing/repo-files.d.ts +113 -0
  199. package/dist/testing/repo-files.js +401 -0
  200. package/dist/testing/scratch-database.d.ts +19 -0
  201. package/dist/testing/scratch-database.js +75 -0
  202. package/dist/testing/start-test-stack.d.ts +85 -0
  203. package/dist/testing/start-test-stack.js +342 -0
  204. package/dist/testing/tcp-proxy.d.ts +40 -0
  205. package/dist/testing/tcp-proxy.js +164 -0
  206. package/dist/testing/tenant-isolation.d.ts +31 -0
  207. package/dist/testing/tenant-isolation.js +205 -0
  208. package/dist/testing/test-events.d.ts +37 -0
  209. package/dist/testing/test-events.js +150 -0
  210. package/dist/testing/timeout-scale.d.ts +4 -0
  211. package/dist/testing/timeout-scale.js +17 -0
  212. package/dist/testing/timing-guard.d.ts +22 -0
  213. package/dist/testing/timing-guard.js +54 -0
  214. package/dist/testing/worker-scope.d.ts +31 -0
  215. package/dist/testing/worker-scope.js +68 -0
  216. package/package.json +177 -4
@@ -0,0 +1,81 @@
1
+ import { sanitizeError } from './sanitize-error.js';
2
+ /** Срок пробы (§3.2). */
3
+ export const PROBE_TIMEOUT_MS = 2000;
4
+ /** Реестр проб готовности; один на экземпляр приложения. */
5
+ export class HealthRegistry {
6
+ logger;
7
+ probes = new Map();
8
+ lastState = new Map();
9
+ stopping = false;
10
+ constructor(options) {
11
+ this.logger = options.logger;
12
+ }
13
+ /** Регистрирует пробу; повторная регистрация имени заменяет прежнюю. */
14
+ register(name, probe, options) {
15
+ this.probes.set(name, { critical: options.critical, probe });
16
+ }
17
+ /** Шаг 1 остановки: с этого момента `evaluate` — `unavailable` без запуска проб. */
18
+ markStopping() {
19
+ this.stopping = true;
20
+ }
21
+ /** Идёт ли остановка. */
22
+ get isStopping() {
23
+ return this.stopping;
24
+ }
25
+ /** Запускает пробы параллельно и сводит статус; при остановке — `unavailable` без проб. */
26
+ async evaluate() {
27
+ if (this.stopping) {
28
+ return { status: 'unavailable', checks: {} };
29
+ }
30
+ const results = await Promise.all([...this.probes.entries()].map(async ([name, { critical, probe }]) => {
31
+ const result = await runProbe(probe);
32
+ this.trackState(name, critical, result);
33
+ return { name, critical, up: result.up };
34
+ }));
35
+ const checks = {};
36
+ for (const result of results) {
37
+ checks[result.name] = result.up ? 'up' : 'down';
38
+ }
39
+ const down = results.filter((result) => !result.up);
40
+ if (down.some((result) => result.critical)) {
41
+ return { status: 'unavailable', checks };
42
+ }
43
+ return { status: down.length > 0 ? 'degraded' : 'ok', checks };
44
+ }
45
+ /** Лог только при смене состояния: up → down (и первый down) — warn, down → up — info. */
46
+ trackState(name, critical, result) {
47
+ const previous = this.lastState.get(name);
48
+ const current = result.up ? 'up' : 'down';
49
+ this.lastState.set(name, current);
50
+ if (previous === current) {
51
+ return;
52
+ }
53
+ if (current === 'down') {
54
+ this.logger.warn({
55
+ check: name,
56
+ critical,
57
+ ...(result.error === undefined ? {} : { err: sanitizeError(result.error) }),
58
+ }, 'проба здоровья: down');
59
+ return;
60
+ }
61
+ if (previous === 'down') {
62
+ this.logger.info({ check: name }, 'проба здоровья: up');
63
+ }
64
+ }
65
+ }
66
+ /** Проба в гонке со сроком; таймер снимается после ответа. */
67
+ async function runProbe(probe) {
68
+ let timer;
69
+ const timeout = new Promise((resolve) => {
70
+ timer = setTimeout(() => resolve({ up: false }), PROBE_TIMEOUT_MS);
71
+ });
72
+ try {
73
+ return await Promise.race([probe().then((up) => ({ up: up === true })), timeout]);
74
+ }
75
+ catch (error) {
76
+ return { up: false, error };
77
+ }
78
+ finally {
79
+ clearTimeout(timer);
80
+ }
81
+ }
@@ -0,0 +1,16 @@
1
+ export { failStartup } from './fail-startup.js';
2
+ export type { StartupFailure } from './fail-startup.js';
3
+ export { HealthRegistry } from './health-registry.js';
4
+ export type { HealthProbe, HealthReport } from './health-registry.js';
5
+ export { KitLifecycle } from './lifecycle.js';
6
+ export type { KitShutdownParticipant, KitShutdownStep } from './lifecycle.js';
7
+ export { createLoggerOptions } from './logger-options.js';
8
+ export type { KitLoggerOptions, KitLoggerParams } from './logger-options.js';
9
+ export { MetricsServer } from './metrics-server.js';
10
+ export { alertRulesTemplate, kitMetrics } from './metrics.js';
11
+ export type { KitMetrics } from './metrics.js';
12
+ export { SENSITIVE_KEYS } from './redaction.js';
13
+ export type { KitRedaction } from './redaction.js';
14
+ export { registerErrorSanitizer, sanitizeError } from './sanitize-error.js';
15
+ export { captureError, captureThrottled, initSentry } from './sentry.js';
16
+ export type { KitSentryOptions } from './sentry.js';
@@ -0,0 +1,10 @@
1
+ // Публичная поверхность @verdeect/kit/observability (prd.md §3.2). Всё, что не перечислено, — не экспортируется.
2
+ export { failStartup } from './fail-startup.js';
3
+ export { HealthRegistry } from './health-registry.js';
4
+ export { KitLifecycle } from './lifecycle.js';
5
+ export { createLoggerOptions } from './logger-options.js';
6
+ export { MetricsServer } from './metrics-server.js';
7
+ export { alertRulesTemplate, kitMetrics } from './metrics.js';
8
+ export { SENSITIVE_KEYS } from './redaction.js';
9
+ export { registerErrorSanitizer, sanitizeError } from './sanitize-error.js';
10
+ export { captureError, captureThrottled, initSentry } from './sentry.js';
@@ -0,0 +1,52 @@
1
+ import type { Logger } from 'pino';
2
+ import type { KitBaseEnv } from '../config/fragments.js';
3
+ import type { HealthRegistry } from './health-registry.js';
4
+ /** Шаг участника (§3.2): 2 — приём соединений, 3 — подписчики и фоновые задачи, 4 — пулы, 5 — сервер метрик. */
5
+ export type KitShutdownStep = 2 | 3 | 4 | 5;
6
+ /** Участник остановки: участники одного шага работают параллельно. */
7
+ export interface KitShutdownParticipant {
8
+ /** Имя для логов и сторожа. */
9
+ readonly name: string;
10
+ /** Останавливает участника; ошибка логируется, остальные шаги выполняются. */
11
+ stop(): Promise<void> | void;
12
+ }
13
+ /** Остановка экземпляра приложения по шагам 1–5 (§3.2). Остановка одна на экземпляр. */
14
+ export declare class KitLifecycle {
15
+ private readonly logger;
16
+ private readonly health;
17
+ private readonly timeoutMs;
18
+ private readonly drainMs;
19
+ private readonly participants;
20
+ private steps;
21
+ private closing;
22
+ private watchdog;
23
+ private finished;
24
+ private currentStep;
25
+ private readonly pending;
26
+ constructor(options: {
27
+ readonly logger: Logger;
28
+ readonly health: HealthRegistry;
29
+ readonly env: Pick<KitBaseEnv, 'SHUTDOWN_TIMEOUT_MS' | 'SHUTDOWN_DRAIN_MS'>;
30
+ });
31
+ /** Регистрирует участника шага; после начала остановки — ошибка разработчика. */
32
+ register(participant: KitShutdownParticipant, options: {
33
+ readonly step: KitShutdownStep;
34
+ }): void;
35
+ /**
36
+ * Остановка по сигналу ОС (умолч. SIGTERM, SIGINT): сторож срока, ошибки участников — `error` и GlitchTip.
37
+ * Обработчик остаётся (`process.on`): повторный сигнал не включает действие Node по умолчанию посреди шагов.
38
+ */
39
+ listen(signals?: readonly NodeJS.Signals[]): void;
40
+ /**
41
+ * Программное закрытие (тесты сервиса, CLI): шаги 1–5 без сторожа и `exit`. Ошибка участника — отказ после
42
+ * всех шагов (несколько — `AggregateError`). Повторный вызов возвращает тот же промис.
43
+ */
44
+ close(): Promise<void>;
45
+ private onSignal;
46
+ private start;
47
+ private run;
48
+ private runStep;
49
+ private stopParticipant;
50
+ /** Сторож: шаги не уложились в срок или процесс не вышел после них — error, GlitchTip, exit(1). */
51
+ private onDeadline;
52
+ }
@@ -0,0 +1,151 @@
1
+ // KitLifecycle — участники остановки и сторож срока (prd.md §3.2 шаги 1–5, AC-2.10, AC-2.13, UC-8, NFR-8; KT-10, KT-11).
2
+ // Эталоны: общий срок от первого вызова — P/O RabbitConnection.shutdownDeadline(); флаг остановки первым действием —
3
+ // O instance-shutdown.ts begin(). Механизм шагов и сторож с exit(1) написаны по PRD: эталона нет.
4
+ // Два режима (KT-11): сигнал ОС (listen) — сторож SHUTDOWN_TIMEOUT_MS от сигнала, по окончании шагов сторож unref и
5
+ // выход 0 сам; программный close() — те же шаги без сторожа и exit, ошибки участников — отказ close().
6
+ import * as Sentry from '@sentry/nestjs';
7
+ import { sanitizeError } from './sanitize-error.js';
8
+ import { captureError } from './sentry.js';
9
+ const STEPS = [2, 3, 4, 5];
10
+ const DEFAULT_SIGNALS = ['SIGTERM', 'SIGINT'];
11
+ const SENTRY_FLUSH_MS = 2000;
12
+ /** Остановка экземпляра приложения по шагам 1–5 (§3.2). Остановка одна на экземпляр. */
13
+ export class KitLifecycle {
14
+ logger;
15
+ health;
16
+ timeoutMs;
17
+ drainMs;
18
+ participants = new Map();
19
+ steps = null;
20
+ closing = null;
21
+ watchdog = null;
22
+ finished = false;
23
+ currentStep = 1;
24
+ pending = new Set();
25
+ constructor(options) {
26
+ this.logger = options.logger;
27
+ this.health = options.health;
28
+ this.timeoutMs = options.env.SHUTDOWN_TIMEOUT_MS;
29
+ this.drainMs = options.env.SHUTDOWN_DRAIN_MS;
30
+ }
31
+ /** Регистрирует участника шага; после начала остановки — ошибка разработчика. */
32
+ register(participant, options) {
33
+ if (this.steps !== null) {
34
+ throw new Error('KitLifecycle: регистрация после начала остановки');
35
+ }
36
+ const list = this.participants.get(options.step) ?? [];
37
+ list.push(participant);
38
+ this.participants.set(options.step, list);
39
+ }
40
+ /**
41
+ * Остановка по сигналу ОС (умолч. SIGTERM, SIGINT): сторож срока, ошибки участников — `error` и GlitchTip.
42
+ * Обработчик остаётся (`process.on`): повторный сигнал не включает действие Node по умолчанию посреди шагов.
43
+ */
44
+ listen(signals = DEFAULT_SIGNALS) {
45
+ for (const signal of signals) {
46
+ process.on(signal, () => this.onSignal(signal));
47
+ }
48
+ }
49
+ /**
50
+ * Программное закрытие (тесты сервиса, CLI): шаги 1–5 без сторожа и `exit`. Ошибка участника — отказ после
51
+ * всех шагов (несколько — `AggregateError`). Повторный вызов возвращает тот же промис.
52
+ */
53
+ close() {
54
+ this.closing ??= this.start({ mode: 'close' }).then(throwFailures);
55
+ return this.closing;
56
+ }
57
+ onSignal(signal) {
58
+ if (this.watchdog !== null) {
59
+ return;
60
+ }
61
+ // t0 — сигнал: сторож взводится до шагов, пауза входит в срок (CE-7).
62
+ this.watchdog = setTimeout(() => void this.onDeadline(), this.timeoutMs);
63
+ void this.start({ signal }).then(() => {
64
+ // Шаги завершены: процесс без открытых дескрипторов выходит сам с кодом 0 (KT-10 п. 4).
65
+ this.watchdog?.unref();
66
+ });
67
+ }
68
+ start(context) {
69
+ this.steps ??= this.run(context);
70
+ return this.steps;
71
+ }
72
+ async run(context) {
73
+ const startedAt = performance.now();
74
+ const failures = [];
75
+ this.logger.info(context, 'остановка: начало');
76
+ // Шаг 1: /health 503 и пауза SHUTDOWN_DRAIN_MS — балансировщик выводит реплику.
77
+ this.health.markStopping();
78
+ if (this.drainMs > 0) {
79
+ await new Promise((resolve) => setTimeout(resolve, this.drainMs));
80
+ }
81
+ // Шаги 2–5: участники шага параллельно, следующий шаг — после завершения предыдущего.
82
+ for (const step of STEPS) {
83
+ await this.runStep(step, failures);
84
+ }
85
+ this.finished = true;
86
+ this.logger.info({ durationMs: elapsed(startedAt) }, 'остановка: завершена');
87
+ return failures;
88
+ }
89
+ async runStep(step, failures) {
90
+ const list = this.participants.get(step) ?? [];
91
+ const startedAt = performance.now();
92
+ this.currentStep = step;
93
+ for (const participant of list) {
94
+ this.pending.add(participant.name);
95
+ }
96
+ this.logger.debug({ step, participants: list.map((participant) => participant.name) }, 'остановка: шаг начат');
97
+ await Promise.allSettled(list.map((participant) => this.stopParticipant(step, participant, failures)));
98
+ this.logger.debug({ step, durationMs: elapsed(startedAt) }, 'остановка: шаг завершён');
99
+ }
100
+ async stopParticipant(step, participant, failures) {
101
+ const startedAt = performance.now();
102
+ try {
103
+ await participant.stop();
104
+ this.logger.debug({ step, participant: participant.name, durationMs: elapsed(startedAt) }, 'остановка: участник завершён');
105
+ }
106
+ catch (error) {
107
+ failures.push(error);
108
+ this.logger.error({ step, participant: participant.name, err: sanitizeError(error) }, 'остановка: участник завершился с ошибкой');
109
+ if (this.watchdog !== null) {
110
+ captureError(error, { tags: { code: 'SHUTDOWN_PARTICIPANT_FAILED' } });
111
+ }
112
+ }
113
+ finally {
114
+ this.pending.delete(participant.name);
115
+ }
116
+ }
117
+ /** Сторож: шаги не уложились в срок или процесс не вышел после них — error, GlitchTip, exit(1). */
118
+ async onDeadline() {
119
+ if (this.finished) {
120
+ this.logger.error({ code: 'SHUTDOWN_NOT_EXITED', timeoutMs: this.timeoutMs }, 'процесс не завершился после остановки');
121
+ captureError(new Error('Процесс не завершился после остановки'), {
122
+ tags: { code: 'SHUTDOWN_NOT_EXITED' },
123
+ });
124
+ }
125
+ else {
126
+ this.logger.error({
127
+ code: 'SHUTDOWN_TIMEOUT',
128
+ step: this.currentStep,
129
+ pending: [...this.pending],
130
+ timeoutMs: this.timeoutMs,
131
+ }, 'срок остановки истёк');
132
+ captureError(new Error('Срок остановки истёк'), {
133
+ tags: { code: 'SHUTDOWN_TIMEOUT' },
134
+ });
135
+ }
136
+ await Sentry.flush(SENTRY_FLUSH_MS);
137
+ process.exit(1);
138
+ }
139
+ }
140
+ function elapsed(startedAt) {
141
+ return Math.round(performance.now() - startedAt);
142
+ }
143
+ /** Отказ close(): одна ошибка — она сама, несколько — AggregateError. */
144
+ function throwFailures(failures) {
145
+ if (failures.length === 1) {
146
+ throw failures[0];
147
+ }
148
+ if (failures.length > 1) {
149
+ throw new AggregateError(failures, 'KitLifecycle: участники остановки завершились с ошибкой');
150
+ }
151
+ }
@@ -0,0 +1,54 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import type { Params } from 'nestjs-pino';
3
+ import type { DestinationStream } from 'pino';
4
+ import type { KitBaseEnv } from '../config/fragments.js';
5
+ import { type KitRedaction } from './redaction.js';
6
+ /** Опции `createLoggerOptions` (§3.2). */
7
+ export interface KitLoggerOptions {
8
+ /** Имя сервиса (`^[a-z][a-z0-9-]{1,31}$`) — поле `service` каждой строки. */
9
+ readonly service: string;
10
+ /** Точка входа: `http` — stdout и журнал доступа, `cli` — stderr без журнала доступа. */
11
+ readonly entry: 'http' | 'cli';
12
+ /** Разобранные ключи окружения: уровень лога и имя реплики (§3.1). */
13
+ readonly env: Pick<KitBaseEnv, 'REPLICA_ID' | 'LOG_LEVEL'>;
14
+ /** `false` — входящий `traceparent` игнорируется, trace-id всегда новый (роль BFF); по умолчанию — доверять. */
15
+ readonly trustTraceparent?: boolean;
16
+ /** Префиксы пути: успешный ответ пишется на `debug`, ошибка и статус ≥ 400 — как обычно. */
17
+ readonly quietRoutes?: readonly string[];
18
+ /** Дополнительные поля `base`. */
19
+ readonly extraBase?: Readonly<Record<string, unknown>>;
20
+ /** Правила редактирования сервиса (общие с `initSentry`). */
21
+ readonly redaction?: KitRedaction;
22
+ }
23
+ type PinoHttpOptions = Extract<NonNullable<Params['pinoHttp']>, readonly unknown[]>[0];
24
+ /**
25
+ * Генератор trace-id: читает только заголовки, поэтому подходит и pino-http (`(req, res) => ReqId`), и адаптеру
26
+ * Fastify (`(req: IncomingMessage | Http2ServerRequest) => string`).
27
+ */
28
+ type TraceIdGenerator = (req: Pick<IncomingMessage, 'headers'>) => string;
29
+ /**
30
+ * Результат `createLoggerOptions`: `Params` nestjs-pino с кортежем `[options, stream]`. Логгер вне Nest —
31
+ * `pino(...params.pinoHttp)`; генератор trace-id для адаптера — `params.pinoHttp[0].genReqId`.
32
+ */
33
+ export type KitLoggerParams = Omit<Params, 'pinoHttp'> & {
34
+ /** Опции pino-http и поток: `process.stdout` (http) или `process.stderr` (cli). */
35
+ readonly pinoHttp: [
36
+ Omit<PinoHttpOptions, 'genReqId'> & {
37
+ genReqId: TraceIdGenerator;
38
+ },
39
+ DestinationStream
40
+ ];
41
+ };
42
+ /** trace-id из `traceparent` (если доверяем) или новый. Внутренняя: генератор адаптера Fastify в `createHttpApp`. */
43
+ export declare function traceIdGenerator(trust: boolean): TraceIdGenerator;
44
+ /**
45
+ * Опции nestjs-pino сервиса (§3.2): `base: {service, entry, replica}`, редактирование `SENSITIVE_KEYS` ∪
46
+ * `redaction.sensitiveKeys` на уровнях 0–2, `err` через `sanitizeError`, путь запроса без query и через `maskPath`,
47
+ * `traceId` в каждой строке запроса, `/ok` вне журнала доступа, `quietRoutes` — на `debug`.
48
+ *
49
+ * На Fastify `genReqId` из `pinoHttp` не вызывается: Fastify ставит свой `request.id`, а pino-http оставляет уже
50
+ * заданный (`req.id = req.id || genReqId(…)`). Поэтому HTTP-адаптер получает тот же генератор:
51
+ * `new FastifyAdapter({genReqId: params.pinoHttp[0].genReqId})` (так делает `createHttpApp`).
52
+ */
53
+ export declare function createLoggerOptions(options: KitLoggerOptions): KitLoggerParams;
54
+ export {};
@@ -0,0 +1,91 @@
1
+ // createLoggerOptions — опции nestjs-pino (prd.md §3.2, AC-2.1, AC-2.2, AC-2.12, NFR-4, NFR-8). Эталон — O
2
+ // observability.module.ts (requestId, serializeRequest, pinoOptions, accessLogLevel, httpLoggerOptions, stdout/stderr);
3
+ // trustTraceparent: false и trace-id 32 hex — B observability.module.ts (randomBytes(16)); quietRoutes — P accessLogLevel
4
+ // (QUIET_ACCESS_PREFIX). Окружение передаёт вызывающий (KitBaseEnv §3.1), своего process.env здесь нет.
5
+ import { randomBytes } from 'node:crypto';
6
+ import { assertServiceName } from '../internal/service-name.js';
7
+ import { maskPathOf, redactPaths } from './redaction.js';
8
+ import { sanitizeLogObject, serializeError } from './sanitize-error.js';
9
+ const TRACEPARENT = /^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$/;
10
+ const HEALTHCHECK_PATH = '/ok';
11
+ /** Путь без query. */
12
+ function pathOf(url) {
13
+ return typeof url === 'string' ? (url.split('?', 1)[0] ?? '') : '';
14
+ }
15
+ /**
16
+ * Путь запроса в middleware: на Fastify middie на время middleware заменяет `req.url` путём относительно точки
17
+ * монтирования, исходный — в `originalUrl`.
18
+ */
19
+ function requestPath(req) {
20
+ return pathOf(req.originalUrl ?? req.url);
21
+ }
22
+ /** Новый trace-id W3C: 32 hex. */
23
+ function newTraceId() {
24
+ return randomBytes(16).toString('hex');
25
+ }
26
+ /** trace-id из `traceparent` (если доверяем) или новый. Внутренняя: генератор адаптера Fastify в `createHttpApp`. */
27
+ export function traceIdGenerator(trust) {
28
+ return (req) => {
29
+ if (!trust) {
30
+ return newTraceId();
31
+ }
32
+ const header = req.headers.traceparent;
33
+ const match = typeof header === 'string' ? TRACEPARENT.exec(header.trim()) : null;
34
+ return match?.[1] ?? newTraceId();
35
+ };
36
+ }
37
+ /** Уровень строки доступа: ошибка или статус ≥ 400 → info; успешный ответ «тихого» маршрута → debug. */
38
+ function accessLogLevel(quietRoutes) {
39
+ return (req, res, err) => {
40
+ if (err !== undefined || res.statusCode >= 400) {
41
+ return 'info';
42
+ }
43
+ const path = requestPath(req);
44
+ return quietRoutes.some((prefix) => path.startsWith(prefix)) ? 'debug' : 'info';
45
+ };
46
+ }
47
+ /**
48
+ * Опции nestjs-pino сервиса (§3.2): `base: {service, entry, replica}`, редактирование `SENSITIVE_KEYS` ∪
49
+ * `redaction.sensitiveKeys` на уровнях 0–2, `err` через `sanitizeError`, путь запроса без query и через `maskPath`,
50
+ * `traceId` в каждой строке запроса, `/ok` вне журнала доступа, `quietRoutes` — на `debug`.
51
+ *
52
+ * На Fastify `genReqId` из `pinoHttp` не вызывается: Fastify ставит свой `request.id`, а pino-http оставляет уже
53
+ * заданный (`req.id = req.id || genReqId(…)`). Поэтому HTTP-адаптер получает тот же генератор:
54
+ * `new FastifyAdapter({genReqId: params.pinoHttp[0].genReqId})` (так делает `createHttpApp`).
55
+ */
56
+ export function createLoggerOptions(options) {
57
+ assertServiceName(options.service, 'createLoggerOptions');
58
+ const maskPath = maskPathOf(options.redaction);
59
+ const http = options.entry === 'http';
60
+ return {
61
+ pinoHttp: [
62
+ {
63
+ level: options.env.LOG_LEVEL,
64
+ base: {
65
+ service: options.service,
66
+ entry: options.entry,
67
+ replica: options.env.REPLICA_ID,
68
+ ...options.extraBase,
69
+ },
70
+ redact: { paths: redactPaths(options.redaction), censor: '[Filtered]' },
71
+ // formatters.log видит исходный err (с cause), сериализатор — уже сериализованный pino-http.
72
+ formatters: { log: sanitizeLogObject },
73
+ serializers: {
74
+ err: serializeError,
75
+ req: (req) => ({
76
+ id: req.id,
77
+ method: req.method,
78
+ url: maskPath(pathOf(req.url)),
79
+ }),
80
+ },
81
+ genReqId: traceIdGenerator(options.trustTraceparent !== false),
82
+ customProps: (req) => ({ traceId: req.id }),
83
+ customLogLevel: accessLogLevel(options.quietRoutes ?? []),
84
+ autoLogging: http
85
+ ? { ignore: (req) => requestPath(req) === HEALTHCHECK_PATH }
86
+ : false,
87
+ },
88
+ http ? process.stdout : process.stderr,
89
+ ],
90
+ };
91
+ }
@@ -0,0 +1,33 @@
1
+ import type { Logger } from 'pino';
2
+ import type { Registry } from '@prometheus-io/client';
3
+ import type { HealthRegistry } from './health-registry.js';
4
+ /** HTTP-сервер метрик и готовности; закрывается последним (шаг 5 `KitLifecycle`). */
5
+ export declare class MetricsServer {
6
+ private readonly port;
7
+ private readonly registry;
8
+ private readonly health;
9
+ private readonly logger;
10
+ private server;
11
+ private closing;
12
+ /** Обработчики в работе: closeAllConnections рвёт сокет, но обработчик ещё может ждать пробу. */
13
+ private readonly inflight;
14
+ constructor(options: {
15
+ readonly port: number;
16
+ readonly registry: Registry;
17
+ readonly health: HealthRegistry;
18
+ readonly logger: Logger;
19
+ });
20
+ /**
21
+ * Начинает слушать `port` (0 — порт выбирает ОС); ошибка прослушивания — отказ (передать в `failStartup`).
22
+ * После `close()` — ошибка разработчика: закрытый сервер не перезапускается.
23
+ */
24
+ listen(): Promise<void>;
25
+ /** Фактический порт; `null` — сервер не слушает. */
26
+ address(): number | null;
27
+ /** Закрывает сервер и ждёт обработчики в работе; идемпотентен. */
28
+ close(): Promise<void>;
29
+ private closeServer;
30
+ private handle;
31
+ /** ok, degraded → 200; unavailable (в том числе с начала остановки) → 503. Тело — только статусы. */
32
+ private handleHealth;
33
+ }
@@ -0,0 +1,108 @@
1
+ // MetricsServer — /health и /metrics на METRICS_PORT (prd.md §3.2, AC-2.7, AC-2.8, UC-13). Эталон — O metrics-server.ts:
2
+ // node:http вне Nest, учёт inflight, query отбрасывается, /health — JSON с cache-control: no-store, /metrics —
3
+ // registry.metrics(), прочее — 404, закрытие closeAllConnections + close + ожидание inflight, address() для порта 0.
4
+ // По PRD и KT-10: без Nest-хуков (закрытие — участник шага 5 KitLifecycle), флаг остановки — в HealthRegistry, ошибка
5
+ // listen (EADDRINUSE) — отказ listen(), а не работа без метрик. Строк лога на запрос нет.
6
+ import { createServer } from 'node:http';
7
+ import { sanitizeError } from './sanitize-error.js';
8
+ import { captureError } from './sentry.js';
9
+ const HEALTH_HEADERS = {
10
+ 'content-type': 'application/json; charset=utf-8',
11
+ 'cache-control': 'no-store',
12
+ };
13
+ const UNAVAILABLE = JSON.stringify({ status: 'unavailable', checks: {} });
14
+ /** HTTP-сервер метрик и готовности; закрывается последним (шаг 5 `KitLifecycle`). */
15
+ export class MetricsServer {
16
+ port;
17
+ registry;
18
+ health;
19
+ logger;
20
+ server = null;
21
+ closing = null;
22
+ /** Обработчики в работе: closeAllConnections рвёт сокет, но обработчик ещё может ждать пробу. */
23
+ inflight = new Set();
24
+ constructor(options) {
25
+ this.port = options.port;
26
+ this.registry = options.registry;
27
+ this.health = options.health;
28
+ this.logger = options.logger;
29
+ }
30
+ /**
31
+ * Начинает слушать `port` (0 — порт выбирает ОС); ошибка прослушивания — отказ (передать в `failStartup`).
32
+ * После `close()` — ошибка разработчика: закрытый сервер не перезапускается.
33
+ */
34
+ async listen() {
35
+ if (this.closing !== null) {
36
+ throw new Error('MetricsServer: listen() после close()');
37
+ }
38
+ const server = createServer((req, res) => {
39
+ const handling = this.handle(req, res).finally(() => this.inflight.delete(handling));
40
+ this.inflight.add(handling);
41
+ });
42
+ await new Promise((resolve, reject) => {
43
+ server.once('error', reject);
44
+ server.listen(this.port, () => {
45
+ server.off('error', reject);
46
+ resolve();
47
+ });
48
+ });
49
+ this.server = server;
50
+ this.logger.info({ port: this.address() }, 'сервер метрик слушает');
51
+ }
52
+ /** Фактический порт; `null` — сервер не слушает. */
53
+ address() {
54
+ const address = this.server?.address();
55
+ if (address === null || address === undefined || typeof address === 'string') {
56
+ return null;
57
+ }
58
+ return address.port;
59
+ }
60
+ /** Закрывает сервер и ждёт обработчики в работе; идемпотентен. */
61
+ close() {
62
+ this.closing ??= this.closeServer();
63
+ return this.closing;
64
+ }
65
+ async closeServer() {
66
+ const server = this.server;
67
+ if (server === null) {
68
+ return;
69
+ }
70
+ this.server = null;
71
+ server.closeAllConnections();
72
+ await new Promise((resolve) => server.close(() => resolve()));
73
+ await Promise.allSettled(this.inflight);
74
+ this.logger.debug('сервер метрик закрыт');
75
+ }
76
+ async handle(req, res) {
77
+ const path = (req.url ?? '').split('?', 1)[0];
78
+ if (req.method === 'GET' && path === '/health') {
79
+ await this.handleHealth(res);
80
+ return;
81
+ }
82
+ if (req.method !== 'GET' || path !== '/metrics') {
83
+ res.writeHead(404).end();
84
+ return;
85
+ }
86
+ try {
87
+ const body = await this.registry.metrics();
88
+ res.writeHead(200, { 'content-type': this.registry.contentType }).end(body);
89
+ }
90
+ catch (error) {
91
+ this.logger.error({ err: sanitizeError(error) }, 'сервер метрик: сбор метрик не удался');
92
+ captureError(error);
93
+ res.writeHead(500).end();
94
+ }
95
+ }
96
+ /** ok, degraded → 200; unavailable (в том числе с начала остановки) → 503. Тело — только статусы. */
97
+ async handleHealth(res) {
98
+ try {
99
+ const report = await this.health.evaluate();
100
+ res.writeHead(report.status === 'unavailable' ? 503 : 200, HEALTH_HEADERS).end(JSON.stringify(report));
101
+ }
102
+ catch (error) {
103
+ this.logger.error({ err: sanitizeError(error) }, 'сервер метрик: проверка здоровья не удалась');
104
+ captureError(error);
105
+ res.writeHead(503, HEALTH_HEADERS).end(UNAVAILABLE);
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,30 @@
1
+ import { Counter, Gauge, Registry } from '@prometheus-io/client';
2
+ /** Реестр и метрики пакета одного экземпляра приложения (§3.2). Метрики домена — `<service>_*`, вне пакета. */
3
+ export interface KitMetrics {
4
+ /** Реестр экземпляра: метки `service`, метрики процесса Node и пакета. */
5
+ readonly registry: Registry;
6
+ /** `verdeect_outbox_pending` — неотправленные строки outbox. */
7
+ readonly outboxPending: Gauge;
8
+ /** `verdeect_outbox_lag_seconds` — возраст самой старой неотправленной строки. */
9
+ readonly outboxLagSeconds: Gauge;
10
+ /** `verdeect_events_consumed_total{type, result}`; result: handled | duplicate | retry | dlq. */
11
+ readonly eventsConsumed: Counter<'type' | 'result'>;
12
+ /** `verdeect_db_pool_wait_timeouts_total{pool}` — истёк срок ожидания клиента пула. */
13
+ readonly dbPoolWaitTimeouts: Counter<'pool'>;
14
+ /** `verdeect_rate_limit_unavailable_total` — лимитер без Redis (fail-open). */
15
+ readonly rateLimitUnavailable: Counter;
16
+ /**
17
+ * `verdeect_cleanup_runs_total{table, result}` — проходы фоновой очистки. `table` — имя таблицы без схемы, только
18
+ * таблицы пакета (`idempotency_key`; E5 — `outbox`, `inbox`); `result`: `ok` — проход завершён, `partial` — упёрся
19
+ * в `maxBatches`, записи старше срока остались, `interrupted` — остановлен (выкат; алерт не срабатывает), `error` —
20
+ * проход упал. Проход без блокировки метрику не меняет;
21
+ * число удалённых строк — в `info`-логе прохода.
22
+ */
23
+ readonly cleanupRuns: Counter<'table' | 'result'>;
24
+ }
25
+ /** Создаёт реестр экземпляра с меткой `service`, метриками процесса Node и метриками пакета (§3.2, AC-2.8). */
26
+ export declare function kitMetrics(options: {
27
+ readonly service: string;
28
+ }): KitMetrics;
29
+ /** Абсолютный путь к шаблону правил vmalert `alerts/verdeect-kit.rules.yml` внутри установленного пакета (§3.2). */
30
+ export declare const alertRulesTemplate: string;