Skip to content

feat(hooks): log per-handler dispatch timings - #998

Open
CarlosWonMore wants to merge 1 commit into
Tencent:mainfrom
CarlosWonMore:feat/hook-dispatch-timings
Open

CarlosWonMore wants to merge 1 commit into
Tencent:mainfrom
CarlosWonMore:feat/hook-dispatch-timings

Conversation

@CarlosWonMore

Copy link
Copy Markdown
Contributor

Summary

dispatch() runs its matched handlers through Promise.allSettled, so the dispatcher's wall-clock is the slowest handler, not the sum — but nothing recorded which handler that was. A slow hook only says exceeded timeout of 4500ms when a handler overruns, and says nothing at all when several handlers merely add up under the host's cap. That makes the interesting case invisible.

This logs each handler's wall-clock, slowest first, at debug level.

[hook-dispatch] prompt-submit/workbuddy foreground: 2414ms total, 5 handlers, slowest pending-hint=2414ms | pending-hint=2414ms(ok) dashboard-report=2201ms(ok) local-agent-sync=54ms(ok) package-pending-hint=22ms(ok) track-slash=10ms(ok)

Observation only — withTimeout still enforces each handler's own timeoutMs budget, the caller's ordering is untouched, and a rejecting handler still propagates its error unchanged.

Why it matters

A real Windows UserPromptSubmit was spending ~4.7s against the host's 10s limit with no way to attribute it. The timings show it is not handler work:

handler wall-clock
pending-hint 2001–2597 ms
dashboard-report 1774–2350 ms
local-agent-sync 44–64 ms
package-pending-hint 16–28 ms
track-slash 9–13 ms

Both slow handlers import() the same dashboard-collector.js graph. A control run with a cwd that does not exist drops pending-hint from 2061 ms to 32 ms — it returns before reaching that import — while dashboard-report, which goes on to resolve the scope, is unchanged. A dispatch with a single handler costs ~51 ms. So the bulk of a dispatch is loading modules the handler then never uses, which no timeout message can show.

Naming slowest next to total makes that split legible without arithmetic: total is the floor any one handler imposes, slowest is what it spent on its own work.

Note this is not a sum of handler times — Promise.allSettled already prevents that, so a single slow handler and several slow handlers look the same from the outside. The gap between total and slowest is time spent before the handler's own work began.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature causing existing behavior to change)
  • Documentation only
  • Refactor / internal cleanup

Test Plan

  • npx tsc --noEmit — passes
  • npm run lint — 0 warnings, 0 errors on 811 files. (--type-aware/tsgolint could not run in my sandbox: its subprocess fails to spawn with os error 231, an environment limit rather than a code signal.)
  • Real-CLI verification against a live ~/.teamai/debug.log, on prompt-submit, post-tool-use (foreground and background) and session-start; hook output is byte-identical with and without the change.
  • npx vitest run — not a usable gate in my environment. hook-dispatch-cli.test.ts and hook-dispatch-scope.test.ts fail identically with and without this change (spawnSync git EBUSY on Windows, plus 15s test timeouts). I verified the baseline by stashing the change and re-running: same failure counts (4 failed / 18 passed and 7 failed respectively). builtin-hooks.test.ts passes fully.

Implementation notes

  • Timings are collected in .then() wrappers around the existing withTimeout(...) promise, so nothing about the timeout or the concurrency changes.
  • A rejection is recorded and rethrown, so settled sees exactly what it saw before.
  • The outcome label is ok / failed / timeout rather than the raw error message: the message already reaches the log via hook-dispatch-cli's existing handler "X" failed: line, and keeping it out avoids duplicating content that can carry paths or URLs. timeout is separated out because that is the case the existing line cannot explain.
  • Debug level only — one line per dispatch, no cost when the level is raised.

`dispatch()` runs its matched handlers through `Promise.allSettled`, so the
dispatcher's wall-clock is the slowest handler rather than the sum of them —
but nothing recorded which handler that was. A hook that takes seconds only
says `exceeded timeout of 4500ms` when a handler overruns, and says nothing at
all when several merely add up under the host's cap. On Windows a
`UserPromptSubmit` was spending ~4.7s against the host's 10s limit with no way
to attribute it.

Log each handler's wall-clock, slowest first, at debug level.

The timings are observation only: `withTimeout` still enforces each handler's
own `timeoutMs` budget, and the caller's ordering is untouched. A handler that
rejects is recorded and the error still propagates to the caller unchanged.

On a Windows run this attributes the time to module loading rather than to any
handler's own work — `pending-hint` and `dashboard-report` each spend ~2.0-2.6s
and ~1.8-2.2s, while `local-agent-sync`, `package-pending-hint` and
`track-slash` stay at 9-64ms. Both slow handlers `import()` the same
`dashboard-collector.js` graph, and their cost disappears when a handler exits
before reaching that import. The single-handler case is ~51ms, so the bulk of
the dispatch is loading modules the handler then never uses.

Naming the slowest handler separately makes that split legible without
arithmetic: `total` is the floor any one handler imposes, and `slowest` is how
much of it that handler actually spent on its own work.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant