Skip to content

feat(perf): opt-in performance diagnostics with turn timelines and an environment probe - #408

Open
pjdoland wants to merge 8 commits into
plmbr:mainfrom
pjdoland:feat/perf-diagnostics
Open

feat(perf): opt-in performance diagnostics with turn timelines and an environment probe#408
pjdoland wants to merge 8 commits into
plmbr:mainfrom
pjdoland:feat/perf-diagnostics

Conversation

@pjdoland

@pjdoland pjdoland commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

Enterprise deployments running NBI in constrained environments (an internal Claude endpoint, ~/.claude and the Jupyter home on mounted EFS, TLS-intercepting proxies) see much worse latency than desktop installs, with no way to tell whether the time goes to the model gateway, subprocess startup, filesystem I/O, tool execution, or NBI itself. This adds an opt-in performance diagnostics mode that answers "where did this turn's time go," plus an on-demand environment probe that measures the usual suspects directly. Off by default; when off, the cost is one boolean check per instrumentation site.

Solution

Three pieces, all gated behind a perf_diagnostics config section following the repo's policy conventions (value lock NBI_PERF_DIAGNOSTICS, policy triad with force-on for fleet-wide baselining, and a separate NBI_PERF_PROBE_NETWORK policy for just the probe's network leg):

Turn timeline. Every chat turn records a span tree (context prep, connect/spawn with a cold flag, first token, stream with stall events tagged by what preceded them, per-tool spans, time waiting on the user) plus token counts and the SDK-reported duration_api_ms. Wall total vs api_ms is the single best gateway-vs-local discriminator and comes free from the ResultMessage. Turns are keyed by message id in a locked registry because a turn crosses three threads that share no contextvars context; close is guaranteed in the request thread's finally, and cancelled turns are recorded as cancelled rather than as fast successes. The report is served at GET /notebook-intelligence/perf/report, a per-turn INFO summary line goes to the server log (headless installs get the signal with zero UI), and turns can optionally append to a JSONL sink that batches, tolerates failures, self-disables rather than blocking on a slow filesystem, and writes 0700/0600.

Environment probe. POST /notebook-intelligence/perf/probe runs filesystem latency/throughput (with fs type and mount options, cold-vs-warm first iteration), node-vs-CLI cold start, and contention checks on abandonable worker threads (a hung NFS stat is uninterruptible; the pool is recreated per run). The network leg is opt-in per run behind a confirmation showing the exact URL, is proxy-aware, sends one request to the configured base URL only, and captures the presented TLS chain, which makes interception visible as the issuer CN. Output is scrubbed (home paths incl. resolved real home, usernames, no hostname fields).

Surfacing. A Performance tab in NBI Settings with the controls (policy- and lock-aware), a recent-turns table, the probe, and copy-as-JSON for support tickets. The table carries a Verdict column naming the phase that dominated each turn (model or gateway, tool execution, agent cold start, context preparation, mid-stream stalls), derived from the recorded columns rather than from any new field, and rows expand into that turn's spans as proportional bars followed by its events. Probe checks are grouped and banded, with a plain-language note on anything that trips a threshold (network filesystem, EFS burst-credit exhaustion, cgroup throttling, TLS interception, clock skew); the raw JSON stays available behind a toggle. Every column carries a tooltip, and the docs the interpretation came from now live in docs/performance-diagnostics.md rather than in one paragraph of the admin guide.

Privacy is enforced structurally: the recorder schema-checks every attribute against a fixed allowlist, and the default redacted mode hashes file basenames and model/tool/server names, including tool names embedded in span names. Never recorded: prompt/response text, absolute paths, env values, exception messages, hostnames.

Testing

  • 98 new backend tests (test_perf.py 48, test_perf_instrumentation.py 32, test_perf_probe.py 23) covering the disabled fast path, concurrent turns, span caps, redaction, ring/sink behavior, the finally-close and cancelled-status paths, REST gating, probe timeouts and scrubbing, macOS firmlink mount resolution, mount-option redaction, disabling mid-turn, sink robustness against an unserializable document, and TLS interception detected against a self-signed server
  • 24 jest tests for the tab, covering the verdicts, row expansion and span nesting, the empty states, the probe banding, the interception rendering, and the policy-lock path; suite totals: pytest 1470, jest 405, tsc/eslint/stylelint/prettier clean
  • Live verification in a running JupyterLab, twice: the runtime toggle flips 404 to 200 without a restart, the probe runs and renders, and real chat turns record sane semantics (cache-inclusive token counts matching the usage footer, api time above wall time producing the "model or gateway" verdict, first token measured from the first content chunk rather than the progress spinner)

Review provenance

The plan was reviewed pre-implementation by three expert personas (observability architect, JupyterLab extension architect, enterprise bank platform engineer); their MUST items are all reflected above. The implementation then went through two xhigh-effort multi-angle reviews plus a combined concurrency/security pass. The second review is why the network check can detect TLS interception at all: the timing leg used a verifying context, so an untrusted certificate failed that handshake and no certificate was ever captured, and verified_against_default_bundle: false was unreachable for any input. Alongside it: a turn-registry leak when diagnostics are disabled mid-turn, a JSONL writer thread that a single unserializable document could kill while its queue kept growing, mount options carrying CIFS addresses and credentials into a document promising neither, the gateway host embedded in the exportable report, a byte counter counting characters, and a per-chunk global lock on the streaming path. Earlier findings (a probe request-body contract break, a Windows-breaking import resource, a settings-clobbering capabilities gap, first-token misattribution, MCP double-counting, redaction gaps, JSONL permissions, TLS-verification and probe-budget bugs, and several races) were all fixed and are covered by the tests above.

Risks / follow-ups

  • OTLP export was deliberately deferred: the span model is OTel-shaped, and a future [otel] extra will honor standard OTEL_EXPORTER_OTLP_* env vars; no config field ships for it now
  • Inline-completion and GenerateCode turns are explicitly out of scope for v1; the recorder API is turn-generic so they can be added next
  • Known minor items documented in code comments rather than fixed: the JSONL retention cap is enforced at file rollover, a self-disabled sink stays disabled until reconfigured, and the single-open-turn guard for Claude in-process tools is best-effort under concurrent turns

Enterprise deployments (internal LLM gateways, EFS-mounted homes,
intercepting proxies) see much worse latency than desktop installs with
no way to tell where a turn's time goes. This adds the foundation: a
turn recorder and an on-demand environment probe, both inert until
enabled.

The recorder keys turns by message id in a locked registry rather than
a ContextVar because a turn crosses three threads that share no
contextvars context (tornado loop, per-request asyncio.run thread, SDK
client thread). Spans carry a fixed name vocabulary and a schema-checked
attribute allowlist enforced in the recorder itself, so the privacy
guarantee cannot erode when a later change passes a new kwarg. In the
default redacted mode, file basenames and model/tool/server names are
hashed, including external tool names embedded in span names. Disabled
cost is one boolean test per call. Finished turns land in a ring buffer
and, optionally, a JSONL sink on a dedicated writer thread that batches,
tolerates write failures, self-disables rather than blocking on the slow
filesystem it may be diagnosing, and creates its directory and files
0700/0600.

The probe measures the usual suspects directly: per-directory stat,
read, and write+fsync latency with cold-vs-warm first iteration and a
sustained-throughput pass, filesystem type and mount options, node vs
CLI cold-start cost, contention signals, and an opt-in network leg that
is proxy-aware, sends one request to the configured base URL only,
captures the presented TLS chain (interception shows up as the issuer),
and reports unauthenticated TTFB with an honest caption. Checks run on
abandonable worker threads because a hung NFS stat is uninterruptible;
the pool is recreated per run so abandoned workers cannot poison later
probes. Output is scrubbed of home paths (including the resolved real
home), usernames, and hostname fields.

Config follows the repo's policy conventions: a perf_diagnostics
section, NBI_PERF_DIAGNOSTICS value lock, a policy triad with force-on
for fleet-wide baselining (which pins redacted mode), and a separate
NBI_PERF_PROBE_NETWORK policy so admins can keep the probe while
disabling its network leg.
Wires the recorder through the turn lifecycle in all three modes. The
turn opens at websocket ingress (ChatRequest branch only), covers the
real per-turn prep work in a context_prep span, and is force-closed in
the request thread's finally so cancellation and errors cannot leak it;
a guard around the pre-thread stretch closes the turn as errored if a
malformed request raises before the worker starts, since one leaked
turn would otherwise disable the single-open-turn tool guard for the
process lifetime. Cancelled turns are recorded as cancelled, not as
fast successes that poison the percentiles.

first_token is an event recorded on the first content-bearing chunk;
the locally generated "Thinking" spinner previously triggered it and
made time-to-first-token read ~0 on every turn, exactly in the slow
deployments the feature targets, while pulling the connect phase inside
the stream span. Stall events start from the first SDK message rather
than the loop entry (the gap before the first message is TTFT, not a
stall) and are tagged with the preceding message kind so a gap after
tool use is distinguishable from a gateway stall. Token counts sum
cache reads and writes the same way the usage footer does, so the two
surfaces agree on cached turns.

Tool timing uses the three real hook points: the Copilot/base dispatch
loop, the Claude in-process tool wrappers, and the ACP session-update
events (those tools run in a separate process). MCP calls annotate the
enclosing dispatch span with the server name instead of nesting a
duplicate, which had double-counted every MCP call in the aggregates.

The report endpoint serves the ring snapshot plus a probe_target
reduced to scheme and host (never netloc, which preserves embedded
credentials); the probe endpoint accepts only the network boolean and
enforces the NBI_PERF_PROBE_NETWORK policy server-side. Both are
authenticated and 404 while diagnostics is disabled. Each closed turn
also logs one INFO summary line with the per-phase breakdown so
headless installs get the signal from the server log alone.
A new tab in NBI Settings hosting the diagnostics controls (enabled,
log to file, attribute detail), a last-N-turns table, and the probe.
Controls respect both the settingLocks value-locks and the
perf_diagnostics policy, so force-on and force-off render as locked
rather than as a toggle that silently reverts. The first-token column
reads the event mark the backend actually records instead of a span
that never exists. The network check is behind an explicit inline
confirmation that shows the exact URL the probe will contact, taken
from the report's probe_target. Report and probe output can be copied
as JSON for support tickets.

The capabilities payload now carries the perf_diagnostics section;
without it the panel rendered defaults and a save clobbered stored
settings.
README gets the user-facing section: what a turn records, what is never
recorded, the probe, the env vars. The admin guide covers the operator
knobs: the policy triad including fleet-wide force-on, redirecting the
JSONL log off network homes with NBI_PERF_LOG_DIR, how to read
total-vs-api and the EFS signatures, the probe's SOC-relevant behavior,
and the new endpoints in the HTTP API table.
@pjdoland pjdoland added the enhancement New feature or request label Aug 24, 2026
…anel

Follow-ups from the post-implementation review pass on the performance
diagnostics feature.

Recorder:
- run the whole compare-dir/teardown/reassign/recreate sequence in
  configure() as one critical section under _sink_lock, so a concurrent
  TurnHandle.close() cannot recreate a sink pinned to the old log dir
- recreate a self-disabled sink on the next configure(), so a settings
  save retries the sink after an operator fixes the filesystem
- add set_current_span_attr() for callees that only annotate one attr

Instrumentation:
- exclude Progress chunks from every stream perf signal (span open,
  first_token, chunk and byte counters), not just the span open
- stop running asdict + json.dumps on every stream chunk purely to count
  bytes; estimate from the content field and write the final counts once
  in finish()
- attribute in-process tool spans via get_current_response() first, so
  concurrent turns do not fall back to the process-wide single-open-turn
  guess
- collapse input_tokens to None only when no usage token key is present,
  so a payload that legitimately reports zero still records 0
- apply the perf_diagnostics value lock on the config POST write path, so
  a scripted POST cannot persist enabled:true under NBI_PERF_DIAGNOSTICS
- log perf turn close failures at warning, not debug

Probe:
- submit every independent check before blocking on any result, so wall
  time is roughly the slowest check rather than the sum of the timeouts
- give the claude_home session scan an outer budget above its own internal
  bound, so a scan that runs to that bound is not abandoned with its
  samples discarded
- return partial results plus tls_error on a sub-timeout TLS handshake
  failure instead of discarding the dns/tcp timings
- close the raw socket when the handshake never produced an SSLSocket
- report process RSS as max_rss_kb, which is what ru_maxrss actually is
- skip, rather than error, when the resource module is unavailable
- fall back to a top-level base_url for openai-compatible providers

Panel:
- read the perf_diagnostics lock from the feature policy entry instead of
  comparing a policy object against policy-name strings, which never
  matched; register perf_diagnostics in the api.ts policy name list
- gate the network checkbox on perf_probe_network_allowed
- exclude probe_target from the copied report JSON
- correct IPerfTurn.t_wall to a number and add the skipped check status

Docs: describe the probe's network leg as the several connections it
actually makes, and note that only the HTTP legs carry the probe
User-Agent.
Second review pass on the performance diagnostics feature.

- The stream byte estimator read a flat "content" key, which no chunk
  actually has: raw LLM chunks are OpenAI-shaped, so the stream span's
  "bytes" attribute and the egress event's byte count both read exactly 0
  for the whole of Copilot chat mode. Read through
  choices[0].delta.content, and make the test fixtures use the shape the
  providers really emit instead of a synthetic flat dict.
- The probe submitted every check up front onto a shared pool, so the
  filesystem latency loop timed individual stat and fsync operations while
  the sustained-IO check was writing a 4 MB file into the same directory.
  That makes the probe measure its own contention, and it is worst on the
  network-mounted homes the probe exists to diagnose. Run the filesystem,
  subprocess, and contention groups strictly one at a time, and overlap
  only the network check, which touches neither disk nor CPU and is the
  long pole anyway.
- TurnHandle.close reads _log_to_file outside _sink_lock, so a configure()
  that turns file logging off could land between that read and the sink
  lookup, and the close would then build a fresh sink and leave a live
  writer thread behind. Re-check _log_to_file inside the locked factory.
- The in-process tool span comment claimed the current-response lookup
  disambiguates concurrent sessions. It does not: _current_response is a
  module global, not a contextvar. What it does provide is the query
  actually running on the client thread, which is what makes it better
  than giving up whenever a second turn merely sits open. Say that.
@pjdoland

Copy link
Copy Markdown
Collaborator Author

Review pass on my own branch, posted as a comment since GitHub does not let an author formally review their own PR. Two remediation commits are pushed (83e8afd, abca3bf); the branch is green at pytest 1454, jest 392, and tsc/eslint/stylelint/prettier clean.

Real defects found and fixed

  • The stream byte estimator read a flat content key, which no chunk actually has. Raw LLM chunks are OpenAI-shaped (choices[0].delta.content), so the stream span's bytes attribute and the egress event's byte count both read exactly 0 for the whole of Copilot chat mode. Fixed, and the test fixtures now use the shape the providers really emit rather than a synthetic flat dict, which is what let the bug through in the first place.
  • The probe was submitting every check up front onto a shared pool, so the filesystem latency loop was timing individual stat and fsync operations while the sustained-IO check wrote a 4 MB file into the same directory. That makes the probe measure its own contention, and it is worst on exactly the network-mounted homes the probe exists to diagnose. The filesystem, subprocess, and contention groups now run strictly one at a time; only the network check overlaps, since it touches neither disk nor CPU and is the long pole anyway.
  • TurnHandle.close reads _log_to_file outside _sink_lock, so a configure() that turned file logging off could land between that read and the sink lookup, and the close would then construct a fresh sink and leave a live writer thread behind. The locked factory now re-checks. Regression test added.
  • The panel was comparing a feature-policy object against policy-name strings (perfPolicy === 'force-on'), so admin locking never engaged. perf_diagnostics is now registered in FeaturePolicyName per the checklist in extension.py, and the panel reads locked off the typed getter.
  • The config POST path did not apply the perf_diagnostics value lock, so a scripted POST could persist enabled: true under NBI_PERF_DIAGNOSTICS. It now mirrors the read path's lock.
  • Probe fixes: partial results plus tls_error are returned on a sub-timeout TLS handshake failure instead of discarding the DNS and TCP timings; the raw socket is closed when the handshake never produced an SSLSocket; the claude_home session scan gets an outer budget above its own internal bound so a scan that runs to that bound is not abandoned with its samples thrown away; process RSS is reported as max_rss_kb, which is what ru_maxrss actually is.
  • Instrumentation: Progress chunks are now excluded from every stream signal rather than only from the span open; asdict + json.dumps no longer runs on every chunk of the hot path purely to count bytes; in-process tool spans attribute via the running query rather than a process-wide single-open-turn guess; input_tokens collapses to None only when no usage token key is present, so a payload that legitimately reports zero still records 0.

Documentation corrections. The README and admin guide described the probe's network leg as "exactly one request". It is actually several connections to the single configured host: a raw connection to time DNS/TCP/TLS, a second to verify the certificate against the default trust store, and an HTTP HEAD with a GET retry on 405. Only the HTTP legs carry the nbi-perf-probe/<version> User-Agent, so a User-Agent allowlist rule alone would not have covered it. Both documents now say so, since an operator writing a firewall rule from the old wording would have gotten it wrong.

Known and deliberate. The in-process tool span lookup uses _current_response, which is a module global rather than a contextvar. It correctly names the query running on the client thread, which is what makes it better than giving up whenever a second turn merely sits open in the registry, but it does not disambiguate two client threads. There is one client per participant today; the comment now says exactly that instead of overclaiming. The JSONL retention cap is still enforced at file rollover, and the single-open-turn fallback remains best-effort, both noted in code.

The recorder and probe collected the right data, but the panel showed
twelve numeric columns and a raw JSON dump, and the knowledge needed to
interpret either lived in one paragraph of the admin guide. This adds the
part between "the data exists" and "the user reaches a conclusion".

Turns table:
- a Verdict column naming the phase that dominated the turn (model or
  gateway, tool execution, agent cold start, context preparation,
  mid-stream stalls), derived from the columns already present rather
  than from any new recorded field
- Time and Stalls columns, both from data that was already transmitted
  and dropped on the floor
- a tooltip on every column, including what Active excludes and why
  Active against API ms is the comparison that matters
- expandable rows showing that turn's spans as proportional bars with
  their attributes, then its events with offsets, and a warning when the
  per-turn caps truncated the record
- an empty state that says to run a turn, instead of an empty table

Probe:
- checks are grouped and banded rather than dumped as JSON, with check
  ids translated into what they measure and a plain-language note on
  anything that trips a threshold (network filesystem, EFS burst-credit
  exhaustion, cgroup throttling, TLS interception, clock skew)
- skipped and timed-out checks read differently from healthy ones
- the raw JSON is still there, behind a toggle, since that is what gets
  pasted into a ticket
- sub-millisecond latencies keep two decimals instead of rounding to
  "0 ms", which read as "not measured" rather than "fast"

Two probe bugs the new rendering made visible:
- macOS firmlinks: /Users is presented at /Users but lives on the Data
  volume, and Path.resolve does not follow the firmlink, so the mount
  match landed on "/" and reported every home directory as sealed
  read-only apfs. Re-match through /System/Volumes/Data when that mount
  exists.
- the panel labelled the config-directory checks "config" while the probe
  emits "nbi_user_dir", so that group rendered with a raw id.

Model name:
- the Claude connect span now carries the model, which is the only place
  the turn header can learn it. Before this the Model column was empty on
  every Claude turn.
- TurnHandle captures it through the same redaction the span attr gets.
  It was being stored in the clear while the span copy was hashed, which
  disagreed with itself and put the model name into reports and the JSONL
  sink that document it as hashed.

Copy: a one-paragraph description at the top of each section, and an
explanation of what redacted and full actually trade off, at the point
where that choice is made.

Docs: new docs/performance-diagnostics.md with the column and verdict
reference, the span and event vocabulary, the probe thresholds, worked
diagnoses for a slow gateway, a slow home directory, and an intercepting
proxy, the JSONL schema, and what to attach to a support ticket. The
README section is restructured around that and the admin guide keeps only
the operator-owned knobs, pointing at the new document for the rest.
…panel

Findings from a full-PR review pass, plus one the live check turned up.

Probe, TLS. The network check could not detect interception, which is its
headline capability. The timing leg used a verifying context, so an
untrusted certificate failed that handshake and the function returned
early with only a tls_error and no certificate at all; a trusted one made
the second leg's identical verifying handshake succeed too. There was no
input for which verified_against_default_bundle came back false, so the
panel branch and the documented diagnosis were both dead code. The
capture leg now deliberately does not verify (it sends nothing, and the
certificate you need to see is exactly the one a verifying handshake
refuses), issuer and subject are parsed from the DER because
getpeercert() returns an empty dict for an unvalidated peer, and a
separate verifying leg produces the verdict. Both legs, and the HTTP
request, now go through the configured proxy: the verification leg used
to connect directly, which in a proxy-only egress environment is refused,
so the field silently stayed null in precisely the deployments that
intercept. A live check against a self-signed server then showed the HTTP
leg raising through and discarding every one of those findings, so its
failure is reported as a field instead. Covered by a test that stands up
a TLS server with an untrusted certificate.

Probe, other:
- the ~/.claude presence stat ran inline on the caller's thread with no
  timeout, so a hung mount (the condition this exists to diagnose) hung
  run_probe forever and burned a Jupyter executor thread
- mount options were copied verbatim out of /proc/mounts, where CIFS/SMB
  carries addr= and username=, into a document that promises no hostnames
  and no credentials. Bare flags are kept, key=value only on a safe list,
  and the withheld count is shown rather than silently dropped
- the subprocess and mount checks had an outer budget equal to their own
  inner timeout, so the outer timer always won and the documented
  slow-but-completing band could never be produced

Recorder:
- get_turn is gated on _enabled, so a turn in flight when diagnostics were
  switched off never closed and leaked in the registry for the process
  lifetime, which also poisons the single-open-turn lookup and silently
  disables tool spans from then on. Added take_turn for the close path;
  a turn closed after disabling is released but not recorded
- the JSONL writer caught only OSError, so an unserializable document
  killed the thread while enqueue kept filling an unbounded queue with no
  consumer. Documents are now serialized individually, any failure counts
  toward the documented self-disable, and the thread cannot die silently
- span_id and parent_id were computed on every span, at the cost of a lock
  acquisition, and then discarded. They are emitted now, which is what
  lets a reader tell a phase from the wrapper containing it
- _detect_fs_type re-implemented mount resolution with a bare-prefix match
  and no firmlink handling, so it named the wrong filesystem for the one
  thing it exists to reveal. It delegates to perf_probe
- SPAN_NAMES and the attr allowlist advertised names and fields no
  producer sets, including a gateway_host that would have put a hostname
  in a document promising none

Instrumentation:
- the stream byte counter counted characters for an attribute named bytes,
  undercounting CJK and emoji three to four times
- stream() called get_turn per chunk, taking the global registry lock on
  the hottest path in the product. The handle is resolved once
- the tool-dispatch loop never marked tools builtin, so every NBI tool was
  recorded with a hashed name in the default mode, the opposite of what
  the docs say. MCPTool revokes the flag for itself
- the turns report embedded the gateway host, contradicting the README,
  the guide, and the panel's own copy. It moved to capabilities, which
  already carries the base URL for the settings UI

Panel:
- fmtMs only guarded undefined, so a null duration_api_ms rendered as
  "0 ms" on every non-Claude turn, in the column the docs call the most
  useful comparison
- nested spans are indented
- the network checkbox handler did not check the policy it renders
- the directory-level claude_home row no longer renders a dangling colon
- the Performance tab is hidden under force-off, where both endpoints 404
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant