Skip to content

feat(operations): group by the namespace that called the operation - #134

Merged
lcottercertinia merged 8 commits into
certinia:mainfrom
lukecotter:127-caller-namespace
Aug 14, 2026
Merged

feat(operations): group by the namespace that called the operation#134
lcottercertinia merged 8 commits into
certinia:mainfrom
lukecotter:127-caller-namespace

Conversation

@lukecotter

Copy link
Copy Markdown
Collaborator

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

namespace on a row is the namespace of the frame, which is not always the namespace that asked for the work. DMLBeginLine pins namespace = "default" however the DML was reached (ApexLogParser.ts:1412), and because the tree builder inherits with ||= the enclosing frame can never overwrite it. SOQLExecuteBeginLine and SOSLExecuteBeginLine carry 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:

groupBy result
name four rows, every one namespace: default
callerNamespace 934.0 ms to the calling package, 10.3 ms to default

Why it is not a column

Over two real logs the caller and the frame agree on almost every row:

log rows caller differs
19 MB, managed packages throughout 2,469 72 (2.9%)
39 MB, every category at INFO 39,415 2 (0.0%)

Flows, SOQL, workflow and methods differ on 0% of rows. The time sits entirely in the rows that do differ — DML above, and codeUnit on 17% of rows carrying 10,600 of its 10,904 ms. So the fact matters and the column does not: it would repeat namespace on 97% of every response.

callerNamespace is therefore carried on the internal Operation and never emitted. It is a groupBy value instead. The rule is written up in DEVELOPING.md beside the other shaping conventions.

What changed

  • Operation.callerNamespace, taken from the enclosing operation via the parent link fix: a grouped row's durationTotalMs double counts nested operations #101 added. No traversal change.
  • GROUP_BY gains "callerNamespace".
  • groupOperations now takes the pair a folded row reports from one IDENTITY_BY_GROUP table rather than branching on by in two places. Same partition as before for name and namespace.

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 callerNamespace attributes, without which an agent cannot act on the value.

pnpm run build, pnpm test (265), pnpm run lint and pnpm run eval all pass.

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
lcottercertinia merged commit daf485e into certinia:main Aug 14, 2026
14 checks passed
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.

Distinguish the caller's namespace from the frame's

2 participants