• v4.0.2 8ed5b79f68

    v4.0.2
    All checks were successful
    CI / build-and-test (push) Successful in 2m51s
    Stable

    joeyr released this 2026-08-10 22:01:45 -05:00 | 0 commits to main since this release

    Fixed

    • "Your Pool This Patch" was quoting an ally pairing from the wrong lane. The
      champion-synergies artifact splits every pairing by the ally's role — Hwei mid
      and Hwei bottom are separate rows — but the row rendered beside a pool entry
      reads "50.0% with Hwei · 62 games" and says nothing about which lane Hwei
      played. The consumer matched on champion id alone, so whichever single lane
      happened to clear the 50-game floor was shown beside a teammate hovering that
      champion in a different lane. In a real champ select, Briar's Hwei row was a
      62-game mid-Hwei sample displayed next to a bot-lane Hwei; the pairing that
      actually applied was 27 games at 29.6%.

      The indexer now sums each ally's rows across the roles it was played at and
      applies the floor to the total, so a row means what its label says: games with
      that champion anywhere on the team. Summing is exact rather than an
      approximation — the artifact carries wins per row, so the combined rate is
      Σwins / Σgames, not an average of rates.

      Splitting by ally lane was also the wrong statistical call for this section.
      The same file carries no measurable pairing effect at any sample floor (see
      src/shared/champion-synergies/schema.ts), so subdividing a pairing by lane
      subdivides noise, and the thin slices read louder: Nocturne beside a bottom
      Hwei is 66.7% over 18 games, while Nocturne beside Hwei anywhere is 59.1% over
      71. Against the patch-16.15 file (26,698 matches, 119,760 pair rows) the change
      lifts qualifying pairings from 3,980 to 5,331 — about a third more evidence at
      the same floor, not a lowered bar.

    Changed

    • SynergyAllyRow no longer carries allyRole, and indexChampionSynergies
      emits one row per ally champion rather than per (champion, role). This also
      retires a latent duplicate-React-key case in PoolStrengthSection, which keys
      ally lines on the champion id.
    • MAX_ALLY_EVIDENCE_PER_ENTRY stays at 2, and is now what usually limits the
      rows on screen rather than the game floor: a full team of hovering allies
      routinely puts three or four pairings over the floor, and the two best-sampled
      win. A qualifying pairing can be computed and not shown — intended, and now
      documented at the constant.
    Downloads
  • v4.0.1 883cc34872

    v4.0.1
    All checks were successful
    CI / build-and-test (push) Successful in 1m32s
    Stable

    joeyr released this 2026-08-09 00:01:41 -05:00 | 2 commits to main since this release

    Changed

    • The test suite runs in about a third of the wall clock, with no coverage
      given up.
      Two independent causes, both pure waste:

      • Renderer interaction tests now share a setupUser() helper that passes
        delay: null to userEvent. The default delay: 0 awaits a real macrotask
        between every sub-event of an interaction (pointerover → pointerdown →
        mousedown → focus → pointerup → mouseup → click), so an interaction test's
        cost was set by timer scheduling rather than by the component. The same
        events fire in the same order with the same assertions available between
        them; only the waiting is gone. In isolation settings-panel.test.tsx went
        from 4191 ms to 937 ms.

      • Only 21 of the 86 unit test files touch the DOM, but all of them ran under
        jsdom. test/unit/renderer/** is now its own renderer project and the
        other 65 run in node. That cost was invisible in the per-file durations
        Vitest prints — about 1 ms there — because it lands in the run summary's
        environment total, which was 510 s on CI.

      Locally the full suite went from ~45 s to ~17 s, and environment from 264 s
      to 57 s. npm run test:unit runs both halves, so the split is invisible to
      the scripts and to CI. All 1054 tests still pass.

    Fixed

    • Two Vitest projects were handed the same define object reference. Vitest
      mutates it in place while resolving a project, so the second clobbered the
      first and __APP_VERSION__ survived as a bare identifier — every test that
      renders App died on ReferenceError. Each project now spreads its own copy.
      No user-facing effect; recorded because the failure is remote from its cause.
    Downloads
  • v4.0.0 3cf913eed7

    v4.0.0
    All checks were successful
    CI / build-and-test (push) Successful in 3m8s
    Stable

    joeyr released this 2026-08-08 22:54:17 -05:00 | 3 commits to main since this release

    Changed

    • The op.gg scrapers are gone, and with them every fallback in the data
      layer.
      Counters, tier badges and rune pages each used to consult the
      published artifact first and then scrape op.gg live for anything it could not
      answer. That second path is deleted. Every provider now reads one published
      artifact, and a question it cannot answer is reported as unanswered rather
      than filled in from somewhere else.

      The reasoning is that the two paths were never the same answer. The crawler
      fetches those op.gg pages overnight, at its own pace, under fixed filters; a
      mid-draft scrape fetched a different page at a different moment. A champ
      select could be served by both, and nothing on screen said which — so a
      defensive-ban suggestion, a tier badge, or the rune page the Import button
      wrote could depend on which source happened to answer first.

      What you will notice: on patch-drop morning the board shows no counters and
      no tier badges
      until the crawler publishes for the new patch. Those
      artifacts refuse an off-patch copy on purpose — a tier list is a claim about
      this patch's meta — and the scrape that had already rolled over is no longer
      there to cover the gap. A blank badge is visibly blank; last patch's meta
      rendered as this patch's is not.

      Caches are unaffected and are not fallbacks under this rule: every provider
      still serves its own last good copy when a fetch fails, and damage splits and
      overlay benchmarks still serve an off-patch copy, because a champion's kit
      survives a patch that did not touch it.

    Removed

    • Settings.statsSource and Settings.rateLimitPerSecond. One selected between
      op.gg adapters and the other paced their page fetches; with no scraper there
      is nothing to select and nothing to pace. Both keys are ignored if present in
      an existing settings.json and dropped the next time it is written — no
      action needed. Riot API traffic (the scouting report and the benchmark
      harvest) keeps its own rate limiter at 0.75 req/s.
    • The tier-list disk cache (tier-list-cache.json under %APPDATA%). It held
      scraped pages; the counters artifact keeps its own cache, and two files with
      independent TTLs disagreeing about which was stale is a bug waiting to happen.
      The stale file is simply left unread and can be deleted.

    Fixed

    • CountersArtifactAdapter.fetchChampionStats threw synchronously out of a
      method typed to return a promise, because Promise.resolve(lookup(req))
      evaluates the lookup first. Every caller happened to sit inside a try, so it
      never surfaced — but a failure that arrives as a throw rather than a rejection
      is a trap for the next one.
    Downloads
  • v3.19.0 61e960ba5f

    v3.19.0 - Your Pool This Patch
    All checks were successful
    CI / build-and-test (push) Successful in 2m6s
    Stable

    joeyr released this 2026-08-08 14:06:05 -05:00 | 6 commits to main since this release

    Needs a published-data API key. This section reads the same key as every other artifact — paste it in Settings if you have not already, or it stays on the no-data placeholder.

    Added

    • "Your Pool This Patch" — a third pick list, and the only one that can be on
      screen while you are still deciding.
      Suggested Picks cannot compute anything
      until the enemy laner locks, because Riot never exposes enemy hovers; that is
      most of champ select, and the section spends it showing "waiting for the enemy
      laner". The new list needs only your assigned role, so it fills that window.

      It ranks your configured pool by each champion's own measured win rate at your
      role this patch, from the champion-synergies.json artifact — Riot match data,
      a genuinely independent second opinion from the op.gg tier badge shown beside
      it. Every row carries the games behind it, and the section names the sample it
      came from (NA solo queue, Gold-Emerald), because those are one ladder's numbers
      and you may not be on it.

      Ranking is by a shrunk win rate — pulled toward 50% by games / (games + 400)
      — so 53% over 900 games outranks 56% over 60. The displayed rate stays raw; only
      the ordering is shrunk.

    • Ally pairings are shown as evidence, and deliberately do not rank anything.
      When a teammate hovers or locks, a row can carry a line like 54.7% with Thresh · 192 games for pairs with at least 50 games behind them.

      That restraint is the main design decision in this release, and it is measured
      rather than stylistic. The artifact's headline content is 100k+ ally pairing
      rows, and the obvious feature ranks picks by them. Against the published patch
      16.15 file, each pair's win rate was compared to an additive baseline (the
      champion's own rate plus the ally's own rate minus 50) and the spread of those
      residuals compared to the binomial noise expected if pairing did not matter at
      all — with the null corrected for a pair's games being a subset of both
      champions' totals, which otherwise widens the noise band and manufactures a
      signal:

      Games floor Pairs Observed SD Noise-only SD
      ≥ 20 9,948 8.65 8.65
      ≥ 50 1,828 5.76 5.82
      ≥ 100 200 3.80 4.23

      Zero excess variance at every floor; a 3-point pairing effect would have shown
      as 5.2 in the last row. The consequence is not that the ranking needed better
      tuning — a shrinkage estimator fitted to this data shrinks every pair back to
      its baseline, so the optimal ranker is the one that ignores the rows. Ranking
      on them would have sorted champ-select advice by coin flips, and the top of
      that list is visibly nonsense: a 35-game Lucian+Amumu pair at 77%. Even the ten
      best-sampled pairs in 9.5 MB — Lucian+Nami among them — have confidence
      intervals of ±5 to ±7 points around rates 1-4 points above baseline.

      So the pair line is a measurement with its sample attached, phrased
      symmetrically ("with Thresh", never "thanks to Thresh" — both champions won or
      lost the same games), and it never touches the sort. Live against production:
      Syndra shows the best pairings in a five-champion mid pool and ranks fourth,
      because its own record is 48.4%.

      This is a statement about sample size, not about the game. The producer samples
      ~21k matches per patch and does not carry them across a rollover; if that rises
      roughly 5-7x, re-run the residual test before assuming the finding holds.

    • champion-synergies.json is now consumed — eight of the ten published
      artifacts have a consumer here. It is much the largest at 9.5 MB, which the
      existing machinery absorbed better than expected: 450 KB on the wire (the API
      serves it brotli), ~1 ms to validate under the sampled posture where a strict
      whole-file parse of 103k rows would cost ~125 ms, and 9.2 MB retained — pruned
      to 0.3 MB, since the section reads about 2% of the pair rows.

      It refuses an off-patch copy, matching counters and opposite to damage and
      benchmarks: last patch's win rates are not a degraded answer to "how is this
      champion doing now", they answer a different question. On the morning of a
      patch, before the crawler has caught up, the section goes quiet rather than
      showing yesterday's meta as today's.

      Entries below 50 games are dropped outright rather than kept and filtered at
      the call site — the artifact publishes everything down to games: 1, so that
      judgement had to be made somewhere, and a caller must not be able to rank on a
      two-game record by forgetting a check.

    Downloads
  • v3.18.0 305227ba54

    v3.18.0 - Published artifacts move to the query API
    All checks were successful
    CI / build-and-test (push) Successful in 2m1s
    Stable

    joeyr released this 2026-08-08 10:08:49 -05:00 | 9 commits to main since this release

    Upgrading: this release needs a Published data key (Settings -> Published data key).
    Without one the app still runs, but every published-artifact fetch is refused and each
    feature serves whatever is already cached on disk.

    Changed

    • Published data now comes from the query API, not raw Forgejo URLs. All
      seven consumed artifacts are fetched from https://api.jrendar.org/v1/artifacts/{name}
      over TLS. Nothing about what they contain changed — same schemas, same caches,
      same patch policy. This is the transport and nothing else.

      It is not a cosmetic move. The producer's next phase deletes the raw-URL path,
      and an app still reading one would not have failed loudly: the fetch would
      404, the provider would fall back to its cache, and the board would keep
      working while serving data that got a night older every night. That
      silent-staleness failure is the reason this shipped ahead of the deletion
      rather than after it.

      The seven URL literals that used to be copied provider-to-provider are gone,
      collapsed into src/main/stats/artifact-source.ts — one base plus a name per
      artifact. The count had grown by one in each of the last two releases.

    • Artifacts are validated envelope-in-full, rows-once. Provenance and every
      small top-level dictionary (items, perks, styles, and the counters
      tierList the board's badges read) are strict-parsed on every fetch; one row
      of the big champion-keyed record goes through the real row schema; the rest
      are parsed and trusted.

      Strict whole-file parsing was the right posture while the app fetched a public
      URL it did not control. It is now reading bytes this household's own crawler
      generated, checksummed, and round-trip verified at publish time. But a
      checksum proves integrity, not shape agreement — a producer-side schema change
      publishes with a perfectly valid digest — so the sample stays, and it is what
      catches that. Measured against today's published files, validation cost drops
      from 184 ms to 19 ms across the five largest.

    • ChampionDamageProvider converged onto the shared artifact path. It had
      reimplemented fetch/validate/persist/fall-back by hand since before that path
      existed, which was harmless duplication right up until it meant being the one
      provider still pointed at a raw URL. It is now a thin indexing wrapper. Its
      on-disk cache format is unchanged, so nothing re-downloads.

    Added

    • A Published data key field in Settings. The API authenticates every route
      but health, so the app carries a per-install key — never one baked into the
      build, since an Electron asar unpacks in minutes and a shared key cannot be
      rotated without breaking every install that has not updated. Stored in
      settings.json encrypted at rest via safeStorage, alongside the Riot key
      and now through the same code path.

      Unlike the Riot key it applies immediately rather than on the next launch —
      the providers read it per fetch — because the state it fixes is a fresh
      install looking at an empty board.

      Without a key the app still runs. A 401 is treated as fatal-but-quiet: it
      takes the same branch an unreachable server already takes, so every provider
      serves its cached copy and champ select never sees an error. That makes an
      unconfigured install a supported but invisible state, so the panel shows the
      key status and startup logs artifact.no_api_key.

    Downloads
  • v3.17.0 031d0ba8e7

    v3.17.0 — Classic Rift tier badges
    All checks were successful
    CI / build-and-test (push) Successful in 1m51s
    Stable

    joeyr released this 2026-08-07 23:38:30 -05:00 | 10 commits to main since this release

    Fixed

    • Classic Rift shows tier badges, and guesses enemy lanes again. Both
      symptoms read the same per-role tier snapshot, and that snapshot was built
      from champion-counters.json unconditionally — a file keyed by live champion
      ids. Every 60xxx lookup missed, so classic cells rendered no badge and every
      locked enemy fell into the board's "Unassigned" group until the user picked a
      lane by hand.

      champion-counters-classic.json has carried a complete tierList since data
      v0.11.0 and the app has been downloading it since 3.14.0 for counters. It is
      now read for tiers too: 90 champion-roles across all five lanes, keyed by the
      same 60xxx ids champ select already hands us.

    Changed

    • One tier snapshot holds both rosters, and the board needs no mode signal.
      Riot allocated the Classic Rift roster at 60000 + base id, so a classic id
      can never equal a live one and a single map can carry both without a
      collision. Every renderer consumer of that map does an exact-id lookup — the
      badge reads its own cell's championId, the lane inference reads each
      enemy's — so a classic cell finds classic rows and a live cell finds live ones
      out of the same map, with no map-id plumbing reaching the renderer at all.

      The provider still keeps the two apart internally. getAll() answers for the
      live roster only, because its consumer — the Suggested Picks candidate pool —
      walks a role's entire list and offers everything inside the user's meta
      filter, where a merged map would suggest Jade Annie in a ranked game.

    • The classic counters fetch is awaited before the tier snapshot is built.
      It was fire-and-forget, which lost the race it needed to win: the tier refresh
      reads the artifact synchronously and has nothing to fall back on when it is
      missing. The file may still legitimately 404 — the producer's classic pass is
      opt-in and the mode is limited-time — so a rejection is caught rather than
      allowed to skip the publish, and there is deliberately no scrape behind it.
      buildOpggTierListUrl takes no queue parameter, and ranked rows served as
      classic would be the confident wrong answer rather than a missing one.

    Downloads
  • v3.16.0 962ec3193c

    v3.16.0 — Classic Rift item builds
    All checks were successful
    CI / build-and-test (push) Successful in 1m55s
    Stable

    joeyr released this 2026-08-07 23:11:03 -05:00 | 11 commits to main since this release

    Fixed

    • Classic Rift shows item builds again. Deleting the op.gg items scraper in
      3.15.0 took classic's item list with it: every request routed to
      champion-builds.json, which is keyed by live ids and built from queue 420
      only, so a classic lock-in asked for champion 60051, found no key, and
      rendered "No item builds for this champion and role in the latest data."

      The list now comes from champion-items-classic.json, which the sibling
      crawler has been publishing nightly since data v0.9.0 — 88 champion-roles and
      1,132 builds, already laid out in the four sections the panel renders.

    Added

    • A second item artifact, routed on the champion id block. Ranked and
      classic cannot share one file. champion-builds.json is counted from Riot's
      own match timelines and Riot's match-v5 does not cover Classic Rift, so op.gg's
      aggregation is the only measurement that exists for the mode. They cannot share
      champion ids either — op.gg keys classic pages by BASE id, so id 24 exists in
      both naming two different kits, and a classic Jax resolved against the ranked
      file would return ranked Jax's build: correctly shaped, plausibly ranged, and
      confidently wrong. ItemOptionsProvider routes on the 60xxx block and never
      merges the two lists.

      The file may legitimately 404 — the producer's classic pass is opt-in and the
      mode is limited-time — and a miss is an empty panel, since the scraper this
      replaced is gone.

    • Builds op.gg recommends are shown as recommendations, not as zeroes. op.gg
      pads every classic page with editorial item orders carrying no play, win or
      pick figures — 557 of the 1,132 published rows, and a typical champion-role has
      one measured core against two recommended ones. The old scraper dropped every
      row without statistics, which is most of what these pages contain and why the
      panel was close to empty even before it was deleted.

      ItemBuildOption is now a discriminated union on basis, with the stat fields
      absent rather than zeroed on a recommended row. A zero would sort and filter
      alongside real rows and read as "measured, nobody played it" — a stronger claim
      than op.gg is making. The card shows "Recommended by op.gg" where it shows the
      stat line, and the type is what enforces it: nothing can read winRate without
      first narrowing on basis.

    Changed

    • Classic core sequences carry their boots inside the order, unlike ranked
      ones — op.gg publishes them that way — so coreItemIds now accepts up to six
      items and an item may appear in both the Core and Boots strips. The strips
      carry no rates, so nothing is double-counted.
    • Classic item rows carry their names inline and no icons, so the panel renders
      text nameplates there. Adding icons is a producer-side follow-up.
    • Nothing about the write path changed: classic builds remain display-only, with
      the Import buttons hidden in the renderer and the write refused at the IPC
      boundary on the same 60xxx id block.
    Downloads
  • v3.15.0 57c216117c

    v3.15.0 Stable

    joeyr released this 2026-08-04 22:44:22 -05:00 | 18 commits to main since this release

    Changed

    Item builds now come from Riot match data instead of a live op.gg scrape.

    Locking in a champion no longer fetches an op.gg page. The list is read out of champion-builds.json, which the sibling crawler builds nightly by replaying Riot match timelines and counting what players actually bought.

    What that takes out of champ select: a page fetch through the shared rate limiter against a draft timer, a 10-second timeout, and a scraper that balanced-bracket scanned a JSON literal out of op.gg's RSC payload and broke whenever they redeployed their frontend. adapters/op-gg-items.ts is deleted.

    No fallback behind it, unlike every other artifact

    Runes and counters short-circuit a scraper that measures the same op.gg numbers, so a miss there costs only latency. Items are not comparable: op.gg published its aggregation of its sample, this counts Riot's timelines. Keeping both would mean the Import button sometimes writing a build measured over a different population than the win rate displayed next to it.

    So the scraper is gone rather than demoted. ItemOptionsProvider is a synchronous map lookup with no rate limiter, timeout or failure path; the item-options message goes straight to ready; and every miss — no artifact yet, off-patch, champion unseen at that role — collapses to an empty list with one line explaining it, because none of them is worth retrying at the user's expense.

    Three samples, one item set

    The four blocks an import writes are drawn from measurements of different sizes: starter items and the core sequence from the sampled timelines, boots from every recorded match, and situational from order.late — the items measured as bought after the core, which is the field added upstream in v0.21.0 for exactly this.

    Only the core row's win rate, pick rate and games reach the card, and all three are measured over the same sample.

    Rows are no longer re-sorted or floored. op.gg needed a 50-game floor because its page mixed in rows it had surfaced by win rate, where a three-game Mejai's build could outrank a real one. These rows are ranked by how often the build was actually assembled.

    Names come from the artifact

    Item names and icons are published alongside the ids, pinned to the exact Data Dragon version those ids were classified under (lol-companion-data v0.22.0). The bundled extract in src/shared/item-costs/ remains the overlay's gold table — resolving names against it would let them drift from the ids they describe every time Riot renames an item.

    Fixed

    • The Item Import section said nothing at all when there were no builds to show. It now explains why, and no longer offers a spinner for a lookup that cannot take any time.

    Gate green: typecheck, lint, 966 tests across 96 files, build.

    Downloads
  • v3.14.1 9bb2917429

    v3.14.1
    All checks were successful
    CI / build-and-test (push) Successful in 2m25s
    Stable

    joeyr released this 2026-08-03 15:31:31 -05:00 | 20 commits to main since this release

    Fixed

    • "Follow" stopped auto-scrolling the log panels once their buffer filled up.
      On the Diagnostic event stream that is seconds after the League client
      connects — the LCU firehose logged 1,422 events in 20 seconds in the session
      that surfaced this — so in practice Follow worked briefly at startup and then
      did nothing for the rest of the session. Unchecking and re-checking it, or
      scrolling back to the bottom, did not revive it.

      useFollowScroll re-pinned to the bottom in a layout effect keyed on the
      visible row count. Every panel's buffer is capped (MAX_DIAG_ENTRIES,
      MAX_APP_LOG_ENTRIES, MAX_LOG_ENTRIES), so once full, each new record evicts
      a row from the top and appends one at the bottom and the count is pinned at the
      cap forever. React saw unchanged deps and skipped the effect from then on.

      The effect is now keyed on rowsKey(rows) — row count and newest row id.
      Both halves matter: the id catches an append at the cap, the count catches a
      filter that drops older rows while leaving the newest one in place.

    • The log lists set overflow-anchor: none. They evict from the top and their
      rows are not a fixed height (long URIs wrap), so Chromium's scroll anchoring
      adjusted scrollTop to hold the anchored row still — walking the view off the
      bottom and firing scroll events that raced the re-pin, which was enough to
      uncheck Follow on its own.

    • The Status log was keyed the same way and had the same latent bug at its own
      200-entry cap; it is fixed alongside.

    Changed

    • undici 8.5.0 → 8.10.0 (lockfile only, the ^8.5.0 range is unchanged) for
      GHSA-4cwx-7wf7-3272, a HIGH advisory on cross-user information disclosure via
      degenerate private cache directives, plus four moderate advisories in the same
      range. The CI gate's npm audit --omit=dev --audit-level=high step fails
      without it.

    Notes

    • Read-only against the League Client (LCU) — never writes to the client.
    Downloads
  • v3.14.0 3c34b2f0cf

    v3.14.0 — log panel shows line context
    All checks were successful
    CI / build-and-test (push) Successful in 2m27s
    Stable

    joeyr released this 2026-08-03 13:38:30 -05:00 | 21 commits to main since this release

    The application log panel was showing you less than the log file it mirrors.
    This release closes that gap.

    What was wrong

    Every log call in this app already attaches structured context — which champion,
    which role, which attempt, which error. forwardStream then parsed each pino
    line and rebuilt it as a fixed four-field record:

    const record: AppLogRecord = {
      at, level, msg, event,   // <- everything else dropped here
    };
    

    So a failed rune fetch wrote championId and role to debug.log, and showed
    the panel Rune options fetch failed and nothing more. The panel was strictly
    worse than the file for the same line — which is backwards, because the panel is
    the log view you can actually reach while a game is running.

    The block now

    18:42:07 WARN  rune_options.fetch_failed Rune options fetch failed  championId=63 role=mid err=Timeout: op.gg did not answer in 8000ms
    18:42:09 ERROR window.renderer_gone      Renderer "companion" exited (crashed)  window=companion reason=crashed exitCode=133
    

    Trailing key=value chips, dimmed so a row still reads message-first. The
    filter box matches them too, so championId=63 narrows to one champion's lines.

    Design

    • An open bag, not named fields. Context arrives as whatever the call site
      attached, minus the four keys the panel renders in their own columns. A new
      field therefore reaches the UI without touching the logger, the IPC contract,
      the store and the component — which is what the old shape charged for it.
    • One line high, always. Scalars print bare; objects and arrays collapse to
      compact JSON. The full record is in debug.log — the panel is for triage, so
      a row that stays scannable beats a row that is complete.
    • err gets its own treatment. Pino's {type, message, stack} renders as
      err=Timeout: op.gg did not answer, with the stack on hover. Inlining a stack
      in a scrolling list would bury the twenty lines around it.

    Errors now keep their stacks

    52 call sites across 26 files logged err instanceof Error ? err.message : String(err) — the stack discarded before it ever reached the log. They pass the
    Error itself now; pino serialises it, so no logger configuration changed.

    Being straight about the value: 44 of those 52 are warns on expected failure
    paths — a fetch that failed, an LCU frame that would not parse — where the
    message is the diagnosis and the stack points into Node internals. This
    matters for the handful of broad catch blocks, where "Cannot read properties
    of undefined" previously named no location at all. It also stops a non-Error
    throw stringifying to [object Object]; pino passes such values through whole.


    Minor rather than patch for the new panel output. No settings, no LCU writes and
    no data-source changes; nothing to reconfigure after updating.

    Downloads