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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ This server implements the [Model Context Protocol (MCP)](https://modelcontextpr
- **Runs as a local process** — your AI client spawns the server and communicates locally. No network requests, no API keys.
- **Uses the same parser as the [Apex Log Analyzer VS Code extension](https://github.com/certinia/debug-log-analyzer)** — battle-tested parsing of the Apex debug log format.
- **Returns structured data** — all durations in milliseconds, governor limits as used/max rows, operations with SOQL/DML counts — so your AI assistant can reason about the results.
- **Keeps responses lean** — TOON encoding, no duplicated figures, and zero/empty fields omitted, so more of the context window is left for reasoning.
- **Keeps responses lean** — TOON tables and no duplicated figures, so more of the context window is left for reasoning. The fields stay, including the ones at zero: see [Tools Reference](#tools-reference).
- **Parses a log once, not once per tool** — a summary followed by a deeper analysis of the same file reuses the parse, so a large log is read and parsed one time.

## Documentation
Expand Down
25 changes: 21 additions & 4 deletions scripts/eval.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const ANSWERABILITY = {
{ question: "What did the transaction spend its time on?", keys: ["operations"] },
{
question: "Was it a method, a query, a search or DML?",
keys: ["operations"],
columns: ["kind", "callCount"],
},
{
Expand All @@ -107,10 +108,12 @@ const ANSWERABILITY = {
},
{
question: "Did any of them touch the database, and how much did they move?",
keys: ["operations"],
columns: ["dmlCount", "soqlCount", "soslCount", "rowCount"],
},
{
question: "Where in the code are they, and whose namespace are they in?",
keys: ["operations"],
columns: ["namespace", "lineNumber"],
},
],
Expand Down Expand Up @@ -312,7 +315,7 @@ function createClient() {
function inspect(toon) {
const scalars = new Map();
const keys = [];
const columns = new Set();
const columns = new Map();
const tables = new Map();
const strings = [];
let table = new Map();
Expand All @@ -326,7 +329,10 @@ function inspect(toon) {
table = new Map();
tables.set(key, table);
if (header) {
header.split(",").forEach((column) => columns.add(column.trim()));
columns.set(
key,
new Set(header.split(",").map((column) => column.trim())),
);
} else if (value !== "" && !line.endsWith(":")) {
const numeric = Number(value);
if (Number.isFinite(numeric) && /^-?[\d.]+$/.test(value)) {
Expand Down Expand Up @@ -360,8 +366,19 @@ function checkAnswerability({ tool, fixture }, toon, failures) {
for (const key of check.keys ?? []) {
if (!keys.includes(key)) missing.push(key);
}
for (const column of check.columns ?? []) {
if (!columns.has(column)) missing.push(column);
// A column belongs to one table. Pooling every header into one set let a
// check pass on a column another table happened to carry.
if (check.columns) {
const [table, ...rest] = check.keys ?? [];
if (!table || rest.length) {
throw new Error(
`${tool}: a "columns" check names the one table they are in, in "keys" — "${check.question}"`,
);
}
const header = columns.get(table) ?? new Set();
for (const column of check.columns) {
if (!header.has(column)) missing.push(`${table}.${column}`);
}
}
for (const limit of check.limits ?? []) {
if (!limitRows.has(limit)) missing.push(`governorLimits.${limit}`);
Expand Down
23 changes: 23 additions & 0 deletions src/salesforce/debugLevels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,29 @@ export type TraceCategory = (typeof TRACE_CATEGORIES)[number];

export type TraceConfig = Partial<Record<TraceCategory, LogLevel>>;

/**
* The same categories as a debug log header spells them.
*
* A log opens with `APEX_CODE,FINE;DB,FINEST;…`, which is what the parser reads
* into `debugLevels`, so this is the spelling every response uses. `DATA_ACCESS`
* appears there but is not a `DebugLevel` field, so nothing can set it.
*/
export const LOG_CATEGORIES = [
"APEX_CODE",
"APEX_PROFILING",
"CALLOUT",
"DATA_ACCESS",
"DB",
"NBA",
"SYSTEM",
"VALIDATION",
"VISUALFORCE",
"WAVE",
"WORKFLOW",
] as const;

export type LogCategory = (typeof LOG_CATEGORIES)[number];

/** The only place the per-category defaults live. */
export const DEFAULT_TRACE_CONFIG: Required<TraceConfig> = {
apexCode: "FINE",
Expand Down
12 changes: 9 additions & 3 deletions src/tools/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import type { ApexLog, LogLine, LogSubCategory } from "../ApexLogParser.js";
import type { LogCategory } from "../salesforce/debugLevels.js";
import { walkLog } from "./apexLogSource.js";

/**
Expand All @@ -29,8 +30,13 @@ export const OPERATION_KINDS = [

export type OperationKind = (typeof OPERATION_KINDS)[number];

/** The trace category that decides whether a kind reaches the log. */
const LOG_CATEGORY_BY_KIND: Record<OperationKind, string> = {
/**
* The category that decides whether a kind reaches the log.
*
* Typed as `LogCategory`, so the spelling here cannot drift from the one the
* `debugLevels` rows carry — a caller reads `timeByKind` against them.
*/
const LOG_CATEGORY_BY_KIND: Record<OperationKind, LogCategory> = {
codeUnit: "APEX_CODE",
managedPackage: "APEX_CODE",
method: "APEX_CODE",
Expand All @@ -42,7 +48,7 @@ const LOG_CATEGORY_BY_KIND: Record<OperationKind, string> = {
workflow: "WORKFLOW",
};

export function logCategoryOf(kind: OperationKind): string {
export function logCategoryOf(kind: OperationKind): LogCategory {
return LOG_CATEGORY_BY_KIND[kind];
}

Expand Down
5 changes: 4 additions & 1 deletion tests/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import type { ApexLog } from "../src/ApexLogParser";
import { LOG_CATEGORIES } from "../src/salesforce/debugLevels";
import {
groupOperations,
listOperations,
Expand Down Expand Up @@ -92,7 +93,9 @@ describe("listOperations", () => {

it("covers every kind it declares", () => {
expect(new Set(OPERATION_KINDS).size).toBe(OPERATION_KINDS.length);
OPERATION_KINDS.forEach((kind) => expect(logCategoryOf(kind)).toBeTruthy());
OPERATION_KINDS.forEach((kind) =>
expect(LOG_CATEGORIES).toContain(logCategoryOf(kind)),
);
});

it("drops the transaction frame, which owns no time of its own", () => {
Expand Down
45 changes: 44 additions & 1 deletion tests/salesforce/debugLevels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@
* Copyright (c) 2025 Certinia Inc. All rights reserved.
*/

import fs from "node:fs";
import path from "node:path";

import { Connection } from "@salesforce/core";
import { getOrCreateDebugLevelId } from "../../src/salesforce/debugLevels";
import {
getOrCreateDebugLevelId,
LOG_CATEGORIES,
TRACE_CATEGORIES,
} from "../../src/salesforce/debugLevels";

describe("Debug Levels", () => {
const testId = "000000000000000000";
Expand Down Expand Up @@ -260,4 +267,40 @@ describe("Debug Levels", () => {
});
});
});

describe("log categories", () => {
const fixtures = path.join(__dirname, "..", "eval", "fixtures");

it.each(fs.readdirSync(fixtures).filter((name) => name.endsWith(".log")))(
"spells every category in %s the way the log header does",
(name) => {
const header = fs
.readFileSync(path.join(fixtures, name), "utf8")
.split("\n")[0]!;
const categories = header
.split(" ")[1]!
.split(";")
.map((pair) => pair.split(",")[0]!);

expect(categories.length).toBeGreaterThan(0);
categories.forEach((category) =>
expect(LOG_CATEGORIES).toContain(category),
);
},
);

it("gives every settable category a header spelling", () => {
TRACE_CATEGORIES.forEach((category) => {
const spelling =
category === "database" ? "DB" : toScreamingSnake(category);

expect(LOG_CATEGORIES).toContain(spelling);
});
});
});
});

/** `apexCode` as a log header spells it: `APEX_CODE`. */
function toScreamingSnake(category: string): string {
return category.replace(/([A-Z])/g, "_$1").toUpperCase();
}