• v0.13.0 80f6258bfd

    v0.13.0 — readable error lines
    All checks were successful
    CI / gate (push) Successful in 37s
    CI / image (push) Successful in 15s
    Stable

    joeyr released this 2026-08-02 20:52:43 -05:00 | 220 commits to main since this release

    stack_trace was the last of the five keys Portainer's log viewer colours to go
    unused — and the one that mattered on the worst night.

    What an error used to look like

    err={"type":"Error","message":"op.gg returned HTTP 403","stack":"Error: op.gg
    returned HTTP 403\n    at crawlCounters (/app/dist/src/counters.js:316:11)\n
    at async runOpggWindow (/app/dist/src/index.js:466:7)"}
    

    Three separate problems, and the dashboard could not help with any of them.
    err is not one of the five keys it knows, so the whole error was demoted to a
    trailing key=value at the end of the line. That renderer flattens a nested
    object to JSON. And the stack inside it was a single string with its newlines
    escaped, so the frames printed as literal \n on one unbroken line — the least
    readable form of the most useful field.

    What it looks like now

    counters/classic > counters crawl or publish failed  queue=classic
      error=OpggHttpError: op.gg returned HTTP 403 for https://op.gg/…/counters/top
        at crawlCounters (/app/dist/src/counters.js:316)
        at runOpggWindow (/app/dist/src/index.js:466)
    

    error is rendered redformatKeyValuePair special-cases exactly the key
    names error and ERR — and stack_trace gets the dedicated renderer that
    prints one indented at func (source:line) per frame, which is the shape a
    stack was always meant to have.

    The class name is kept when it adds something. OpggHttpError versus
    RiotApiError says which half of the window failed before the message has to,
    and CountersCrawlAborted says the crawl stopped itself rather than broke. A
    bare Error says only that something threw, so it is left off.

    No call site changed

    The transformation is a formatters.log hook, which runs before pino's own
    error serialiser and therefore still sees the Error instance rather than the
    {type, message, stack} object it would otherwise become. That ordering is the
    whole opportunity.

    Being a property of the logger rather than of fifteen call sites also means a
    new logger.error({ err }, …) gets this for free instead of silently
    regressing. The six call sites that pass a pre-stringified err.message rather
    than the error get the red error key too, without being touched — they simply
    have no stack to give.

    Details

    • Stacks are capped at 12 frames. An async throw in Node carries twenty-odd,
      most of them node:internal/… plumbing below the first line of this codebase,
      and Portainer prints every frame it is given. A cap rather than a filter on
      node:internal/: the interesting frame is occasionally in there, and a filter
      that hides the cause is worse than a list that is slightly too long.
    • Frame parsing is anchored on the trailing numbers, because a source is
      routinely C:\…\index.js:527:5 or file:///C:/…/index.js:527:5 — both carry
      colons of their own, and splitting on the first takes the drive letter for the
      path.
    • A wrapped error's cause is reported beside it. OpggParseError takes
      one, and without this the line named the wrapper and lost what it wrapped.
    • A thrown non-Error carries its contents rather than [object Object].
      throw accepts anything, and a line that reports a failure and then spends
      its one field saying nothing is barely better than no line at all.

    Minor rather than patch for the changed log output. No artifact, schema, or
    published file changes. This completes the Portainer work: all five keys the
    dashboard renders — time, level, caller, message, stack_trace — are
    now used.

    Downloads