Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,40 @@ log.

This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **A response that arrives with no pending call left is now visible in the
log.** The line is written through the configured logger on every such
response, names the service, the method and the request id, and never the
arguments, the request or the response object (which echoes the whole
request back) or an error text. It tells a late reply apart from a service
that never answered, and after a restart it names the backlog the previous
process left behind.

A reply that could not be written to the caller's queue is reported by
`@imqueue/core` itself, as part of its write-failure episode reporting, so
this package adds no reporting of its own there. A call that got no
response within `callTimeout` is not logged here either: the caller
receives the `IMQ_RPC_CALL_TIMEOUT` rejection and decides how to report it.

### Changed

- **`@logged()` now names the class and the method instead of dumping the
error.** The line reads `Class.method() failed, code <code>`. The code is
never taken from the error as it is: only an allow-listed code is printed —
an `IMQ_`-prefixed framework code, a system `E…` code, a small integer, a
known redis reply code or one of a few known redis-client failure messages
mapped to codes of our own; everything else, the error's class name
included, is reported as `unknown`. The caught value itself, its message and its
stack are no longer printed: an application error may carry personal data,
and an imq error carries the call arguments in its properties. The method
name now also reaches the line under standard (TC39) decorators, where it was
previously unavailable. Everything else is unchanged, including which logger
is resolved, `doNotThrow`, the re-thrown value and the fact that a throwing
logger replaces the original error.

## [3.4.4] - 2026-07-26

### Changed
Expand Down
16 changes: 16 additions & 0 deletions src/IMQClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
fileExists,
mkdir,
writeFile,
logSafe,
SIGNALS,
} from './helpers/index.js';
import { EventEmitter } from 'node:events';
Expand Down Expand Up @@ -162,6 +163,7 @@ export abstract class IMQClient extends EventEmitter {
private readonly signalHandlers: Array<[string, (...args: any[]) => void]> =
[];
private readonly logger: ILogger;

private resolvers: {
[id: string]: [
(data: AnyJson, res: IMQRPCResponse) => void,
Expand Down Expand Up @@ -388,6 +390,8 @@ export abstract class IMQClient extends EventEmitter {
// extends the budget accordingly
timer = setTimeout(() => {
delete this.resolvers[id];
// not logged here: the caller receives the
// rejection below and decides how to report it
doReject(
IMQError(
'IMQ_RPC_CALL_TIMEOUT',
Expand Down Expand Up @@ -468,6 +472,18 @@ export abstract class IMQClient extends EventEmitter {
// current redis mock, BTW it was tested manually on real
// redis run
if (!this.resolvers[message.to]) {
// a response nobody is waiting for any more: either it came
// after the call had been given up on, or the call was made
// by a process which is gone. Neither the response nor its
// error is logged - the response echoes the request back
logSafe(
this.logger,
'warn',
`${this.serviceName}: response to request ${
message?.to
} has no pending call, method ${message?.request?.method}`,
);

// when there is no resolvers it means
// we have message in queue which was initiated
// by some process which is broken. So we provide an
Expand Down
63 changes: 53 additions & 10 deletions src/decorators/logged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,29 @@
* <support@imqueue.com> to get commercial licensing options.
*/
import { type ILogger } from '@imqueue/core';
import { errorCode } from '../helpers/index.js';

/**
* Name of the class a decorated method was called on, or `unknown` when it
* cannot be told.
*
* @param self - the `this` of the decorated call
* @returns the class name
*
* @remarks
* On a static method `this` is the class itself, so its own name is the answer
* there — going through the constructor would report `Function`.
*/
function className(self: any): string {
try {
const name =
typeof self === 'function' ? self.name : self?.constructor?.name;

return typeof name === 'string' && name ? name : 'unknown';
} catch {
return 'unknown';
}
}

/**
* Names of the `ILogger` methods {@link logged} can use to record a caught
Expand Down Expand Up @@ -64,12 +87,14 @@ export interface LoggedDecoratorOptions {

/**
* Creates a `@logged()` method decorator that wraps the decorated method in a
* try/catch and logs any error it throws. The logger is resolved in this
* order: an explicitly passed logger, then a `logger` defined on the instance
* or on the class, and finally the global `console`. By default the error is
* re-thrown after being logged; pass `{ doNotThrow: true }` to swallow it. The
* returned decorator is dual-mode: it works both as a standard (TC39) and as a
* legacy method decorator.
* try/catch and logs any error it throws. The logged line names the class, the
* method and an allow-listed failure code — never the error object itself,
* whose message, stack and properties may carry application data. The logger
* is resolved in this order: an explicitly passed logger, then a `logger`
* defined on the instance or on the class, and finally the global `console`.
* By default the error is re-thrown after being logged; pass
* `{ doNotThrow: true }` to swallow it. The returned decorator is dual-mode:
* it works both as a standard (TC39) and as a legacy method decorator.
*
* @param options - a logger to use, or the
* logged-decorator options
Expand All @@ -82,7 +107,7 @@ export function logged(options?: ILogger | LoggedDecoratorOptions): any {
: 'error';
const doThrow = !options || !(options as LoggedDecoratorOptions).doNotThrow;

const wrap = (original: (...args: any[]) => any) =>
const wrap = (original: (...args: any[]) => any, method?: string) =>
async function <T>(this: any, ...args: any[]): Promise<T | void> {
try {
if (original) {
Expand All @@ -103,7 +128,25 @@ export function logged(options?: ILogger | LoggedDecoratorOptions): any {
? ((this.constructor as any).logger as ILogger)
: console;

(logger as any)[level](err);
// the caught value itself is never printed: an application
// error may carry personal data, and an imq error carries the
// call arguments in its own properties. Only the class, the
// method and the failure code go out
let where = 'unknown.unknown()';
let code = 'unknown';

try {
where = `${className(this)}.${method || 'unknown'}()`;
code = errorCode(err);
} catch {
// extraction must never replace the original error, so
// the fallbacks above stand
}

// deliberately not contained: a throwing logger replaces the
// original error today, and changing that would be a change
// of behaviour rather than of logging
(logger as any)[level](`${where} failed, code ${code}`);

if (doThrow) {
throw err;
Expand All @@ -117,10 +160,10 @@ export function logged(options?: ILogger | LoggedDecoratorOptions): any {
// (target, propertyKey, descriptor).
return function (target: any, context: any, descriptor?: any): any {
if (context && typeof context === 'object' && 'kind' in context) {
return wrap(target);
return wrap(target, context.name && String(context.name));
}

descriptor.value = wrap(descriptor.value);
descriptor.value = wrap(descriptor.value, context && String(context));

return descriptor;
};
Expand Down
1 change: 1 addition & 0 deletions src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ export * from './signature.js';
export * from './os-uuid.js';
export * from './pid.js';
export * from './fs.js';
export * from './logging.js';
158 changes: 158 additions & 0 deletions src/helpers/logging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/*!
* I'm Queue Software Project
* Copyright (C) 2025 imqueue.com <support@imqueue.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* If you want to use this code in a closed source (commercial) project, you can
* purchase a proprietary commercial license. Please contact us at
* <support@imqueue.com> to get commercial licensing options.
*/
import { type ILogger } from '@imqueue/core';

/**
* Writes the given line through the given logger, containing any logger
* failure.
*
* @param logger - logger to write the line with
* @param level - logger method to use
* @param message - the line, which must never carry call arguments, request
* or response objects or an error text
*
* @remarks
* Never throws: a broken logger must not be able to change what the caller
* does.
*/
export function logSafe(
logger: ILogger,
level: 'info' | 'warn' | 'error',
message: string,
): void {
try {
logger[level](message);
} catch {
// a failing logger must never influence behaviour
}
}

/**
* Redis error replies this helper is allowed to quote. The leading token of a
* Redis error reply is a protocol constant, never data — but only these are
* recognised, so an arbitrary upper-case first word of some other error's
* message can never reach the log.
*/
const REDIS_REPLY_CODES: Set<string> = new Set([
'ASK',
'BUSY',
'BUSYGROUP',
'CLUSTERDOWN',
'CROSSSLOT',
'ERR',
'EXECABORT',
'LOADING',
'MASTERDOWN',
'MISCONF',
'MOVED',
'NOAUTH',
'NOGROUP',
'NOPERM',
'NOPROTO',
'NOREPLICAS',
'NOSCRIPT',
'NOTBUSY',
'OOM',
'READONLY',
'TRYAGAIN',
'UNBLOCKED',
'UNKILLABLE',
'WRONGPASS',
'WRONGTYPE',
]);

/**
* Transport failures the redis client reports by message only, mapped to a
* code of our own. The patterns are fixed library strings, so nothing from an
* application error can match them.
*/
const CLIENT_MESSAGE_CODES: Array<[RegExp, string]> = [
[/^Connection is closed/i, 'CONNECTION_CLOSED'],
[/^Stream connection ended/i, 'STREAM_ENDED'],
[/^Reached the max retries per request limit/i, 'MAX_RETRIES'],
[/^Command timed out/i, 'COMMAND_TIMEOUT'],
];

/**
* Shape of an `err.code` this helper accepts: an `IMQ_`-prefixed code of the
* framework itself, or a system errno such as `ECONNREFUSED`.
*/
const SAFE_CODE = /^(IMQ_[A-Z0-9_]{1,48}|E[A-Z]{2,15})$/;

/** Upper bound of a numeric `err.code`, so that no long number can pass */
const MAX_NUMERIC_CODE = 65535;

/**
* Extracts a loggable failure code from an unknown thrown value.
*
* @param err - the caught value, of any shape
* @returns the code, or `unknown` when none can be told safely
*
* @remarks
* Deliberately conservative: only an allow-listed code can come out of here,
* because an application error reaches this helper too and anything of its own
* may carry personal data. Recognised are a framework or system `code`, a
* small numeric `code`, the leading token of a known Redis error reply and a
* known redis-client failure message. Everything else — including the error's
* message, its stack and its class name — yields `unknown`. Never throws.
*/
export function errorCode(err: unknown): string {
try {
const code = (err as { code?: unknown } | undefined)?.code;

if (
typeof code === 'number' &&
Number.isInteger(code) &&
code >= 0 &&
code <= MAX_NUMERIC_CODE
) {
return String(code);
}

if (
typeof code === 'string' &&
(SAFE_CODE.test(code) || REDIS_REPLY_CODES.has(code))
) {
return code;
}

const message = (err as { message?: unknown } | undefined)?.message;

if (typeof message === 'string') {
const reply = message.split(' ', 1)[0];

if (REDIS_REPLY_CODES.has(reply)) {
return reply;
}

for (const [pattern, mapped] of CLIENT_MESSAGE_CODES) {
if (pattern.test(message)) {
return mapped;
}
}
}

return 'unknown';
} catch {
return 'unknown';
}
}
Loading
Loading