@yolo-croket-dev/core 1.10.0 → 1.10.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.
package/CHANGELOG.md CHANGED
@@ -11,3 +11,13 @@
11
11
  - 현재 소비 버전 1.10.0-jun.2의 gRPC metadata 및 예외 처리 동작을 유지합니다.
12
12
  - 비회원 인증 요청 로그의 민감 값을 마스킹하고 검증 오류에서 입력값을 제외합니다.
13
13
  - 인증 실패의 남은 입력 횟수와 재시도 대기시간 필드를 추가합니다.
14
+
15
+ ## 1.9.13-beta.1
16
+
17
+ - AMQP 요청의 `param.timeout`을 최대 29분 범위에서 적용해 timeout 시 클라이언트 Observable 구독을 해제합니다.
18
+
19
+ ## 1.9.12-beta.1
20
+
21
+ - `GracefulServerRMQ`가 RabbitMQ 연결에 queue, environment, owner, hostname, process ID 기반 `connection_name`을 자동으로 추가합니다.
22
+ - 호출자가 지정한 `connectionOptions`, `clientProperties`, 유효한 문자열 `connection_name`은 그대로 보존합니다.
23
+ - 문자열이 아닌 `connection_name`은 런타임 오류 없이 자동 생성값으로 대체합니다.
@@ -25,6 +25,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
26
  exports.AmqpManagement = void 0;
27
27
  const msa_logger_1 = __importStar(require("@yolo-croket-dev/msa-logger"));
28
+ const rmq_diagnostics_function_1 = require("./rmq-diagnostics.function");
28
29
  const _1 = require(".");
29
30
  const msaLogger = new msa_logger_1.default(msa_logger_1.ServerTypeEnum.SERVICE);
30
31
  class AmqpManagement {
@@ -44,7 +45,9 @@ class AmqpManagement {
44
45
  }
45
46
  return param;
46
47
  }
47
- async call(funcName, messagePattern, payload, argParam, options, file) {
48
+ async call(funcName, messagePattern, payload, argParam, options,
49
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
+ file) {
48
51
  const param = this.paramProcessor(argParam || {
49
52
  traceId: (0, _1.generateTraceId)(),
50
53
  isInternal: true,
@@ -58,7 +61,9 @@ class AmqpManagement {
58
61
  return result.data;
59
62
  }
60
63
  catch (err) {
61
- msaLogger.warn(`ThrowWrapppingCall[${funcName}] error: traceId=${param.traceId}`, { traceId: param.traceId }, { param });
64
+ if (!(0, rmq_diagnostics_function_1.getRmqDiagnosticSnapshot)(this.client) && !(0, rmq_diagnostics_function_1.isRmqDiagnosticsEnabled)()) {
65
+ msaLogger.warn(`ThrowWrapppingCall[${funcName}] error: traceId=${param.traceId}`, { traceId: param.traceId }, { param });
66
+ }
62
67
  if (options && options.isErrorThrowing) {
63
68
  throw err;
64
69
  }
@@ -1,10 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AmqpHandler = exports.HttpExceptionCustom = void 0;
4
+ /* eslint-disable max-len */
5
+ /* eslint-disable @typescript-eslint/no-explicit-any */
6
+ const microservices_1 = require("@nestjs/microservices");
4
7
  const common_1 = require("@nestjs/common");
5
8
  const rxjs_1 = require("rxjs");
6
9
  const operators_1 = require("rxjs/operators");
7
10
  const msa_logger_1 = require("@yolo-croket-dev/msa-logger");
11
+ const rmq_diagnostics_function_1 = require("./rmq-diagnostics.function");
8
12
  const _1 = require(".");
9
13
  const DEFAULT_TIMEOUT = 1000 * 60 * 29; // 29분 타임아웃
10
14
  const DEFAULT_TIMEOUT_MESSAGE = '요청이 타임아웃되었습니다.';
@@ -23,10 +27,23 @@ function resolveAmqpTimeout(timeoutMs) {
23
27
  }
24
28
  return Math.min(timeoutMs, DEFAULT_TIMEOUT);
25
29
  }
30
+ function getLegacyRmqQueue(client) {
31
+ if (!(client instanceof microservices_1.ClientRMQ))
32
+ return undefined;
33
+ try {
34
+ const queue = Reflect.get(Reflect.get(client, 'options'), 'queue');
35
+ return typeof queue === 'string' ? (0, msa_logger_1.sanitizeDiagnosticText)(queue).slice(0, 240) : undefined;
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
26
41
  async function AmqpHandler(client, messagePattern, payload) {
27
42
  var _a, _b, _c, _d, _e;
28
43
  let data;
29
44
  const timeoutMs = resolveAmqpTimeout((_a = payload === null || payload === void 0 ? void 0 : payload.param) === null || _a === void 0 ? void 0 : _a.timeout);
45
+ const startedAt = Date.now();
46
+ const startConnection = (0, rmq_diagnostics_function_1.getRmqDiagnosticSnapshot)(client);
30
47
  try {
31
48
  data = await (0, rxjs_1.lastValueFrom)(client.send(messagePattern, payload).pipe((0, operators_1.timeout)(timeoutMs)));
32
49
  if (data.isError) {
@@ -55,36 +72,67 @@ async function AmqpHandler(client, messagePattern, payload) {
55
72
  }
56
73
  }
57
74
  catch (err) {
75
+ const normalized = (startConnection || (0, rmq_diagnostics_function_1.isRmqDiagnosticsEnabled)())
76
+ ? (0, msa_logger_1.normalizeDiagnosticError)(err, err instanceof HttpExceptionCustom ? 'service_error' : undefined) : undefined;
77
+ const diagnosticInfo = normalized ? {
78
+ ...normalized.diagnosticInfo,
79
+ ...(0, rmq_diagnostics_function_1.getRmqDiagnosticSnapshot)(client),
80
+ ...(startConnection ? {} : {
81
+ direction: 'client',
82
+ connectionState: 'unobserved',
83
+ targetService: getLegacyRmqQueue(client),
84
+ }),
85
+ event: 'call_failed',
86
+ messagePattern: (0, msa_logger_1.sanitizeDiagnosticText)(messagePattern).slice(0, 240),
87
+ elapsedMs: Date.now() - startedAt,
88
+ timeoutMs,
89
+ startGeneration: startConnection === null || startConnection === void 0 ? void 0 : startConnection.generation,
90
+ startConnectionState: startConnection === null || startConnection === void 0 ? void 0 : startConnection.connectionState,
91
+ } : undefined;
92
+ const details = normalized ? normalized.details : { err };
93
+ if (normalized)
94
+ (0, rmq_diagnostics_function_1.recordRmqDiagnosticError)(err, normalized);
58
95
  if (typeof err === 'string' && err.includes('There is no matching message handler defined in the remote service.')) {
59
96
  msaLogger.error('NOT_EXISTS_MESSAGE_PATTERN', {
60
97
  traceId: ((_c = payload === null || payload === void 0 ? void 0 : payload.param) === null || _c === void 0 ? void 0 : _c.traceId) || '-',
98
+ diagnosticInfo,
61
99
  errInfo: {
62
100
  errType: 'NOT_EXISTS_MESSAGE_PATTERN',
63
101
  message: `${messagePattern} 해당 메시지 패턴은 존재하지 않습니다.`,
64
102
  status: 404,
65
103
  },
66
- }, { err });
67
- throw new HttpExceptionCustom('해당 메시지 패턴은 존재하지 않습니다.', 404, 'There is no matching message handler defined in the remote service.', {
104
+ }, details);
105
+ const exception = new HttpExceptionCustom('해당 메시지 패턴은 존재하지 않습니다.', 404, 'There is no matching message handler defined in the remote service.', {
68
106
  type: 'NOT_EXISTS_MESSAGE_PATTERN',
69
107
  message: `${messagePattern} 해당 메시지 패턴은 존재하지 않습니다.`,
70
108
  });
109
+ if (normalized)
110
+ (0, rmq_diagnostics_function_1.recordRmqDiagnosticError)(exception, normalized);
111
+ throw exception;
71
112
  }
72
- else if (err instanceof rxjs_1.TimeoutError || err.message === DEFAULT_TIMEOUT_MESSAGE) {
113
+ else if (err instanceof rxjs_1.TimeoutError || (err === null || err === void 0 ? void 0 : err.message) === DEFAULT_TIMEOUT_MESSAGE) {
73
114
  msaLogger.error('TIMEOUT_ERROR', {
74
115
  traceId: ((_d = payload === null || payload === void 0 ? void 0 : payload.param) === null || _d === void 0 ? void 0 : _d.traceId) || '-',
116
+ diagnosticInfo,
75
117
  errInfo: {
76
118
  errType: 'TIMEOUT_ERROR',
77
119
  message: DEFAULT_TIMEOUT_MESSAGE,
78
120
  status: 500,
79
121
  },
80
- }, { err });
81
- throw new HttpExceptionCustom(DEFAULT_TIMEOUT_MESSAGE, 500, DEFAULT_TIMEOUT_MESSAGE, {
122
+ }, details);
123
+ const exception = new HttpExceptionCustom(DEFAULT_TIMEOUT_MESSAGE, 500, DEFAULT_TIMEOUT_MESSAGE, {
82
124
  type: 'TIMEOUT_ERROR',
83
125
  message: DEFAULT_TIMEOUT_MESSAGE,
84
126
  });
127
+ if (normalized)
128
+ (0, rmq_diagnostics_function_1.recordRmqDiagnosticError)(exception, normalized);
129
+ throw exception;
85
130
  }
86
131
  else {
87
- msaLogger.warn('AMQP_HANDLER_ERROR', { traceId: ((_e = payload === null || payload === void 0 ? void 0 : payload.param) === null || _e === void 0 ? void 0 : _e.traceId) || '-' }, { err });
132
+ msaLogger.warn('AMQP_HANDLER_ERROR', {
133
+ traceId: ((_e = payload === null || payload === void 0 ? void 0 : payload.param) === null || _e === void 0 ? void 0 : _e.traceId) || '-',
134
+ diagnosticInfo,
135
+ }, details);
88
136
  throw err;
89
137
  }
90
138
  }
@@ -8,3 +8,4 @@ export * from './extract-redis-url.function';
8
8
  export * from './abort';
9
9
  export * from './uncaught-exception-handler';
10
10
  export * from './unhandled-rejection-handler';
11
+ export * from './rmq-diagnostics.function';
@@ -24,3 +24,4 @@ __exportStar(require("./extract-redis-url.function"), exports);
24
24
  __exportStar(require("./abort"), exports);
25
25
  __exportStar(require("./uncaught-exception-handler"), exports);
26
26
  __exportStar(require("./unhandled-rejection-handler"), exports);
27
+ __exportStar(require("./rmq-diagnostics.function"), exports);
@@ -0,0 +1,89 @@
1
+ /// <reference types="node" />
2
+ import { EventEmitter } from 'node:events';
3
+ import { ClientRMQ, RmqOptions } from '@nestjs/microservices';
4
+ import { DiagnosticInfo, NormalizedDiagnosticError } from '@yolo-croket-dev/msa-logger';
5
+ type RmqConnectionOptions = NonNullable<RmqOptions['options']>;
6
+ type Snapshot = Pick<DiagnosticInfo, 'connectionId' | 'connectionName' | 'direction' | 'targetService' | 'generation' | 'connectionState' | 'stateChangedAt'>;
7
+ type DiagnosticWriter = (info: DiagnosticInfo, details?: NormalizedDiagnosticError['details']) => void;
8
+ /**
9
+ * @author jun
10
+ * @description 프로세스의 RMQ 연결 진단 활성 여부를 확인한다.
11
+ * @process 명시적인 true 값만 활성으로 해석한다.
12
+ */
13
+ export declare function isRmqDiagnosticsEnabled(): boolean;
14
+ /**
15
+ * @author jun
16
+ * @description 전송 동작을 바꾸지 않고 연결 상태와 반복 실패를 기록한다.
17
+ * @process 연결 이벤트 구독 → 상태 갱신/60초 실패 요약 → 종료 시 listener/timer 정리.
18
+ */
19
+ export declare class RmqConnectionObserver {
20
+ private readonly direction;
21
+ private readonly write;
22
+ readonly connectionId: `${string}-${string}-${string}-${string}-${string}`;
23
+ readonly connectionName: string;
24
+ readonly targetService: string;
25
+ private generation;
26
+ private state;
27
+ private changedAt;
28
+ private emitter?;
29
+ private listeners;
30
+ private timer?;
31
+ private failed;
32
+ private repeatCount;
33
+ private firstOccurredAt?;
34
+ private lastOccurredAt?;
35
+ private latestFailure?;
36
+ constructor(queue: string | undefined, direction: 'client' | 'consumer', configuredName?: string, write?: DiagnosticWriter);
37
+ snapshot(): Snapshot;
38
+ private transition;
39
+ private emit;
40
+ private flush;
41
+ private onFailure;
42
+ observe(emitter: EventEmitter): void;
43
+ private detach;
44
+ close(): void;
45
+ }
46
+ /**
47
+ * @author jun
48
+ * @description 기존 연결 옵션으로 관찰자를 만든다.
49
+ * @process 명시적인 broker 연결 이름을 읽고 인스턴스 식별자를 생성한다.
50
+ */
51
+ export declare function createRmqObserver(options: RmqConnectionOptions, direction: 'client' | 'consumer'): RmqConnectionObserver;
52
+ /**
53
+ * @author jun
54
+ * @description 기존 RMQ 옵션에 연결 이름만 추가한다.
55
+ * @process 원본 옵션과 명시적인 연결 이름을 보존한 복사본을 반환한다.
56
+ */
57
+ export declare function withRmqConnectionName<T extends RmqConnectionOptions>(options: T, observer: RmqConnectionObserver): T;
58
+ /**
59
+ * @author jun
60
+ * @description 클라이언트 또는 consumer 채널을 관찰자와 연결한다.
61
+ * @process WeakMap에 등록하여 전송 객체 수명 이후 참조를 유지하지 않는다.
62
+ */
63
+ export declare function registerRmqObservation(owner: object, observer: RmqConnectionObserver): void;
64
+ /**
65
+ * @author jun
66
+ * @description 현재 연결 상태의 독립적인 스냅샷을 반환한다.
67
+ * @process 관찰 대상이 아니면 undefined를 반환한다.
68
+ */
69
+ export declare function getRmqDiagnosticSnapshot(owner: object): Snapshot | undefined;
70
+ /**
71
+ * @author jun
72
+ * @description 이미 기록한 오류 상세를 표시한다.
73
+ * @process 요청별 호출 정보 없이 오류 메타데이터만 WeakMap에 저장한다.
74
+ */
75
+ export declare function recordRmqDiagnosticError(error: unknown, diagnostic: NormalizedDiagnosticError): void;
76
+ /**
77
+ * @author jun
78
+ * @description 동일 오류의 중복 상세 기록 여부를 확인한다.
79
+ * @process 객체 오류에 연결된 정규화 결과를 반환한다.
80
+ */
81
+ export declare function getRecordedRmqDiagnosticError(error: unknown): NormalizedDiagnosticError | undefined;
82
+ /** Same factory/options and lazy connection behavior; observation is opt-in per process. */
83
+ /**
84
+ * @author jun
85
+ * @description lazy 연결을 유지하는 RMQ 클라이언트를 생성한다.
86
+ * @process 진단이 활성화되면 기존 ClientRMQ를 확장하고 그 외에는 기본 factory를 사용한다.
87
+ */
88
+ export declare function createDiagnosticRmqClient(options: RmqOptions): ClientRMQ;
89
+ export {};
@@ -0,0 +1,300 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.createDiagnosticRmqClient = exports.getRecordedRmqDiagnosticError = exports.recordRmqDiagnosticError = exports.getRmqDiagnosticSnapshot = exports.registerRmqObservation = exports.withRmqConnectionName = exports.createRmqObserver = exports.RmqConnectionObserver = exports.isRmqDiagnosticsEnabled = void 0;
30
+ const node_crypto_1 = require("node:crypto");
31
+ const node_os_1 = __importDefault(require("node:os"));
32
+ const microservices_1 = require("@nestjs/microservices");
33
+ const msa_logger_1 = __importStar(require("@yolo-croket-dev/msa-logger"));
34
+ const observations = new WeakMap();
35
+ const recordedErrors = new WeakMap();
36
+ let logger;
37
+ /**
38
+ * @author jun
39
+ * @description 프로세스의 RMQ 연결 진단 활성 여부를 확인한다.
40
+ * @process 명시적인 true 값만 활성으로 해석한다.
41
+ */
42
+ function isRmqDiagnosticsEnabled() {
43
+ return process.env.RMQ_DIAGNOSTICS_ENABLED === 'true';
44
+ }
45
+ exports.isRmqDiagnosticsEnabled = isRmqDiagnosticsEnabled;
46
+ function writeConnectionEvent(info, details) {
47
+ logger !== null && logger !== void 0 ? logger : (logger = new msa_logger_1.default(msa_logger_1.ServerTypeEnum.SERVICE));
48
+ const method = info.event === 'connected' || info.event === 'unblocked' ? 'info' : 'warn';
49
+ logger[method]('RMQ_CONNECTION', {
50
+ traceId: '-',
51
+ diagnosticInfo: info,
52
+ }, details);
53
+ }
54
+ /**
55
+ * @author jun
56
+ * @description 전송 동작을 바꾸지 않고 연결 상태와 반복 실패를 기록한다.
57
+ * @process 연결 이벤트 구독 → 상태 갱신/60초 실패 요약 → 종료 시 listener/timer 정리.
58
+ */
59
+ class RmqConnectionObserver {
60
+ constructor(queue, direction, configuredName, write = writeConnectionEvent) {
61
+ this.direction = direction;
62
+ this.write = write;
63
+ this.connectionId = (0, node_crypto_1.randomUUID)();
64
+ this.generation = 0;
65
+ this.state = 'not_connected';
66
+ this.changedAt = new Date().toISOString();
67
+ this.listeners = [];
68
+ this.failed = false;
69
+ this.repeatCount = 0;
70
+ this.targetService = (0, msa_logger_1.sanitizeDiagnosticText)(queue || 'unknown').slice(0, 240);
71
+ this.connectionName = (configuredName === null || configuredName === void 0 ? void 0 : configuredName.trim()) ? configuredName
72
+ : `${process.env.SERVER_NAME || 'unknown'}/${direction}/${queue || 'unknown'}/${node_os_1.default.hostname()}/${this.connectionId}`;
73
+ }
74
+ snapshot() {
75
+ return {
76
+ connectionId: this.connectionId,
77
+ connectionName: (0, msa_logger_1.sanitizeDiagnosticText)(this.connectionName).slice(0, 240),
78
+ direction: this.direction,
79
+ targetService: this.targetService,
80
+ generation: this.generation,
81
+ connectionState: this.state,
82
+ stateChangedAt: this.changedAt,
83
+ };
84
+ }
85
+ transition(state) {
86
+ if (this.state !== state) {
87
+ this.state = state;
88
+ this.changedAt = new Date().toISOString();
89
+ }
90
+ }
91
+ emit(event, error, extra = {}) {
92
+ // Diagnostics must not throw into the connection manager's synchronous event dispatch.
93
+ try {
94
+ this.write({
95
+ ...error === null || error === void 0 ? void 0 : error.diagnosticInfo,
96
+ ...this.snapshot(),
97
+ event,
98
+ ...extra,
99
+ }, error === null || error === void 0 ? void 0 : error.details);
100
+ }
101
+ catch { /* The transport remains authoritative if the log sink fails. */ }
102
+ }
103
+ flush() {
104
+ if (this.timer)
105
+ clearTimeout(this.timer);
106
+ this.timer = undefined;
107
+ if (this.repeatCount) {
108
+ this.emit('connect_failed_summary', this.latestFailure, {
109
+ repeatCount: this.repeatCount,
110
+ firstOccurredAt: this.firstOccurredAt,
111
+ lastOccurredAt: this.lastOccurredAt,
112
+ });
113
+ }
114
+ this.repeatCount = 0;
115
+ this.latestFailure = undefined;
116
+ this.firstOccurredAt = undefined;
117
+ this.lastOccurredAt = undefined;
118
+ }
119
+ onFailure(value) {
120
+ var _a;
121
+ this.transition('connection_failed');
122
+ const error = (0, msa_logger_1.normalizeDiagnosticError)(value, 'connection_failed');
123
+ if (!this.failed) {
124
+ this.failed = true;
125
+ this.emit('connect_failed', error);
126
+ return;
127
+ }
128
+ this.repeatCount += 1;
129
+ this.latestFailure = error;
130
+ this.lastOccurredAt = new Date().toISOString();
131
+ (_a = this.firstOccurredAt) !== null && _a !== void 0 ? _a : (this.firstOccurredAt = this.lastOccurredAt);
132
+ if (!this.timer) {
133
+ this.timer = setTimeout(() => this.flush(), 60000);
134
+ this.timer.unref();
135
+ }
136
+ }
137
+ observe(emitter) {
138
+ if (this.emitter === emitter)
139
+ return;
140
+ this.detach();
141
+ this.emitter = emitter;
142
+ this.listeners = [
143
+ [
144
+ 'connect',
145
+ () => {
146
+ this.flush();
147
+ this.failed = false;
148
+ this.generation += 1;
149
+ this.transition('connected');
150
+ this.emit('connected');
151
+ },
152
+ ],
153
+ [
154
+ 'connectFailed',
155
+ value => this.onFailure(value),
156
+ ],
157
+ [
158
+ 'disconnect',
159
+ value => {
160
+ this.transition('disconnected');
161
+ this.emit('disconnected', (0, msa_logger_1.normalizeDiagnosticError)(value, 'connection_lost'));
162
+ },
163
+ ],
164
+ [
165
+ 'blocked',
166
+ () => {
167
+ this.transition('blocked');
168
+ this.emit('blocked');
169
+ },
170
+ ],
171
+ [
172
+ 'unblocked',
173
+ () => {
174
+ this.transition('connected');
175
+ this.emit('unblocked');
176
+ },
177
+ ],
178
+ ];
179
+ this.listeners.forEach(([event, listener,]) => emitter.on(event, listener));
180
+ }
181
+ detach() {
182
+ this.listeners.forEach(([event, listener,]) => { var _a; return (_a = this.emitter) === null || _a === void 0 ? void 0 : _a.removeListener(event, listener); });
183
+ this.listeners = [];
184
+ this.emitter = undefined;
185
+ }
186
+ close() {
187
+ this.flush();
188
+ this.detach();
189
+ this.failed = false;
190
+ this.transition('closed');
191
+ }
192
+ }
193
+ exports.RmqConnectionObserver = RmqConnectionObserver;
194
+ /**
195
+ * @author jun
196
+ * @description 기존 연결 옵션으로 관찰자를 만든다.
197
+ * @process 명시적인 broker 연결 이름을 읽고 인스턴스 식별자를 생성한다.
198
+ */
199
+ function createRmqObserver(options, direction) {
200
+ var _a, _b;
201
+ const properties = (_b = (_a = options.socketOptions) === null || _a === void 0 ? void 0 : _a.connectionOptions) === null || _b === void 0 ? void 0 : _b.clientProperties;
202
+ const configured = properties && typeof properties === 'object'
203
+ ? properties.connection_name : undefined;
204
+ return new RmqConnectionObserver(options.queue, direction, typeof configured === 'string' ? configured : undefined);
205
+ }
206
+ exports.createRmqObserver = createRmqObserver;
207
+ /**
208
+ * @author jun
209
+ * @description 기존 RMQ 옵션에 연결 이름만 추가한다.
210
+ * @process 원본 옵션과 명시적인 연결 이름을 보존한 복사본을 반환한다.
211
+ */
212
+ function withRmqConnectionName(options, observer) {
213
+ var _a, _b, _c;
214
+ return {
215
+ ...options,
216
+ socketOptions: {
217
+ ...options.socketOptions,
218
+ connectionOptions: {
219
+ ...(_a = options.socketOptions) === null || _a === void 0 ? void 0 : _a.connectionOptions,
220
+ clientProperties: {
221
+ ...(_c = (_b = options.socketOptions) === null || _b === void 0 ? void 0 : _b.connectionOptions) === null || _c === void 0 ? void 0 : _c.clientProperties,
222
+ connection_name: observer.connectionName,
223
+ },
224
+ },
225
+ },
226
+ };
227
+ }
228
+ exports.withRmqConnectionName = withRmqConnectionName;
229
+ /**
230
+ * @author jun
231
+ * @description 클라이언트 또는 consumer 채널을 관찰자와 연결한다.
232
+ * @process WeakMap에 등록하여 전송 객체 수명 이후 참조를 유지하지 않는다.
233
+ */
234
+ function registerRmqObservation(owner, observer) {
235
+ observations.set(owner, observer);
236
+ }
237
+ exports.registerRmqObservation = registerRmqObservation;
238
+ /**
239
+ * @author jun
240
+ * @description 현재 연결 상태의 독립적인 스냅샷을 반환한다.
241
+ * @process 관찰 대상이 아니면 undefined를 반환한다.
242
+ */
243
+ function getRmqDiagnosticSnapshot(owner) {
244
+ var _a;
245
+ return (_a = observations.get(owner)) === null || _a === void 0 ? void 0 : _a.snapshot();
246
+ }
247
+ exports.getRmqDiagnosticSnapshot = getRmqDiagnosticSnapshot;
248
+ // Contains error metadata only, never per-request context: one RMQ error can reject concurrent calls.
249
+ /**
250
+ * @author jun
251
+ * @description 이미 기록한 오류 상세를 표시한다.
252
+ * @process 요청별 호출 정보 없이 오류 메타데이터만 WeakMap에 저장한다.
253
+ */
254
+ function recordRmqDiagnosticError(error, diagnostic) {
255
+ if (error !== null && typeof error === 'object')
256
+ recordedErrors.set(error, diagnostic);
257
+ }
258
+ exports.recordRmqDiagnosticError = recordRmqDiagnosticError;
259
+ /**
260
+ * @author jun
261
+ * @description 동일 오류의 중복 상세 기록 여부를 확인한다.
262
+ * @process 객체 오류에 연결된 정규화 결과를 반환한다.
263
+ */
264
+ function getRecordedRmqDiagnosticError(error) {
265
+ return error !== null && typeof error === 'object' ? recordedErrors.get(error) : undefined;
266
+ }
267
+ exports.getRecordedRmqDiagnosticError = getRecordedRmqDiagnosticError;
268
+ class DiagnosticClientRMQ extends microservices_1.ClientRMQ {
269
+ constructor(options, observation) {
270
+ super(withRmqConnectionName(options, observation));
271
+ this.observation = observation;
272
+ registerRmqObservation(this, observation);
273
+ }
274
+ createClient() {
275
+ const client = super.createClient();
276
+ this.observation.observe(client);
277
+ return client;
278
+ }
279
+ async close() {
280
+ try {
281
+ await super.close();
282
+ }
283
+ finally {
284
+ this.observation.close();
285
+ }
286
+ }
287
+ }
288
+ /** Same factory/options and lazy connection behavior; observation is opt-in per process. */
289
+ /**
290
+ * @author jun
291
+ * @description lazy 연결을 유지하는 RMQ 클라이언트를 생성한다.
292
+ * @process 진단이 활성화되면 기존 ClientRMQ를 확장하고 그 외에는 기본 factory를 사용한다.
293
+ */
294
+ function createDiagnosticRmqClient(options) {
295
+ if (!isRmqDiagnosticsEnabled())
296
+ return microservices_1.ClientProxyFactory.create(options);
297
+ const connectionOptions = options.options || {};
298
+ return new DiagnosticClientRMQ(connectionOptions, createRmqObserver(connectionOptions, 'client'));
299
+ }
300
+ exports.createDiagnosticRmqClient = createDiagnosticRmqClient;
@@ -39,11 +39,22 @@ const common_1 = require("@nestjs/common");
39
39
  const msa_logger_1 = __importStar(require("@yolo-croket-dev/msa-logger"));
40
40
  const operators_1 = require("rxjs/operators");
41
41
  const dd_trace_1 = __importDefault(require("dd-trace"));
42
+ const rmq_diagnostics_function_1 = require("../functions/rmq-diagnostics.function");
42
43
  const redact_auth_data_function_1 = require("../functions/redact-auth-data.function");
43
44
  const grpc_param_metadata_function_1 = require("../functions/grpc-param-metadata.function");
44
45
  const { SERVER_NAME } = process.env;
45
46
  const msaLogger = new msa_logger_1.default(msa_logger_1.ServerTypeEnum.SERVICE);
46
47
  const MAX_CUT_LENGTH = 500;
48
+ function readErrorProperty(error, key) {
49
+ if (error === null || error === undefined)
50
+ return undefined;
51
+ try {
52
+ return Reflect.get(Object(error), key);
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
47
58
  dd_trace_1.default.init();
48
59
  let LoggingServiceInterceptor = class LoggingServiceInterceptor {
49
60
  keyCutter(data) {
@@ -108,6 +119,7 @@ let LoggingServiceInterceptor = class LoggingServiceInterceptor {
108
119
  pattern = content.pattern;
109
120
  payload = context.switchToHttp().getRequest().payload;
110
121
  }
122
+ const connection = isGrpc ? undefined : (0, rmq_diagnostics_function_1.getRmqDiagnosticSnapshot)(rmqContext.getChannelRef());
111
123
  const pastParam = JSON.parse(JSON.stringify(param));
112
124
  const pastFrom = pastParam.from || '-';
113
125
  const pastFroms = pastParam.froms || [pastFrom];
@@ -171,22 +183,47 @@ let LoggingServiceInterceptor = class LoggingServiceInterceptor {
171
183
  loggingSpan.finish();
172
184
  }
173
185
  })).pipe((0, operators_1.catchError)((error) => {
186
+ const diagnostic = connection ? (0, msa_logger_1.normalizeDiagnosticError)(error, 'service_error') : undefined;
187
+ const frame = diagnostic === null || diagnostic === void 0 ? void 0 : diagnostic.details.errors[0];
188
+ const subMessageValue = readErrorProperty(error, 'subMessage');
189
+ let subMessage = typeof subMessageValue === 'string' ? subMessageValue : undefined;
190
+ if (subMessage && diagnostic)
191
+ subMessage = (0, msa_logger_1.sanitizeDiagnosticText)(subMessage);
192
+ const messageValue = readErrorProperty(error, 'message');
193
+ const errorMessage = (frame === null || frame === void 0 ? void 0 : frame.message) || (typeof messageValue === 'string' ? messageValue : undefined)
194
+ || (typeof error === 'string' ? error : undefined);
195
+ const nameValue = readErrorProperty(error, 'name');
196
+ const errorName = (frame === null || frame === void 0 ? void 0 : frame.name) || (typeof nameValue === 'string' ? nameValue : 'UNKNOWN_ERROR');
197
+ const stackValue = readErrorProperty(error, 'stack');
198
+ const errorStack = (frame === null || frame === void 0 ? void 0 : frame.stack) || (typeof stackValue === 'string' ? stackValue : undefined);
199
+ const statusValue = readErrorProperty(error, 'status');
200
+ const status = typeof statusValue === 'number' ? statusValue : 500;
201
+ const errOption = readErrorProperty(error, 'errOption');
174
202
  if (loggingSpan) {
175
203
  loggingSpan.setTag('error', true);
176
- loggingSpan.setTag('error.message', error.message);
177
- loggingSpan.setTag('error.status', error.status);
178
- loggingSpan.setTag('error.subMessage', error.subMessage);
179
- loggingSpan.setTag('error.stack', error.stack);
180
- loggingSpan.setTag('error.type', error.constructor.name);
204
+ loggingSpan.setTag('error.message', errorMessage);
205
+ loggingSpan.setTag('error.status', status);
206
+ loggingSpan.setTag('error.subMessage', subMessage);
207
+ loggingSpan.setTag('error.stack', errorStack);
208
+ loggingSpan.setTag('error.type', errorName);
181
209
  }
182
210
  msaLogger.printExceptionFliterService({
211
+ diagnosticInfo: diagnostic ? {
212
+ ...diagnostic.diagnosticInfo,
213
+ ...(0, rmq_diagnostics_function_1.getRmqDiagnosticSnapshot)(rmqContext.getChannelRef()),
214
+ event: 'handler_failed',
215
+ messagePattern: (0, msa_logger_1.sanitizeDiagnosticText)(pattern).slice(0, 240),
216
+ elapsedMs: Date.now() - now,
217
+ startGeneration: connection === null || connection === void 0 ? void 0 : connection.generation,
218
+ startConnectionState: connection === null || connection === void 0 ? void 0 : connection.connectionState,
219
+ } : undefined,
183
220
  traceId,
184
221
  errInfo: {
185
- errType: error.subMessage ? error.message : error.name,
186
- message: error.subMessage ? error.subMessage : error.message,
187
- status: error.status,
188
- stack: error.stack,
189
- errOption: error.errOption,
222
+ errType: subMessage ? errorMessage || errorName : errorName,
223
+ message: subMessage || errorMessage,
224
+ status,
225
+ stack: errorStack,
226
+ errOption,
190
227
  },
191
228
  messageBrokerInfo: {
192
229
  messagePatten: pattern,
@@ -196,12 +233,12 @@ let LoggingServiceInterceptor = class LoggingServiceInterceptor {
196
233
  from: pastFrom,
197
234
  froms: pastFroms,
198
235
  },
199
- });
236
+ }, diagnostic === null || diagnostic === void 0 ? void 0 : diagnostic.details);
200
237
  const elapsed = Date.now() - now;
201
238
  msaLogger.printOutService({
202
239
  traceId,
203
240
  responseInfo: {
204
- status: error.status,
241
+ status,
205
242
  ms: elapsed,
206
243
  },
207
244
  messageBrokerInfo: {
@@ -48,6 +48,7 @@ const functions_1 = require("../functions");
48
48
  const exceptions_1 = require("../exceptions");
49
49
  const context_promise_patch_1 = require("../functions/abort/context-promise-patch");
50
50
  const graceful_shutdown_service_1 = require("../modules/graceful-shutdown/graceful-shutdown.service");
51
+ const rmq_diagnostics_function_1 = require("../functions/rmq-diagnostics.function");
51
52
  const msaLogger = new msa_logger_1.default(msa_logger_1.ServerTypeEnum.SERVICE);
52
53
  // const MAX_CUT_LENGTH = 500;
53
54
  const MAX_LENGTH = 300;
@@ -154,6 +155,7 @@ let RPCInterceptor = class RPCInterceptor {
154
155
  const arg = args[0];
155
156
  const rmq = args[1];
156
157
  const channel = rmq.getChannelRef();
158
+ const startConnection = (0, rmq_diagnostics_function_1.getRmqDiagnosticSnapshot)(channel);
157
159
  const message = rmq.getMessage();
158
160
  const now = Date.now();
159
161
  const content = JSON.parse(message.content.toString());
@@ -206,7 +208,7 @@ let RPCInterceptor = class RPCInterceptor {
206
208
  }
207
209
  const noAck = message.fields.routingKey.includes('ASYNC');
208
210
  let localErrorProcessor = async (err) => {
209
- var _a;
211
+ var _a, _b;
210
212
  const errMsg = err.response ? err.response : err.message;
211
213
  const status = err.status ? err.status : 500;
212
214
  const errType = err.errType;
@@ -278,10 +280,28 @@ let RPCInterceptor = class RPCInterceptor {
278
280
  }
279
281
  }
280
282
  const sendMessage = Buffer.from(JSON.stringify(abstractAmqpHandlerData));
281
- if (!isAck) {
283
+ const diagnostic = startConnection
284
+ ? (0, msa_logger_1.normalizeDiagnosticError)(err, isTimeoutError ? 'response_timeout' : 'service_error') : undefined;
285
+ const diagnosticInfo = diagnostic ? {
286
+ ...diagnostic.diagnosticInfo,
287
+ ...(0, rmq_diagnostics_function_1.getRmqDiagnosticSnapshot)(channel),
288
+ event: 'handler_failed',
289
+ messagePattern: (0, msa_logger_1.sanitizeDiagnosticText)(String(method)).slice(0, 240),
290
+ elapsedMs: duration,
291
+ startGeneration: startConnection === null || startConnection === void 0 ? void 0 : startConnection.generation,
292
+ startConnectionState: startConnection === null || startConnection === void 0 ? void 0 : startConnection.connectionState,
293
+ } : undefined;
294
+ if (!isAck || diagnostic) {
282
295
  const currentContext = functions_1.AbortContext.getCurrentContext();
283
- // 에러 전달
284
- msaLogger.warn('에러 응답 전송 처리', { traceId: execInterceptor.traceId }, {
296
+ msaLogger.warn(isAck ? 'RPCInterceptor 오류 처리' : '에러 응답 전송 처리', {
297
+ traceId: execInterceptor.traceId,
298
+ diagnosticInfo,
299
+ }, diagnostic ? {
300
+ error: diagnostic.details,
301
+ reDeliveryCount,
302
+ staticRetryCount,
303
+ currentContext: (_a = currentContext === null || currentContext === void 0 ? void 0 : currentContext.requestId) !== null && _a !== void 0 ? _a : null,
304
+ } : {
285
305
  error: {
286
306
  message: err.message,
287
307
  stack: err.stack,
@@ -293,11 +313,13 @@ let RPCInterceptor = class RPCInterceptor {
293
313
  messagePattern: method,
294
314
  currentContext: currentContext && currentContext.requestId ? currentContext.requestId : null,
295
315
  });
316
+ }
317
+ if (!isAck) {
296
318
  channel.sendToQueue(message.properties.replyTo, sendMessage, { correlationId: message.properties.correlationId });
297
319
  }
298
320
  const messageContent = message.content.toString();
299
321
  /** 파일 객체 처리 */
300
- (0, functions_1.reWriteFileValue)((_a = messageContent === null || messageContent === void 0 ? void 0 : messageContent.data) === null || _a === void 0 ? void 0 : _a.file);
322
+ (0, functions_1.reWriteFileValue)((_b = messageContent === null || messageContent === void 0 ? void 0 : messageContent.data) === null || _b === void 0 ? void 0 : _b.file);
301
323
  if (!noAck && !isAck) {
302
324
  channel.ack(message);
303
325
  execInterceptor.isMessageAck = true;
@@ -1,5 +1,5 @@
1
- import { Method } from 'axios';
2
1
  import { ErrInfo } from './exception';
2
+ type RequestLogMethod = 'GET' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'POST' | 'PUT' | 'PATCH' | 'PURGE' | 'LINK' | 'UNLINK';
3
3
  export interface ExternalTokenValue {
4
4
  provider: string;
5
5
  client_id: string;
@@ -16,7 +16,7 @@ export interface DeviceRequest {
16
16
  export interface RequestLog {
17
17
  query?: unknown;
18
18
  body?: unknown;
19
- method: Method;
19
+ method: RequestLogMethod | Lowercase<RequestLogMethod>;
20
20
  endpoint: string;
21
21
  }
22
22
  /** 최신 에러 정보 */
@@ -98,3 +98,4 @@ export interface ExternalResponseNormalize<T> {
98
98
  export interface ResponseAction {
99
99
  result: boolean;
100
100
  }
101
+ export {};
@@ -13,12 +13,14 @@ export type GracefulRmqOptions = RmqOptions['options'] & {
13
13
  */
14
14
  export declare class GracefulServerRMQ extends ServerRMQ {
15
15
  protected readonly options: GracefulRmqOptions;
16
+ private readonly observation?;
16
17
  protected runningMessages: number;
17
18
  protected closing: boolean;
18
19
  protected consumerTag: string | null;
19
20
  protected readonly waitingEndingHandlersTimeoutMs: number;
20
21
  protected readonly waitingEndingHandlersIntervalMs: number;
21
22
  constructor(options: GracefulRmqOptions);
23
+ createClient(): any;
22
24
  setupChannel(channel: Channel, callback: () => void): Promise<void>;
23
25
  protected waitingHandlers(): Promise<void>;
24
26
  handleMessage(message: ConsumeMessage, channel: Channel): Promise<void>;
@@ -7,6 +7,7 @@ exports.GracefulServerRMQ = void 0;
7
7
  const microservices_1 = require("@nestjs/microservices");
8
8
  const os_1 = __importDefault(require("os"));
9
9
  const node_util_1 = require("node:util");
10
+ const rmq_diagnostics_function_1 = require("../../functions/rmq-diagnostics.function");
10
11
  const sleep = (0, node_util_1.promisify)(setTimeout);
11
12
  const RMQ_DEFAULT_WAITING_ENDING_HANDLERS_TIMEOUT_MS = 5000;
12
13
  const RMQ_DEFAULT_WAITING_ENDING_HANDLERS_INTERVAL_MS = 500;
@@ -56,21 +57,32 @@ function addRmqConnectionName(options) {
56
57
  */
57
58
  class GracefulServerRMQ extends microservices_1.ServerRMQ {
58
59
  constructor(options) {
59
- const optionsWithConnectionName = addRmqConnectionName(options);
60
+ const observation = (0, rmq_diagnostics_function_1.isRmqDiagnosticsEnabled)() ? (0, rmq_diagnostics_function_1.createRmqObserver)(options, 'consumer') : undefined;
61
+ const optionsWithConnectionName = observation
62
+ ? (0, rmq_diagnostics_function_1.withRmqConnectionName)(options, observation) : addRmqConnectionName(options);
60
63
  super(optionsWithConnectionName);
61
64
  this.runningMessages = 0;
62
65
  this.closing = false;
63
66
  this.consumerTag = null;
64
67
  this.options = optionsWithConnectionName;
68
+ this.observation = observation;
65
69
  this.waitingEndingHandlersTimeoutMs = this.getOptionsProp(this.options, 'waitingEndingHandlersTimeoutMs')
66
70
  || RMQ_DEFAULT_WAITING_ENDING_HANDLERS_TIMEOUT_MS;
67
71
  this.waitingEndingHandlersIntervalMs = this.getOptionsProp(this.options, 'waitingEndingHandlersIntervalMs')
68
72
  || RMQ_DEFAULT_WAITING_ENDING_HANDLERS_INTERVAL_MS;
69
73
  }
74
+ createClient() {
75
+ var _a;
76
+ const client = super.createClient();
77
+ (_a = this.observation) === null || _a === void 0 ? void 0 : _a.observe(client);
78
+ return client;
79
+ }
70
80
  async setupChannel(channel, callback) {
71
81
  if (this.closing) {
72
82
  return;
73
83
  }
84
+ if (this.observation)
85
+ (0, rmq_diagnostics_function_1.registerRmqObservation)(channel, this.observation);
74
86
  if (!this.queueOptions.noAssert) {
75
87
  await channel.assertQueue(this.queue, this.queueOptions);
76
88
  }
@@ -91,18 +103,24 @@ class GracefulServerRMQ extends microservices_1.ServerRMQ {
91
103
  });
92
104
  }
93
105
  async close() {
106
+ var _a;
94
107
  this.closing = true;
95
- if (this.channel) {
96
- await this.channel.removeSetup(undefined, (channel) => channel.cancel(this.consumerTag));
108
+ try {
109
+ if (this.channel) {
110
+ await this.channel.removeSetup(undefined, (channel) => channel.cancel(this.consumerTag));
111
+ }
112
+ this.consumerTag = null;
113
+ await Promise.race([
114
+ this.waitingHandlers(),
115
+ this.waitingEndingHandlersTimeoutMs > 0
116
+ && sleep(this.waitingEndingHandlersTimeoutMs),
117
+ ]);
118
+ this.runningMessages = 0;
119
+ await super.close();
120
+ }
121
+ finally {
122
+ (_a = this.observation) === null || _a === void 0 ? void 0 : _a.close();
97
123
  }
98
- this.consumerTag = null;
99
- await Promise.race([
100
- this.waitingHandlers(),
101
- this.waitingEndingHandlersTimeoutMs > 0
102
- && sleep(this.waitingEndingHandlersTimeoutMs),
103
- ]);
104
- this.runningMessages = 0;
105
- super.close();
106
124
  }
107
125
  }
108
126
  exports.GracefulServerRMQ = GracefulServerRMQ;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-croket-dev/core",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
4
4
  "author": "Yolo Co., Ltd.",
5
5
  "description": "Yolo commonization module",
6
6
  "main": "index.js",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@yolo-croket-dev/logger": "^1.0.15",
28
- "@yolo-croket-dev/msa-logger": "^3.5.0",
28
+ "@yolo-croket-dev/msa-logger": "3.5.3",
29
29
  "@yolo-croket-dev/redis": "^1.0.25",
30
30
  "axios": "^1.1.3",
31
31
  "randomstring": "^1.2.2",
@@ -0,0 +1,150 @@
1
+ const assert = require('node:assert/strict');
2
+ const { EventEmitter } = require('node:events');
3
+ const test = require('node:test');
4
+ const { ServerRMQ, RmqContext } = require('@nestjs/microservices');
5
+ const { lastValueFrom, throwError } = require('rxjs');
6
+ const { MsaLogger } = require('@yolo-croket-dev/msa-logger');
7
+ const { GracefulServerRMQ } = require('../modules/graceful-shutdown/graceful-shutdown.rmq');
8
+ const { LoggingServiceInterceptor } = require('../interceptors/logging-service.interceptor');
9
+ const { RPCInterceptor } = require('../interceptors/rpc.interceptor');
10
+ const { getRmqDiagnosticSnapshot, RmqConnectionObserver, registerRmqObservation } = require('../functions/rmq-diagnostics.function');
11
+
12
+ test('default/manage consumers preserve QoS/ACK, correlate failures and dispose observers on close', async t => {
13
+ const flag = process.env.RMQ_DIAGNOSTICS_ENABLED;
14
+ process.env.RMQ_DIAGNOSTICS_ENABLED = 'true';
15
+ t.after(() => { if (flag === undefined) delete process.env.RMQ_DIAGNOSTICS_ENABLED; else process.env.RMQ_DIAGNOSTICS_ENABLED = flag; });
16
+ const managers = [];
17
+ t.mock.method(ServerRMQ.prototype, 'createClient', () => {
18
+ const manager = new EventEmitter(); managers.push(manager); return manager;
19
+ });
20
+ const close = t.mock.method(ServerRMQ.prototype, 'close', () => {});
21
+ const errors = [];
22
+ for (const method of ['info', 'warn', 'printInputInterceptorService', 'printOutService']) t.mock.method(MsaLogger.prototype, method, () => {});
23
+ t.mock.method(MsaLogger.prototype, 'printExceptionFliterService', (info, details) => errors.push({ info, details }));
24
+ const identities = new Set();
25
+ for (const queue of ['USER_SERVICE', 'MANAGE_SERVICE']) {
26
+ const calls = [];
27
+ const channel = {
28
+ assertQueue: async (...args) => calls.push(['assert', ...args]),
29
+ prefetch: async (...args) => calls.push(['prefetch', ...args]),
30
+ consume: async (...args) => { calls.push(['consume', ...args]); return { consumerTag: 'tag' }; },
31
+ cancel: async tag => calls.push(['cancel', tag]),
32
+ };
33
+ const server = new GracefulServerRMQ({ queue, noAck: false, prefetchCount: 7, queueOptions: { durable: true, deadLetterExchange: 'existing.dlx' }, waitingEndingHandlersTimeoutMs: 1 });
34
+ const manager = server.createClient(); manager.emit('connect');
35
+ await server.setupChannel(channel, () => {});
36
+ assert.deepEqual(calls[0], ['assert', queue, { durable: true, deadLetterExchange: 'existing.dlx' }]);
37
+ assert.deepEqual(calls[1], ['prefetch', 7, undefined]);
38
+ assert.deepEqual(calls[2][3], { noAck: false });
39
+ const snapshot = getRmqDiagnosticSnapshot(channel);
40
+ identities.add(snapshot.connectionId);
41
+ const data = { payload: { ok: true }, param: { traceId: 'consumer-trace' } };
42
+ const context = new RmqContext([{ content: Buffer.from(JSON.stringify({ pattern: 'test.receive' })) }, channel, 'test.receive']);
43
+ const execution = { getArgs: () => [data, context], switchToRpc: () => ({ getContext: () => context, getData: () => data }), switchToHttp: () => ({ getRequest: () => data }) };
44
+ const error = Object.assign(Error('handler failed'), { status: 409, errOption: { logErrorLevel: 'warn' } });
45
+ await assert.rejects(lastValueFrom(new LoggingServiceInterceptor().intercept(execution, { handle: () => throwError(() => error) })), actual => actual === error);
46
+ const record = errors.at(-1);
47
+ assert.equal(record.info.diagnosticInfo.connectionId, snapshot.connectionId);
48
+ assert.equal(record.info.diagnosticInfo.targetService, queue);
49
+ assert.equal(record.info.diagnosticInfo.direction, 'consumer');
50
+ assert.equal(record.info.diagnosticInfo.kind, 'service_error');
51
+ assert.equal(record.info.errInfo.errOption.logErrorLevel, 'warn');
52
+ server.channel = { removeSetup: async (_, cancel) => cancel(channel) };
53
+ await server.close();
54
+ assert.deepEqual(calls.at(-1), ['cancel', 'tag']);
55
+ assert.equal(manager.eventNames().length, 0);
56
+ }
57
+ assert.equal(identities.size, 2);
58
+ assert.equal(close.mock.callCount(), 2);
59
+ });
60
+
61
+ test('primitive and getter failures retain the original rejection and emit diagnostics', async t => {
62
+ const output = [];
63
+ for (const method of ['printInputInterceptorService', 'printOutService']) t.mock.method(MsaLogger.prototype, method, () => {});
64
+ t.mock.method(MsaLogger.prototype, 'printExceptionFliterService', (info, details) => output.push({ info, details }));
65
+ const channel = {};
66
+ registerRmqObservation(channel, new RmqConnectionObserver('USER_SERVICE', 'consumer', undefined, () => {}));
67
+ const data = { payload: {}, param: { traceId: 'primitive-error' } };
68
+ const context = new RmqContext([{ content: Buffer.from(JSON.stringify({ pattern: 'test.receive' })) }, channel, 'test.receive']);
69
+ const execution = { getArgs: () => [data, context], switchToRpc: () => ({ getContext: () => context, getData: () => data }), switchToHttp: () => ({ getRequest: () => data }) };
70
+ const throwingGetter = new Proxy({}, { get() { throw Error('getter failed'); } });
71
+ for (const original of [null, 'remote failed', throwingGetter]) {
72
+ const settled = await Promise.allSettled([lastValueFrom(new LoggingServiceInterceptor().intercept(execution, { handle: () => throwError(() => original) }))]);
73
+ assert.equal(settled[0].status, 'rejected');
74
+ assert.equal(settled[0].reason, original);
75
+ assert.equal(output.at(-1).info.traceId, 'primitive-error');
76
+ assert.equal(output.at(-1).info.diagnosticInfo.event, 'handler_failed');
77
+ }
78
+ });
79
+
80
+ test('consumer observer is disposed even when channel setup removal fails', async t => {
81
+ const flag = process.env.RMQ_DIAGNOSTICS_ENABLED;
82
+ process.env.RMQ_DIAGNOSTICS_ENABLED = 'true';
83
+ t.after(() => { if (flag === undefined) delete process.env.RMQ_DIAGNOSTICS_ENABLED; else process.env.RMQ_DIAGNOSTICS_ENABLED = flag; });
84
+ const manager = new EventEmitter();
85
+ t.mock.method(ServerRMQ.prototype, 'createClient', () => manager);
86
+ t.mock.method(ServerRMQ.prototype, 'close', () => {});
87
+ const server = new GracefulServerRMQ({ queue: 'USER_SERVICE' });
88
+ server.createClient();
89
+ server.channel = { removeSetup: async () => { throw Error('channel closed'); } };
90
+ await assert.rejects(server.close(), /channel closed/);
91
+ assert.equal(manager.listenerCount('connect'), 0);
92
+ });
93
+
94
+ test('RPC-only consumer failure keeps the existing reply and adds connection diagnostics to its warning', async t => {
95
+ const warnings = [];
96
+ t.mock.method(MsaLogger.prototype, 'warn', (message, info, details) => warnings.push({ message, info, details }));
97
+ const replies = [];
98
+ const channel = {
99
+ sendToQueue: (...args) => replies.push(args),
100
+ ack: () => {},
101
+ };
102
+ const observer = new RmqConnectionObserver('MANAGE_SERVICE', 'consumer', undefined, () => {});
103
+ registerRmqObservation(channel, observer);
104
+ const request = { pattern: 'manage.fail', data: { payload: {}, param: { traceId: 'rpc-only' } } };
105
+ const message = {
106
+ content: Buffer.from(JSON.stringify(request)),
107
+ properties: { headers: {}, correlationId: 'correlation', replyTo: 'reply.queue' },
108
+ fields: { routingKey: 'MANAGE_SERVICE', consumerTag: 'consumer' },
109
+ };
110
+ const context = { getArgs: () => [{}, new RmqContext([message, channel, 'manage.fail'])], getHandler: () => function handler() {} };
111
+ const interceptor = new RPCInterceptor({ get: () => ({ isAdminGuard: true }) }, { isApplicationExit: false });
112
+ await interceptor.intercept(context, { handle: () => { throw Error('guard should reject first'); } });
113
+ assert.equal(warnings.length, 1);
114
+ assert.equal(warnings[0].info.traceId, 'rpc-only');
115
+ assert.equal(warnings[0].info.diagnosticInfo.connectionId, observer.connectionId);
116
+ assert.equal(warnings[0].info.diagnosticInfo.targetService, 'MANAGE_SERVICE');
117
+ assert.equal(warnings[0].info.diagnosticInfo.messagePattern, 'manage.fail');
118
+ assert.equal(warnings[0].info.diagnosticInfo.event, 'handler_failed');
119
+ assert.equal(replies.length, 1);
120
+ assert.equal(JSON.parse(replies[0][1].toString()).isError, true);
121
+ });
122
+
123
+ test('RPC-only handler failure records its trace and channel on the existing warning', async t => {
124
+ const warnings = [];
125
+ t.mock.method(MsaLogger.prototype, 'warn', (message, info) => warnings.push({ message, info }));
126
+ const acknowledgements = [];
127
+ const channel = {
128
+ sendToQueue: () => {},
129
+ ack: message => acknowledgements.push(message),
130
+ };
131
+ const observer = new RmqConnectionObserver('MANAGE_SERVICE', 'consumer', undefined, () => {});
132
+ registerRmqObservation(channel, observer);
133
+ const request = { pattern: 'manage.fail', data: { payload: {}, param: { traceId: 'handler-trace' } } };
134
+ const message = {
135
+ content: Buffer.from(JSON.stringify(request)),
136
+ properties: { headers: {}, correlationId: 'correlation', replyTo: 'reply.queue' },
137
+ fields: { routingKey: 'MANAGE_SERVICE', consumerTag: 'consumer' },
138
+ };
139
+ const context = { getArgs: () => [{}, new RmqContext([message, channel, 'manage.fail'])], getHandler: () => function handler() {} };
140
+ const shutdown = { isApplicationExit: false, pushActiveInterceptors: () => {}, popActiveInterceptors: () => {} };
141
+ const interceptor = new RPCInterceptor({ get: () => undefined }, shutdown);
142
+ const failure = Error('handler failed');
143
+ const stream = await interceptor.intercept(context, { handle: () => throwError(() => failure) });
144
+ await assert.rejects(lastValueFrom(stream), actual => actual === failure);
145
+ assert.equal(warnings.length, 1);
146
+ assert.equal(warnings[0].info.traceId, 'handler-trace');
147
+ assert.equal(warnings[0].info.diagnosticInfo.connectionId, observer.connectionId);
148
+ assert.equal(warnings[0].info.diagnosticInfo.event, 'handler_failed');
149
+ assert.equal(acknowledgements.length, 1);
150
+ });
@@ -0,0 +1,177 @@
1
+ const assert = require('node:assert/strict');
2
+ const { EventEmitter } = require('node:events');
3
+ const test = require('node:test');
4
+ const { Observable, of, throwError } = require('rxjs');
5
+ const { ClientRMQ, Transport } = require('@nestjs/microservices');
6
+ const { MsaLogger } = require('@yolo-croket-dev/msa-logger');
7
+ const {
8
+ RmqConnectionObserver, createDiagnosticRmqClient, registerRmqObservation, getRmqDiagnosticSnapshot,
9
+ } = require('../functions/rmq-diagnostics.function');
10
+ const { AmqpHandler, HttpExceptionCustom } = require('../functions/amqp-handler.function');
11
+ const { AmqpManagement } = require('../functions/amqp-call-manager.function');
12
+
13
+ test('connection events have no request context; generation, summary and cleanup follow lifecycle', t => {
14
+ t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
15
+ const records = [];
16
+ const observer = new RmqConnectionObserver('USER_SERVICE', 'client', undefined, (info, details) => records.push({ info, details }));
17
+ const manager = new EventEmitter();
18
+ observer.observe(manager); observer.observe(manager);
19
+ assert.equal(manager.listenerCount('connectFailed'), 1);
20
+ assert.equal(observer.snapshot().connectionState, 'not_connected');
21
+ const error = Object.assign(Error('refused amqp://user:password@host'), { code: 'ECONNREFUSED' });
22
+ manager.emit('connectFailed', { err: error });
23
+ for (let i = 0; i < 100; i += 1) manager.emit('connectFailed', { err: error });
24
+ assert.equal(records.length, 1);
25
+ t.mock.timers.tick(60_000);
26
+ assert.equal(records[1].info.repeatCount, 100);
27
+ manager.emit('connectFailed', { err: error });
28
+ manager.emit('connect', {});
29
+ assert.equal(records[2].info.repeatCount, 1);
30
+ assert.equal(observer.snapshot().generation, 1);
31
+ manager.emit('blocked', { reason: 'private' });
32
+ assert.equal(observer.snapshot().connectionState, 'blocked');
33
+ manager.emit('unblocked');
34
+ manager.emit('disconnect', { err: Error('closed') });
35
+ assert.equal(records.at(-1).info.kind, 'connection_lost');
36
+ manager.emit('connect', {});
37
+ assert.equal(observer.snapshot().generation, 2);
38
+ manager.emit('connectFailed', { err: error });
39
+ manager.emit('connectFailed', { err: error });
40
+ observer.close();
41
+ const count = records.length;
42
+ t.mock.timers.tick(120_000);
43
+ manager.emit('connect', {});
44
+ assert.equal(records.length, count);
45
+ assert.equal(manager.eventNames().length, 0);
46
+ assert.ok(!JSON.stringify(records).includes('user:password'));
47
+ assert.ok(!JSON.stringify(records).includes('private'));
48
+ });
49
+
50
+ test('factory is lazy, preserves transport options and configured names, opt-in defaults off', async t => {
51
+ const previous = process.env.RMQ_DIAGNOSTICS_ENABLED;
52
+ t.after(() => { if (previous === undefined) delete process.env.RMQ_DIAGNOSTICS_ENABLED; else process.env.RMQ_DIAGNOSTICS_ENABLED = previous; });
53
+ const createSpy = t.mock.method(ClientRMQ.prototype, 'createClient', () => { throw Error('must remain lazy'); });
54
+ const options = {
55
+ transport: Transport.RMQ,
56
+ options: { queue: 'USER_SERVICE', noAck: true, queueOptions: { noAssert: true, durable: true },
57
+ socketOptions: { heartbeatIntervalInSeconds: 60, connectionOptions: { clientProperties: { connection_name: 'explicit' } } } },
58
+ };
59
+ const copy = JSON.stringify(options);
60
+ delete process.env.RMQ_DIAGNOSTICS_ENABLED;
61
+ const disabled = createDiagnosticRmqClient(options);
62
+ assert.equal(getRmqDiagnosticSnapshot(disabled), undefined);
63
+ process.env.RMQ_DIAGNOSTICS_ENABLED = 'true';
64
+ const client = createDiagnosticRmqClient(options);
65
+ const other = createDiagnosticRmqClient(options);
66
+ assert.equal(createSpy.mock.callCount(), 0);
67
+ assert.equal(getRmqDiagnosticSnapshot(client).connectionState, 'not_connected');
68
+ assert.equal(getRmqDiagnosticSnapshot(client).connectionName, 'explicit');
69
+ assert.notEqual(getRmqDiagnosticSnapshot(client).connectionId, getRmqDiagnosticSnapshot(other).connectionId);
70
+ assert.equal(JSON.stringify(options), copy);
71
+ assert.deepEqual(client.options, options.options);
72
+ await disabled.close(); await client.close(); await other.close();
73
+ });
74
+
75
+ test('same trace and shared error retain each call target and start/failure generation', async t => {
76
+ const records = [];
77
+ t.mock.method(MsaLogger.prototype, 'warn', (message, info, details) => records.push({ message, info, details }));
78
+ const error = { err: Object.assign(Error('reset'), { code: 'ECONNRESET' }) };
79
+ const subscribers = [];
80
+ const calls = ['USER_SERVICE', 'PAYMENT_SERVICE'].map(target => {
81
+ const observer = new RmqConnectionObserver(target, 'client', undefined, () => {});
82
+ const manager = new EventEmitter(); observer.observe(manager);
83
+ const client = { send: () => new Observable(subscriber => { subscribers.push(subscriber); }) };
84
+ registerRmqObservation(client, observer);
85
+ const promise = AmqpHandler(client, `test.${target}`, { payload: { secret: 'not logged' }, param: { traceId: 'same', timeout: 1000 } });
86
+ manager.emit('connect'); manager.emit('disconnect', error); manager.emit('connect');
87
+ return promise;
88
+ });
89
+ const results = Promise.allSettled(calls);
90
+ subscribers.forEach(subscriber => subscriber.error(error));
91
+ assert.ok((await results).every(result => result.reason === error));
92
+ assert.equal(records.length, 2);
93
+ assert.deepEqual(records.map(record => record.info.diagnosticInfo.targetService), ['USER_SERVICE', 'PAYMENT_SERVICE']);
94
+ for (const record of records) {
95
+ assert.equal(record.info.traceId, 'same');
96
+ assert.equal(record.info.diagnosticInfo.startGeneration, 0);
97
+ assert.equal(record.info.diagnosticInfo.startConnectionState, 'not_connected');
98
+ assert.equal(record.info.diagnosticInfo.generation, 2);
99
+ assert.equal(record.info.diagnosticInfo.kind, 'connection_lost');
100
+ assert.equal(record.info.diagnosticInfo.timeoutMs, 1000);
101
+ }
102
+ assert.ok(!JSON.stringify(records).includes('not logged'));
103
+ });
104
+
105
+ test('success adds no logs; initial connect failure, timeout, service and no-handler stay distinct', async t => {
106
+ const records = [];
107
+ for (const method of ['info', 'warn', 'error']) t.mock.method(MsaLogger.prototype, method, (message, info) => records.push(info));
108
+ const observer = new RmqConnectionObserver('USER_SERVICE', 'client', undefined, () => {});
109
+ const cases = [
110
+ [() => throwError(() => ({ err: Object.assign(Error('refused'), { code: 'ECONNREFUSED' }) })), 'connection_failed'],
111
+ [() => new Observable(() => {}), 'response_timeout'],
112
+ [() => of({ isError: true, status: 400, errType: 'BUSINESS', errMessage: 'no' }), 'service_error'],
113
+ [() => throwError(() => 'There is no matching message handler defined in the remote service.'), 'handler_not_found'],
114
+ ];
115
+ for (const [send, kind] of cases) {
116
+ const client = { send }; registerRmqObservation(client, observer);
117
+ await assert.rejects(AmqpHandler(client, 'test.call', { payload: {}, param: { traceId: kind, timeout: 5 } }));
118
+ assert.equal(records.at(-1).diagnosticInfo.kind, kind);
119
+ }
120
+ const client = { send: () => of({ data: 42 }) }; registerRmqObservation(client, observer);
121
+ const count = records.length;
122
+ assert.deepEqual(await AmqpHandler(client, 'ok', { payload: {}, param: { traceId: 'ok' } }), { data: 42 });
123
+ assert.equal(records.length, count);
124
+ const business = { send: () => of({ isError: true, status: 409, errType: 'EXISTS', errMessage: 'exists' }) };
125
+ registerRmqObservation(business, observer);
126
+ await assert.rejects(AmqpHandler(business, 'test.call', { payload: {}, param: { traceId: 'business' } }), err => {
127
+ assert.ok(err instanceof HttpExceptionCustom); assert.equal(err.getStatus(), 409); assert.equal(err.errInfo.type, 'EXISTS'); return true;
128
+ });
129
+ });
130
+
131
+ test('legacy unobserved clients report failed pattern without inventing connection state', async t => {
132
+ const previous = process.env.RMQ_DIAGNOSTICS_ENABLED;
133
+ process.env.RMQ_DIAGNOSTICS_ENABLED = 'true';
134
+ t.after(() => { if (previous === undefined) delete process.env.RMQ_DIAGNOSTICS_ENABLED; else process.env.RMQ_DIAGNOSTICS_ENABLED = previous; });
135
+ const records = [];
136
+ t.mock.method(MsaLogger.prototype, 'warn', (message, info) => records.push({ message, info }));
137
+ const client = { send: () => throwError(() => Object.assign(Error('connection reset'), { code: 'ECONNRESET' })) };
138
+
139
+ await assert.rejects(AmqpHandler(client, 'user.lookup', { payload: {}, param: { traceId: 'legacy-call' } }));
140
+ assert.equal(records.length, 1);
141
+ assert.equal(records[0].info.traceId, 'legacy-call');
142
+ assert.equal(records[0].info.diagnosticInfo.messagePattern, 'user.lookup');
143
+ assert.equal(records[0].info.diagnosticInfo.connectionState, 'unobserved');
144
+ assert.equal(records[0].info.diagnosticInfo.connectionId, undefined);
145
+
146
+ const legacyClient = new ClientRMQ({ queue: 'USER_SERVICE' });
147
+ legacyClient.send = client.send;
148
+ await assert.rejects(AmqpHandler(legacyClient, 'user.lookup', { payload: {}, param: { traceId: 'legacy-rmq' } }));
149
+ assert.equal(records[1].info.diagnosticInfo.targetService, 'USER_SERVICE');
150
+
151
+ const manager = new AmqpManagement(legacyClient);
152
+ await assert.rejects(manager.call('lookup', 'user.lookup', {}, { traceId: 'legacy-wrapper' }, { isErrorThrowing: true }));
153
+ assert.equal(records.length, 3);
154
+ assert.equal(records[2].info.traceId, 'legacy-wrapper');
155
+ });
156
+
157
+ test('close waits for native async close and retains its rejection after listener cleanup', async t => {
158
+ const previous = process.env.RMQ_DIAGNOSTICS_ENABLED;
159
+ process.env.RMQ_DIAGNOSTICS_ENABLED = 'true';
160
+ t.after(() => { if (previous === undefined) delete process.env.RMQ_DIAGNOSTICS_ENABLED; else process.env.RMQ_DIAGNOSTICS_ENABLED = previous; });
161
+ const manager = new EventEmitter();
162
+ t.mock.method(ClientRMQ.prototype, 'createClient', () => manager);
163
+ let finish;
164
+ t.mock.method(ClientRMQ.prototype, 'close', () => new Promise((resolve, reject) => { finish = reject; }));
165
+ const client = createDiagnosticRmqClient({ transport: Transport.RMQ, options: { queue: 'TEST' } });
166
+ client.createClient();
167
+ let settled = false;
168
+ const error = Error('close failed');
169
+ const closed = client.close().finally(() => { settled = true; });
170
+ const rejected = assert.rejects(closed, err => err === error);
171
+ await Promise.resolve();
172
+ assert.equal(settled, false);
173
+ assert.equal(manager.listenerCount('connect'), 1);
174
+ finish(error);
175
+ await rejected;
176
+ assert.equal(manager.listenerCount('connect'), 0);
177
+ });