Skip to content

concatLoggingArgs crashes on non-coercible log values (null-prototype objects), masking transport errors #83

@Lonli-Lokli

Description

@Lonli-Lokli

Describe the bug

concatLoggingArgs() in src/utils.ts formats every logged argument value with a
template literal (`, ${key} = ${value}`). When a top-level value cannot be
converted to a primitive — most commonly a null-prototype object (header bags
from undici/axios are created with Object.create(null)), or an object with a
pathological Symbol.toPrimitive/valueOf — the coercion throws
TypeError: Cannot convert object to primitive value.

This is reached on the error path of HttpClient.sendAxiosRequest(), which calls
logDebug('Unrecognized axios error', axiosError) with the entire Axios error
object. As a result, whenever a non-retryable transport error occurs, the library
throws a confusing TypeError from inside its own logging code instead of
surfacing the real ConnectionError
— the actual failure is completely masked.

Two underlying defects:

  1. concatLoggingArgs performs unguarded primitive coercion, so it throws on
    null-prototype / non-coercible values (and is lossy — [object Object] — even
    when it doesn't throw).
  2. logDebug/logInfo build the detail string eagerly, before loglevel
    checks the active level. So the work (and the crash) happens even when debug
    logging is disabled, which is the default (getLogger('deepl').getLevel()
    returns 3 / WARN). A level guard would have skipped this code entirely.

To Reproduce

Steps to reproduce the behavior:

  1. Install deepl-node@1.27.0 on Node.js 22.
  2. Trigger any non-retryable transport error on a request (e.g. a translate call
    that fails at the Axios layer behind a proxy / blocked egress), so
    sendAxiosRequest reaches logDebug('Unrecognized axios error', axiosError).
  3. See TypeError: Cannot convert object to primitive value thrown from
    concatLoggingArgs, masking the original error.

Minimal, self-contained reproduction of the faulty formatter (no network needed):

const { logDebug } = require('deepl-node/dist/utils');
const loglevel = require('loglevel');

console.log('deepl logger level =', loglevel.getLogger('deepl').getLevel()); // 3 (WARN) by default

// A null-prototype object as a top-level arg value — exactly what an
// Axios/undici error carries (e.g. a header bag).
const headers = Object.create(null);
headers['content-type'] = 'application/json';

logDebug('Unrecognized axios error', { headers });
// → TypeError: Cannot convert object to primitive value
//   (thrown even though the level is WARN and nothing would be printed)

Observed stack trace from the real failure:

TypeError: Cannot convert object to primitive value
    at concatLoggingArgs (node_modules/deepl-node/dist/utils.js:17:37)
    at logDebug (node_modules/deepl-node/dist/utils.js:23:28)
    at HttpClient.sendAxiosRequest (node_modules/deepl-node/dist/client.js:225:38)
    at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
    at async HttpClient.sendRequestWithBackoff (node_modules/deepl-node/dist/client.js:162:28)
    at async Translator.translateText (node_modules/deepl-node/dist/translator.js:191:41)

Expected behavior

  • Logging helpers must never throw, regardless of the shape of the values passed
    to them. A non-coercible value (null-prototype object, cyclic object, etc.)
    should be formatted safely, not crash the request.
  • The original transport error (ConnectionError) should be surfaced to the
    caller; logging must not replace it with a TypeError.
  • With logging disabled (default WARN level), the formatter should not run at all.

Suggested fix (either alone prevents the crash; both are worth doing):

  1. Guard formatting by level:

    function logDebug(message, args) {
        if (logger.getLevel() <= loglevel.levels.DEBUG) {
            logger.debug(message + concatLoggingArgs(args));
        }
    }
  2. Make coercion safe, e.g. with util.inspect:

    const { inspect } = require('util');
    function concatLoggingArgs(args) {
        let detail = '';
        if (args) {
            for (const [key, value] of Object.entries(args)) {
                const str = typeof value === 'object' && value !== null
                    ? inspect(value, { depth: 2, breakLength: Infinity })
                    : String(value);
                detail += `, ${key} = ${str}`;
            }
        }
        return detail;
    }

    The error branch could also log axiosError.message / axiosError.code
    (strings) rather than the whole error object, matching the retryable branch
    which already uses `Encountered a retryable-error: ${error.message}`.

Screenshots

N/A (server-side library; stack trace and repro included above).

Desktop (please complete the following information):

  • OS: Linux (x86_64)
  • Browser: N/A — this is the deepl-node server-side SDK, not a browser app
  • Version: deepl-node 1.27.0; Node.js v22.22.2

Smartphone (please complete the following information):

  • N/A (server-side library)

Additional context

  • dist/client.js:225 is logDebug('Unrecognized axios error', axiosError);.
  • Confirmed against deepl-node@1.27.0 on Node v22.22.2: passing a top-level
    null-prototype object (or an object whose Symbol.toPrimitive returns a
    non-primitive) to logDebug throws; a nested object does not (it only
    stringifies to [object Object]), so the trigger is a non-coercible value at
    the top level of the args object — which axiosError supplies.
  • Because the formatter runs regardless of log level, this affects users who have
    not enabled debug logging at all.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions