feat(operations): group by the namespace that called the operation - #134
Merged
Merged
Conversation
Replace @modelcontextprotocol/sdk with @modelcontextprotocol/server and @modelcontextprotocol/client, and serve stdio through the serveStdio factory. The factory runs once per connection, after the opening exchange chooses the protocol era, so the process-level signal handlers and the error callback move out of the ApexLogServer constructor into a new exported runStdioServer. legacy: "serve" is the SDK default and is stated explicitly, because dropping the 2025-era clients would be a second breaking change. The SDK no longer emits execution.taskSupport on a tool definition, so every definition costs 9 tokens less. No response changed. Closes certinia#103
ApexLogServer was a constructor plus one private registerTools, and the SDK v2 factory made its server field public just to reach it through a throwaway instance. createApexLogServer returns the McpServer directly. classificationCache moves to module scope. The factory builds one server per connection, so as an instance field the cache no longer had the process lifetime it is documented to have. runStdioServer returns void: no caller read the handle. Refs certinia#103
v4 removes key folding and path expansion. Every encode() call here passes no options, so no response changed and no golden moved. The other v4 encode forms were measured and none pay: keyed tabular costs a character per row, a tab or pipe delimiter saves 2 tokens on a 9,365-token response, nested field groups have nothing left to fold, and indentSize 1 saves 2.4% but decode rejects it. Recorded in DEVELOPING.md. Closes certinia#121
A group holds parents and their children alike, so summing durationTotalNs over every member counted the same time once for the child and again for every ancestor above it — 1.6x the transaction under groupBy: "namespace" on a real log, 3.1x on the test fixture. Only the members with no ancestor in the same group now add their total, so the figure answers "what does the transaction take back if this group never runs". It is not additive across rows, and the groupBy description says so: an agent that sums it would read 4.9x the transaction. listOperations threads the enclosing operation through walkLog, which now carries an inherited value down the tree. The parent link is internal and never reaches a response. The groupBy description costs 26 more definition tokens, so the budget is raised from 338 to 343 and both README token tables are regenerated. Closes certinia#101
Ranking single calls hid volume: a flow element that runs 373 times for 15% of a real transaction was absent from the top ten. The ten rows returned now cover 83% of the self time where they covered 51%. Group on kind + namespace + name, so one name in two namespaces stays two rows. Add durationSelfMaxMs, which read against durationSelfMs tells one bad call from sheer volume, and point lineNumber at that call. groupBy: "none" restores the per-call ranking. The response budgets rise to 325 and the definition budget to 372. Closes certinia#126
Memoize the group key: the nesting test walks the ancestors of every member, and a deep Apex stack rebuilt the same string at every level. Export GROUP_BY from the module that folds, so the tool schema cannot drift from it, and trim the groupBy description to what the agent acts on. The definition budget falls to 354.
DML is pinned to default however it was reached, so the namespace column cannot say which package drove it. On a real log that leaves 934 of the 944 ms of DML time reading as default; grouped by callerNamespace it attributes to the package that asked for it. Carry the caller on the internal Operation and add it to groupBy. It earns no column: caller and frame agree on 97% of rows, so a column would repeat namespace on nearly every response, and no response that does not pass the value changes. The definition budget rises to 379. Closes certinia#127
# Conflicts: # CHANGELOG.md # DEVELOPING.md # README.md # scripts/eval.mjs # src/tools/listSlowOperations.ts # src/tools/operations.ts # tests/operations.test.ts
lcottercertinia
approved these changes
Aug 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #127.
Stacked on #133 (#126), which is stacked on #132 (#101). Review the top commit only — the rest is those two, and this diff shrinks as they merge.
Why
namespaceon a row is the namespace of the frame, which is not always the namespace that asked for the work.DMLBeginLinepinsnamespace = "default"however the DML was reached (ApexLogParser.ts:1412), and because the tree builder inherits with||=the enclosing frame can never overwrite it.SOQLExecuteBeginLineandSOSLExecuteBeginLinecarry no namespace logic at all, so they silently inherit the caller. One column, two meanings.Measured on a real log,
kind: "dml", 944 ms in total:namenamespace: defaultcallerNamespacedefaultWhy it is not a column
Over two real logs the caller and the frame agree on almost every row:
INFOFlows, SOQL, workflow and methods differ on 0% of rows. The time sits entirely in the rows that do differ — DML above, and
codeUniton 17% of rows carrying 10,600 of its 10,904 ms. So the fact matters and the column does not: it would repeatnamespaceon 97% of every response.callerNamespaceis therefore carried on the internalOperationand never emitted. It is agroupByvalue instead. The rule is written up inDEVELOPING.mdbeside the other shaping conventions.What changed
Operation.callerNamespace, taken from the enclosing operation via theparentlink fix: a grouped row's durationTotalMs double counts nested operations #101 added. No traversal change.GROUP_BYgains"callerNamespace".groupOperationsnow takes the pair a folded row reports from oneIDENTITY_BY_GROUPtable rather than branching onbyin two places. Same partition as before fornameandnamespace.Cost
Response goldens unmoved — the enum value alone changes no response. The definition grows 337 → 361 tokens, the budget to 379; the extra is the clause that says what
callerNamespaceattributes, without which an agent cannot act on the value.pnpm run build,pnpm test(265),pnpm run lintandpnpm run evalall pass.