Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ _If you are upgrading from 1.x: please see [Migrating from 1.x](MIGRATING.md)._

- **Breaking:** rename every tool, so the name says which server it belongs to and what comes back: `get_apex_log_summary` is `apexlog_get_summary`, `analyze_apex_log_performance` is `apexlog_list_slow_operations`, `find_performance_bottlenecks` is `apexlog_list_limit_risks`, and `execute_anonymous` is `apexlog_execute_anonymous`. Unprefixed names collide between servers, and a verb that names the work rather than the result cannot be told apart from another tool's ([#107])
- **Breaking:** refuse `apexlog_execute_anonymous` against production orgs, and orgs whose type cannot be read, unless the run is confirmed via [MCP elicitation](https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation) or `--allow-production-orgs` is set ([#52])
- **Breaking:** `apexlog_list_slow_operations` now ranks every timed operation by self time, not methods alone: code units, managed packages, methods, system methods, queries, searches, DML, flows and workflows, in one table of `{kind, name, namespace, lineNumber, callCount, durationTotalMs, durationSelfMs, selfPercentage, soqlCount, dmlCount, soslCount, rowCount, thrownCount}` rows. `slowestMethods`, `totalMethods`, `totalExecutionTime`, `topMethodsSelfPercentage` and `recommendations` are gone, replaced by `operations`, `durationTotalMs` and `returnedSelfPercentage`. The `topMethods` and `minDuration` parameters are now `limit` and `minSelfMs`, beside new `kind`, `namespace` and `groupBy` parameters that select and fold the rows ([#108])
- **Breaking:** `apexlog_list_slow_operations` now ranks every timed operation by self time, not methods alone: code units, managed packages, methods, system methods, queries, searches, DML, flows and workflows, in one table of `{kind, name, namespace, callCount, durationTotalMs, durationSelfMs, selfPercentage, soqlCount, dmlCount, soslCount, rowCount, thrownCount}` rows. `slowestMethods`, `totalMethods`, `totalExecutionTime`, `topMethodsSelfPercentage` and `recommendations` are gone, replaced by `operations`, `durationTotalMs` and `returnedSelfPercentage`. The `topMethods` and `minDuration` parameters are now `limit` and `minSelfMs`, beside new `kind`, `namespace` and `groupBy` parameters that select and fold the rows ([#108])
- **Breaking:** `apexlog_list_limit_risks` now returns one table of the governor limits at risk — `{limit, used, max, usedPercentage}` rows, worst first — beside the `threshold` that selected them. The `cpuBottlenecks`, `databaseBottlenecks`, `methodBottlenecks` and `governorLimitWarnings` sections, the `note`, and the `analysisType` parameter are gone; a new `threshold` parameter sets where a limit becomes worth reporting. All thirteen limits are covered, where the sections covered six, and the response costs 78% less ([#108])
- **Breaking:** drop `file` from `apexlog_get_summary`, and the prose `summary` from `apexlog_list_slow_operations` in favour of a scalar share of the runtime ([#86], [#108])
- **Breaking:** `apexlog_get_summary` now reports where the time went and what each namespace consumed. `timeByKind` gives `{kind, logCategory, operationCount, durationSelfMs, selfPercentage}` for every kind of operation, and `limitsByNamespace` gives `{namespace, limit, used}` for each limit a namespace consumed, so a managed package that spends your CPU time is visible. `totalMethods`, `totalSOQLQueries`, `totalDMLOperations`, `totalSOQLRows` and `totalDMLRows` are gone, and searches are covered for the first time. `size`, `totalExecutionTime` and `parsingErrors` are now `fileSizeBytes`, `durationTotalMs` and `parsingErrorCount`, beside a new `truncated`. A `debugLevels` row names its `logCategory`, not its `category`, which is the name `timeByKind` uses for the same fact ([#62], [#108])
Expand All @@ -23,15 +23,17 @@ _If you are upgrading from 1.x: please see [Migrating from 1.x](MIGRATING.md)._
- Make the `apexlog_execute_anonymous` tool always discoverable, so agents can find it without server flags ([#52])
- Reduce every tool response with no fact lost: `apexlog_list_slow_operations` by 24% ([#86], [#108]) and `apexlog_execute_anonymous` by 30% ([#86], [#109]). `apexlog_get_summary` costs 16% more on a log that uses its limits, for the two tables it gained ([#62])
- **Breaking:** move to the MCP TypeScript SDK v2 packages, so the server speaks the 2026-07-28 protocol revision. Clients on the 2025 revisions keep working through the SDK's compatibility layer ([#103])
- Reduce the standing cost of having the server connected by 26%: `apexlog_execute_anonymous` by 50%, `apexlog_list_limit_risks` by 28% and `apexlog_get_summary` by 5% ([#87], [#108], [#103]). `apexlog_list_slow_operations` costs 46% more, for the five parameters that select what it ranks and for what `groupBy` now states about the grouped row ([#108], [#101], [#126], [#127])
- Reduce the standing cost of having the server connected by 26%: `apexlog_execute_anonymous` by 50%, `apexlog_list_limit_risks` by 28% and `apexlog_get_summary` by 5% ([#87], [#108], [#103]). `apexlog_list_slow_operations` costs 46% more, for the five parameters that select what it ranks, and for what `groupBy` now states about the grouped row ([#108], [#101], [#126], [#127])
- **Breaking:** `durationTotalMs` on a grouped `apexlog_list_slow_operations` row is now what the transaction takes back if the group never runs. A group holds parents and their children alike, and a parent's total already contains its children's, so the old sum counted the same time more than once — 1.6x the transaction on a real log, 3.1x on the test fixture. Only the calls that ran outside every other call in the group now add their total. The figure is not additive across rows, and `durationSelfMs` is unchanged ([#101])
- **Breaking:** `apexlog_list_slow_operations` now folds repeats into one row by default, where it ranked single calls. A flow element that runs 373 times for 15% of a real transaction was absent from the top ten and is now its second row, and the ten rows returned cover 83% of the self time where they covered 51%. Rows key on kind, namespace and name, so one name in two namespaces stays two rows rather than merging under the first namespace seen. A grouped row adds `durationSelfMaxMs`, the self time of its slowest call — read against `durationSelfMs` it tells one bad call from sheer volume, which need opposite fixes — and `lineNumber` now names that call. Pass `groupBy: "none"` to rank each call on its own ([#126])
- **Breaking:** `apexlog_list_slow_operations` now folds repeats into one row by default, where it ranked single calls. A flow element that runs 373 times for 15% of a real transaction was absent from the top ten and is now its second row, and the ten rows returned cover 83% of the self time where they covered 51%. Rows key on kind, namespace and name, so one name in two namespaces stays two rows rather than merging under the first namespace seen. A grouped row adds `durationSelfMaxMs`, the self time of its slowest call — read against `durationSelfMs` it tells one bad call from sheer volume, which need opposite fixes. Pass `groupBy: "none"` to rank each call on its own ([#126])
- **Breaking:** `soqlCount`, `dmlCount`, `soslCount`, `rowCount` and `thrownCount` on a grouped `apexlog_list_slow_operations` row now count what the group did once, on the same rule `durationTotalMs` follows. Each is a subtree total, so the old sum counted a query once for the call that ran it and again for every member above it in the stack: 183 of the 372 groupings across 124 real logs had a row claiming more queries than the whole transaction ran, up to 19.7x, and none now do. Summed `soqlCount` falls 40% and `rowCount` 25% across the corpus. Like `durationTotalMs`, the figures are not additive across rows, and `callCount` and `durationSelfMs` are unchanged ([#131])
- Encode responses with TOON v4. No response changed: v4 removes key folding and path expansion, and this server used neither ([#121])
- Parse a log once rather than once per tool, cached by path, inode, size, modification time and change time, so a summary followed by a deeper tool no longer reads and parses the file again. The parse is dropped after five minutes unused, so a large log is not held for the life of the session ([#88])

### Added

- Report `matchedCount` from `apexlog_list_slow_operations`: the rows the selection matched before `limit` cut it. Read against the rows returned it says whether the cap hid anything, which nothing else in the response states — a table of ten out of ten and a table of ten out of four hundred read the same today ([#63])
- Report the level each log category was captured at — `apexCodeLevel`, `systemLevel`, `dbLevel` and `workflowLevel` — from `apexlog_list_slow_operations` and `apexlog_list_limit_risks`. A capture level decides what reaches the log at all, so it qualifies every figure beside it: on a log taken at `APEX_CODE,ERROR` no method entry is emitted, and the work of everything the level hid pools at the nearest logged boundary, which reads as a finding. A category the log's header never declared is left out rather than named at a default, because a level has no zero ([#102])
- Add `groupBy: "callerNamespace"` to `apexlog_list_slow_operations`, which folds rows by the namespace that called the operation rather than the one it ran in. DML is pinned to `default` however it was reached, so only the caller says which package drove it: on a real log 4 of the 5 DML rows have a different caller, and they carry 934 of the 944 ms. The two agree on 97% of rows, so this is a grouping and not a column — every response that does not ask for it is unchanged ([#127])
- Add `--allow-production-orgs`, to run against production without confirmation ([#52])
- Add `--no-apex-execution`, to stop Apex running at all while the log analysis tools keep working ([#52])
Expand Down Expand Up @@ -82,3 +84,5 @@ _If you are upgrading from 1.x: please see [Migrating from 1.x](MIGRATING.md)._
[#127]: https://github.com/certinia/debug-log-analyzer-mcp/issues/127
[#131]: https://github.com/certinia/debug-log-analyzer-mcp/issues/131
[#124]: https://github.com/certinia/debug-log-analyzer-mcp/issues/124
[#102]: https://github.com/certinia/debug-log-analyzer-mcp/issues/102
[#63]: https://github.com/certinia/debug-log-analyzer-mcp/issues/63
25 changes: 25 additions & 0 deletions DEVELOPING.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,31 @@ one-liners.
- `delimiter` tab or pipe — 2 tokens on a 9,365-token response. Quoting comes from `:` in a name.
- nested field groups — nothing left to fold; every table is already flat.

### A scalar qualifies the response, a column varies per row

**A fact that qualifies every number in the response is a response-level scalar, stated once; a fact
that varies per row is a column.** `threshold` on `apexlog_list_limit_risks` follows this rule, and so
do the four capture levels — `apexCodeLevel`, `systemLevel`, `dbLevel` and `workflowLevel` — that
`apexlog_list_slow_operations` and `apexlog_list_limit_risks` report from the log's header.

They matter because a capture level silently changes what every figure beside it means. On a log
taken at `APEX_CODE,ERROR` no `METHOD_ENTRY` is emitted at all, so the ranking puts 8,161 ms of self
time on a Visualforce page. That is not the page's work — it is everything the level hid, pooled at
the nearest logged boundary. Without the level the number reads as a finding, and any advice built on
it is confidently wrong.

Two consequences:

- **A category the header never declared is left out, not defaulted.** A level has no zero, so the
usual "report the fixed-schema field anyway" does not apply — naming a default would state a value
the log never did. Absent means unstated.
- **No caveat prose and no magnitude.** The response reports the level and stops. A figure measured
once against one org, with the harness not committed, cannot be re-derived by CI and will rot.

`apexlog_get_summary` needs none of them: `timeByKind` already carries a `logCategory` column and
`debugLevels` lists the level per category, so the join is the caller's to make and restating it
would break "say it once".

### When a fact earns a grouping and not a column

`namespace` on an operation is the namespace of the frame, which is not always the namespace that
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,10 @@ The input side is the same for every analysis tool — a tool name and a log fil
| ------------------------------ | -------------------- | -------- | ---- | ------ |
| `apexlog_get_summary` | `governor-heavy.log` | ~341 | ~293 | +16% |
| `apexlog_get_summary` | `minimal.log` | ~238 | ~249 | -4% |
| `apexlog_list_slow_operations` | `governor-heavy.log` | ~310 | ~408 | -24% |
| `apexlog_list_slow_operations` | `minimal.log` | ~94 | ~190 | -51% |
| `apexlog_list_limit_risks` | `governor-heavy.log` | ~21 | ~84 | -75% |
| `apexlog_list_limit_risks` | `minimal.log` | ~6 | ~30 | -80% |
| `apexlog_list_slow_operations` | `governor-heavy.log` | ~320 | ~408 | -22% |
| `apexlog_list_slow_operations` | `minimal.log` | ~111 | ~190 | -42% |
| `apexlog_list_limit_risks` | `governor-heavy.log` | ~39 | ~84 | -54% |
| `apexlog_list_limit_risks` | `minimal.log` | ~24 | ~30 | -20% |

<!-- token-cost-answers:end -->

Expand All @@ -106,7 +106,7 @@ All tools return [TOON](https://github.com/toon-format/toon)-encoded data, kept

Rank what an Apex debug log spent its time on by self-execution time — code units, managed packages, methods, queries, searches, DML, flows and workflows in one table, each row with its calls, durations (in ms), database counts and rows. Best for finding what to optimize.

Rows are `{kind, name, namespace, lineNumber, callCount, durationTotalMs, durationSelfMs, selfPercentage, soqlCount, dmlCount, soslCount, rowCount, thrownCount}`, beside the transaction's `durationTotalMs` and the `returnedSelfPercentage` the returned rows account for between them.
Rows are `{kind, name, namespace, callCount, durationTotalMs, durationSelfMs, selfPercentage, soqlCount, dmlCount, soslCount, rowCount, thrownCount}`, beside the transaction's `durationTotalMs` and the `returnedSelfPercentage` the returned rows account for between them.

`kind` is one of `codeUnit`, `managedPackage`, `method`, `systemMethod`, `soql`, `sosl`, `dml`, `flow` or `workflow`. A `managedPackage` row is the time a package spent where the log shows nothing, and is often most of a transaction.

Expand Down
31 changes: 23 additions & 8 deletions scripts/eval.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,23 @@ const ANSWERABILITY = {
columns: ["dmlCount", "soqlCount", "soslCount", "rowCount"],
},
{
question: "Where in the code are they, and whose namespace are they in?",
question: "Whose namespace are they in?",
keys: ["operations"],
columns: ["namespace", "lineNumber"],
columns: ["namespace"],
},
{
question: "Is it one slow call or many cheap ones?",
keys: ["operations"],
columns: ["callCount", "durationSelfMaxMs"],
},
{
question: "Was the log captured at a level that hides work inside these rows?",
keys: ["apexCodeLevel", "systemLevel", "dbLevel", "workflowLevel"],
},
{
question: "Did the row cap hide operations the selection matched?",
fields: ["matchedCount"],
},
],
apexlog_list_limit_risks: [
{
Expand All @@ -131,6 +139,10 @@ const ANSWERABILITY = {
question: "How near does a limit have to be to appear here?",
fields: ["threshold"],
},
{
question: "Was the log captured at a level that hides what consumed a limit?",
keys: ["apexCodeLevel", "systemLevel", "dbLevel", "workflowLevel"],
},
],
};

Expand Down Expand Up @@ -164,11 +176,14 @@ const TOKEN_BUDGET = {
"apexlog_get_summary/governor-heavy": 357,
"apexlog_get_summary/minimal": 249,
// Raised for the grouped default #126 made: every row now carries its call
// count and the self time of its slowest call.
"apexlog_list_slow_operations/governor-heavy": 325,
// count and the self time of its slowest call, and for the four capture levels
// #102 added, which say how much of the transaction reached the log at all,
// and for the `matchedCount` #63 added, which says whether the row cap hid
// anything the selection matched.
"apexlog_list_slow_operations/governor-heavy": 337,
"apexlog_list_slow_operations/minimal": 130,
"apexlog_list_limit_risks/governor-heavy": 40,
"apexlog_list_limit_risks/minimal": 15,
"apexlog_list_limit_risks/governor-heavy": 41,
"apexlog_list_limit_risks/minimal": 26,
};

/**
Expand Down Expand Up @@ -358,7 +373,6 @@ function inspect(toon) {
const indented = line.trim();
const cells = indented.split(",");
table.set(cells[0], cells);
strings.push(indented);
}

return { scalars, keys, columns, tables, strings };
Expand Down Expand Up @@ -434,7 +448,8 @@ function checkNoDuplication({ tool, fixture }, toon, failures) {

// A prose line must not restate a figure that is already a field of its own.
// This is what the deleted `summary` paragraph did, and what a well-meaning
// future one would do again.
// future one would do again. Table rows are out of scope: a cell that reads
// like a scalar is another measurement of another thing, not a restatement.
for (const [key, value] of scalars) {
if (value === 0 || value === 1) continue;
const rendered = String(value);
Expand Down
4 changes: 3 additions & 1 deletion src/tools/listLimitRisks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { z } from "zod";
import { encode } from "@toon-format/toon";
import type { GovernorLimits } from "../ApexLogParser.js";
import { loadApexLog, logFilePathSchema } from "./apexLogSource.js";
import { captureLevels, type CaptureLevels } from "./operations.js";
import {
percentageOf,
roundPercent,
Expand Down Expand Up @@ -36,7 +37,7 @@ export interface LimitRisk {
usedPercentage: number;
}

export interface LimitRiskResult {
export interface LimitRiskResult extends CaptureLevels {
/**
* What "at risk" meant for this call. The rows are a selection, so without it
* an empty table cannot be told apart from a threshold nothing could reach.
Expand All @@ -63,6 +64,7 @@ export async function listLimitRisks(args: LimitRisksArgs) {
const apexLog = await loadApexLog(logFilePath);

const result: LimitRiskResult = {
...captureLevels(apexLog),
threshold,
atRisk: atRiskLimits(apexLog.governorLimits, threshold),
};
Expand Down
Loading