Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7122362
feat(cli): add query DB schema and open helpers
kgilpin May 1, 2026
74ca2be
feat(cli): port query DB importer and hook into fingerprint pipeline
kgilpin May 1, 2026
94b72ee
feat(cli): add query command with endpoints, find, and tree verbs
kgilpin May 1, 2026
0819a8d
chore(cli): drop validate-against-python script
kgilpin May 1, 2026
188d597
feat(cli): rename --db to --query-db, drop APPMAP_QUERY_DB env, add d…
kgilpin May 2, 2026
4684d60
feat(cli): hotspots verb, schema cleanup, normalized class filters
kgilpin May 2, 2026
12d91fa
feat(cli): per-find-type flag validation
kgilpin May 2, 2026
14b300b
fix(query/tree): include exception source location in tree/summary ou…
kgilpin May 2, 2026
e2ea96c
fix(query): make verb builders generic so CommandModule inference holds
kgilpin May 2, 2026
6570cd7
fix(query/find): determinism, --duration on appmaps, basename matchin…
kgilpin May 2, 2026
7eb22bd
fix(query): SQL pushdown for endpoints; canonical class match for fin…
kgilpin May 2, 2026
bd95c0c
fix(query): importer mis-link, verb-layer Class#method split, hotspot…
kgilpin May 2, 2026
7251272
chore(query): widen handler argv type for CommandModule<{}, any> assi…
kgilpin May 2, 2026
302873f
style(query): replace Array<T> with T[] to satisfy lint rule
kgilpin May 2, 2026
848a3ee
feat(query): related and compare verbs
kgilpin May 2, 2026
a63b8a3
feat(query/tree): focus, ancestors/descendants, min-elapsed-ms
kgilpin May 2, 2026
67b0259
feat(query/mcp): MCP server exposing the V3 query surface
kgilpin May 2, 2026
e85517c
feat(query/mcp): rename tools to descriptive verb-noun forms
kgilpin May 2, 2026
b221334
feat(query): document MCP tool outputs, add appmap_id, alias 'recordi…
kgilpin May 2, 2026
6d2982e
style(query): clear remaining lint errors
kgilpin May 2, 2026
2c0f6c5
chore(cli): add typecheck and verify scripts
kgilpin May 3, 2026
2382d29
docs: add CLAUDE.md with per-package verify guidance
kgilpin May 3, 2026
3cb6e4a
feat(query): expose path:lineno on function rows; add list_labels MCP…
kgilpin May 3, 2026
fc9db5a
chore: hoist verify to repo root, scoped to changed packages
kgilpin May 3, 2026
91ea859
feat(query): add find logs verb + find_logs MCP tool
kgilpin May 3, 2026
b87f5b6
feat(query): attach recent_logs to find_exceptions on opt-in
kgilpin May 3, 2026
fc160a9
feat(query): inline log calls in tree render
kgilpin May 3, 2026
2280aa0
feat(query): expose appmap://recording/{ref}/logs MCP resource
kgilpin May 3, 2026
4e72c6b
fix(query): with_logs neighborhood; project log message server-side
kgilpin May 3, 2026
57174ce
feat(query): substring filters + Page<T> pagination across list queries
kgilpin May 4, 2026
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
39 changes: 39 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Verifying changes

This is a Yarn 3 monorepo (`packages/*`). CI rejects pushes for both lint errors and tsc errors, with several-minute round-trip costs per CI run. Verify locally before committing.

## When to run verify

After any **substantial batch of changes** — multiple files touched, new functions added, tests added, refactors — run `yarn verify` from the repo root before reporting work complete or asking to commit. A one-line typo fix doesn't need it; a multi-file change does.

## How to run verify

```sh
yarn verify # check working-tree changes (staged + unstaged + untracked)
yarn verify:staged # check only staged changes
```

`scripts/verify.mjs`:

1. Reads modified files from git.
2. Groups them by `packages/<name>/`.
3. For each affected package: runs ESLint (`--quiet`, errors only) on the changed lintable files, then `tsc --noEmit` on the whole package (since TS is project-wide, you can't typecheck a single file).

Typical run on one package: ~5–7s. CI's full lint+typecheck takes ~30s; scoped is ~3× faster.

## Adding verify to a package

If you touch a package that doesn't yet participate, add a `typecheck` script (`tsc --noEmit`) to its `package.json` so `verify.mjs` includes it. The existing `lint` script is enough for ESLint coverage.

## What verify catches

- ESLint errors that CI rejects: `array-type` (use `T[]` not `Array<T>`), `no-unnecessary-type-assertion`, `prefer-function-type`, `prefer-optional-chain`, etc. Warnings (e.g. `no-unsafe-*`, `prefer-nullish-coalescing`) are suppressed by `--quiet`.
- Type errors that `tsc --noEmit` finds, including yargs `CommandModule<T, any>` assignability issues that require widening exported handler argv types.

# Driving the MCP after MCP-side changes

The `appmap query mcp` server lives in `built/cli.js`. If you change anything under `packages/cli/src/cmds/query/queries/mcp.ts` (or anywhere it transitively imports), you must run `npx tsc` (or `yarn build`) inside `packages/cli` before launching `mcp` for ad-hoc testing. A stale binary will respond to `tools/list` with the old surface — symptom is usually `unknown tool: …` from a client driving a tool the source defines.

# Recording with appmap-node from a monorepo

`npx appmap-node@latest npx jest …` invoked from the repo root can fail to parse `.ts` test files with a babel SyntaxError, because the inner jest doesn't pick up `packages/<name>/jest.config.js`'s `ts-jest` preset. Run from the package directory whose preset matters — e.g. `cd packages/cli && npx appmap-node@latest npx jest …`.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
],
"scripts": {
"lint": "yarn workspaces foreach --exclude root -v run lint",
"verify": "node scripts/verify.mjs",
"verify:staged": "node scripts/verify.mjs --staged",
"test": "yarn workspaces foreach --exclude '{root}' -v run test",
"build": "yarn workspaces foreach -t --exclude root -v run build",
"build-native": "yarn workspaces foreach -t --exclude root -v run build-native",
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"scripts": {
"lint": "eslint src tests",
"lint:fix": "eslint src tests --fix",
"typecheck": "tsc --noEmit",
"verify": "yarn lint && yarn typecheck",
"pre-commit": "lint-staged",
"test": "jest --filter=./tests/testFilter.js",
"test:binary": "jest -c tests/binary/jest.config.js",
Expand Down
103 changes: 103 additions & 0 deletions packages/cli/scripts/demo-query.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
#
# Quick demo of the `appmap query` verbs against a fixture set.
#
# Usage:
# ./scripts/demo-query.sh # uses appmap-apm fixtures if present,
# # else bundled ruby fixtures
# ./scripts/demo-query.sh /path/to/your/appmaps # any directory of *.appmap.json files
#
# Side effects: copies the fixture set to a temp dir, builds and imports a
# query.db there, leaves the originals untouched. Cleans up on exit.

set -euo pipefail

cd "$(dirname "$0")/.." # → packages/cli

# Pick the richest fixture set available.
DEFAULT="$HOME/source/appland/appmap-apm/tests/fixtures/tmp/appmap"
[ -d "$DEFAULT" ] || DEFAULT="$(pwd)/tests/unit/fixtures/ruby"
SRC="${1:-$DEFAULT}"
[ -d "$SRC" ] || { echo "fixture dir not found: $SRC" >&2; exit 2; }

# Temp work area: copy the fixtures so `appmap index` can write fingerprint
# sidecars without touching the originals.
TMP="$(mktemp -d -t appmap-demo)"
DATA="$TMP/data"
DB="$TMP/query.db"
mkdir -p "$DATA"
cp -r "$SRC"/. "$DATA"/
export NODE_NO_WARNINGS=1
trap 'rm -rf "$TMP"' EXIT

CLI=( node "$(pwd)/built/cli.js" )

echo "Building CLI…" >&2
npx tsc 2>&1 | grep -v 'navie-local' >&2 || true

# Filter out diagnostic noise from @appland/models that the verbs themselves
# don't emit (kept loose so we don't suppress real errors).
NOISE='\[DEBUG '

banner() {
echo
echo "── \$ appmap $*"
}
run() {
banner "$@"
"${CLI[@]}" "$@" 2>&1 | grep -vE "$NOISE" || true
}
run_quiet() {
banner "$@"
"${CLI[@]}" "$@" 2>&1 | grep -vE "$NOISE" | tail -5 || true
}

cat <<HEAD
Source : $SRC
Query DB : $DB
HEAD

# Build the query DB. The index command itself is noisy (one line per file
# plus diagnostics from @appland/models) — the demo cares about the verbs,
# not the indexer, so we silence index output and report a row count.
echo
echo "── \$ appmap index --appmap-dir <DATA> --query-db <QUERY_DB>"
"${CLI[@]}" index --appmap-dir "$DATA" --query-db "$DB" >/dev/null 2>&1
COUNT=$(node -e "
const db = require('better-sqlite3')('$DB', { readonly: true });
process.stdout.write(String(db.prepare('SELECT COUNT(*) AS n FROM appmaps').get().n));
")
echo "indexed $COUNT recordings"

run query endpoints --query-db "$DB" --sort p95 --limit 5
run query find queries --query-db "$DB" --table users --limit 3 || true
run query find exceptions --query-db "$DB" --limit 5 || true
run query hotspots --query-db "$DB" --limit 5
run query hotspots --query-db "$DB" --type=sql --limit 3

# related: find passing baselines for a recording (with whatever data exists)
RELATED_SOURCE="$(node -e "
const db = require('better-sqlite3')('$DB', { readonly: true });
const r = db.prepare(\"SELECT name FROM appmaps WHERE name LIKE '%oups%' LIMIT 1\").get();
process.stdout.write(r ? r.name : '');
")"
if [ -n "$RELATED_SOURCE" ]; then
run query related "$RELATED_SOURCE" --query-db "$DB" --limit 5
fi

# Pick the recording with the most events for the tree demos.
APPMAP="$(node -e "
const db = require('better-sqlite3')('$DB', { readonly: true });
const r = db.prepare(
'SELECT name FROM appmaps WHERE event_count > 0 ORDER BY event_count DESC LIMIT 1'
).get();
process.stdout.write(r ? r.name : '');
")"

if [ -n "$APPMAP" ]; then
run query tree "$APPMAP" --query-db "$DB" --format=summary
run query tree "$APPMAP" --query-db "$DB" --filter=sql
fi

echo
echo "Done."
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import 'reflect-metadata';

const yargs = require('yargs');

Check warning on line 11 in packages/cli/src/cli.ts

View workflow job for this annotation

GitHub Actions / test_cli

Unsafe assignment of an `any` value
const { promises: fsp, readFileSync } = require('fs');
const { queue } = require('async');
const { join } = require('path');
Expand Down Expand Up @@ -40,6 +40,7 @@
import * as RpcClientCommand from './cmds/rpcClient';
import * as NavieCommand from './cmds/navie';
import * as ApplyCommand from './cmds/apply';
import * as QueryCommand from './cmds/query/query';
import * as RunTestCommand from './cmds/runTest';
import TelemetryTestCommand from './cmds/testTelemetry';
import { default as sqlErrorLog } from './lib/sqlErrorLog';
Expand Down Expand Up @@ -156,6 +157,7 @@
.command(RpcClientCommand)
.command(NavieCommand)
.command(ApplyCommand)
.command(QueryCommand)
.command(RunTestCommand)
.command(TelemetryTestCommand)
.option('verbose', {
Expand Down
19 changes: 17 additions & 2 deletions packages/cli/src/cmds/index/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { configureRpcDirectories, handleWorkingDirectory } from '../../lib/handl
import { locateAppMapDir } from '../../lib/locateAppMapDir';
import { verbose } from '../../utils';
import { log, warn } from 'console';
import { openQueryDb } from '../query/db';
import { QueryDbIndexer } from '../query/db/import/QueryDbIndexer';
import { numProcessed } from '../../rpc/index/numProcessed';
import { search } from '../../rpc/search/search';
import appmapFilter from '../../rpc/appmap/filter';
Expand Down Expand Up @@ -51,6 +53,10 @@ export const builder = (args: yargs.Argv) => {
type: 'number',
alias: 'p',
});
args.option('query-db', {
describe: 'path to query.db (overrides default ~/.appmap/data/<sha>/query.db)',
type: 'string',
});
args.option('navie-provider', {
describe: 'navie provider to use',
type: 'string',
Expand Down Expand Up @@ -78,11 +84,19 @@ export const handler = async (argv) => {
const runServer = watch || port !== undefined;
if (port && !watch) warn(`Note: --port option implies --watch`);

const queryDb = openQueryDb(appmapDir, argv.queryDb as string | undefined);
const indexer = new QueryDbIndexer(queryDb.db);
log(
`Query DB at ${queryDb.path} (schema v${queryDb.version}${
queryDb.rebuilt ? ', rebuilt' : ''
})`
);

if (runServer) {
void checkLicense(false);

log(`Running indexer in watch mode`);
const cmd = new FingerprintWatchCommand(appmapDir);
const cmd = new FingerprintWatchCommand(appmapDir, indexer);
await cmd.execute();

if (port !== undefined) {
Expand Down Expand Up @@ -149,7 +163,8 @@ export const handler = async (argv) => {
}
}
} else {
const cmd = new FingerprintDirectoryCommand(appmapDir);
const cmd = new FingerprintDirectoryCommand(appmapDir, indexer);
await cmd.execute();
indexer.close();
}
};
69 changes: 69 additions & 0 deletions packages/cli/src/cmds/query/db/import/QueryDbIndexer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { resolve } from 'path';

import sqlite3 from 'better-sqlite3';

import { findFiles } from '../../../../utils';
import { deleteAppmap, importAppmap } from './importAppmap';

// Subscribes to fingerprint pipeline events and routes per-file work into
// the query DB. Owns no policy beyond "import on index, delete on unlink";
// callers wire it up to whichever queue/watcher fits the command shape.
//
// Failure handling: per-file errors (bad JSON, missing fields) are logged
// and skipped; the walk does not abort. DB-level errors still propagate —
// those indicate a real bug, not bad data.

interface IndexEmitter {
on(event: 'index', listener: (ev: { path: string }) => void): unknown;
}

export class QueryDbIndexer {
private imported = 0;
private failed = 0;

constructor(private readonly db: sqlite3.Database) {}

// Subscribe to a FingerprintQueue (or anything matching its 'index' event
// shape) so each successfully fingerprinted file is also imported.
attach(queue: IndexEmitter): void {
queue.on('index', (ev) => this.onIndexed(ev.path));
}

// Walk a directory and import any .appmap.json that doesn't already have
// a row in the appmaps table. Bridges the gap when query.db is fresh but
// fingerprints already exist (so the fingerprinter skips them and never
// emits an 'index' event for the importer to catch).
async syncDirectory(directory: string): Promise<void> {
const present = this.db.prepare('SELECT 1 FROM appmaps WHERE source_path = ?');
await findFiles(directory, '.appmap.json', (file) => {
const absolutePath = resolve(file);
if (!present.get(absolutePath)) this.onIndexed(absolutePath);
});
}

onIndexed(file: string): void {
try {
importAppmap(this.db, file);
this.imported += 1;
} catch (err) {
this.failed += 1;
console.warn(`query db: failed to import ${file}: ${(err as Error).message}`);
}
}

onRemoved(file: string): void {
try {
deleteAppmap(this.db, file);
} catch (err) {
console.warn(`query db: failed to delete ${file}: ${(err as Error).message}`);
}
}

stats(): { imported: number; failed: number } {
return { imported: this.imported, failed: this.failed };
}

close(): void {
this.db.close();
}
}
98 changes: 98 additions & 0 deletions packages/cli/src/cmds/query/db/import/appmapRecord.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { statSync } from 'fs';
import { basename } from 'path';

import sqlite3 from 'better-sqlite3';

export interface AppmapMetadata {
name?: string;
language?: { name?: string };
frameworks?: { name?: string }[];
recorder?: { type?: string };
git?: { repository?: string; branch?: string; commit?: string };
timestamp?: number;
labels?: unknown;
}

export interface ParsedAppmap {
events?: Record<string, unknown>[];
metadata?: AppmapMetadata;
classMap?: unknown;
}

export interface AppmapRecordResult {
appmapId: number;
timestampIso: string;
}

// Insert the top-level appmaps row and return its id and resolved timestamp.
//
// Total elapsed is taken from the first return event carrying an
// http_server_response. If metadata.timestamp is missing, falls back to the
// file's mtime so time-range queries still work.
export function insertAppmapRecord(
db: sqlite3.Database,
absolutePath: string,
appmap: ParsedAppmap
): AppmapRecordResult {
const events = appmap.events ?? [];
const metadata = appmap.metadata ?? {};

let totalElapsedMs: number | null = null;
for (const ev of events) {
if (ev.event === 'return' && 'http_server_response' in ev) {
const elapsed = ev.elapsed;
if (typeof elapsed === 'number') totalElapsedMs = elapsed * 1000;
break;
}
}

let sqlQueryCount = 0;
let httpRequestCount = 0;
for (const ev of events) {
if ('sql_query' in ev) sqlQueryCount += 1;
if ('http_server_request' in ev) httpRequestCount += 1;
}

const language = metadata.language?.name ?? null;
const framework = metadata.frameworks?.[0]?.name ?? null;
const recorderType = metadata.recorder?.type ?? null;
const git = metadata.git ?? {};

let timestampIso: string;
if (typeof metadata.timestamp === 'number') {
timestampIso = new Date(metadata.timestamp * 1000).toISOString();
} else {
timestampIso = statSync(absolutePath).mtime.toISOString();
}

const labels = metadata.labels;
const metadataLabelsJson = labels ? JSON.stringify(labels) : null;
const name = metadata.name ?? basename(absolutePath);

const info = db
.prepare(
`INSERT INTO appmaps (name, source_path, language, framework, recorder_type,
git_repository, git_branch, git_commit, timestamp,
event_count, sql_query_count, http_request_count, elapsed_ms,
metadata_labels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
name,
absolutePath,
language,
framework,
recorderType,
git.repository ?? null,
git.branch ?? null,
git.commit ?? null,
timestampIso,
events.length,
sqlQueryCount,
httpRequestCount,
totalElapsedMs,
metadataLabelsJson
);

return { appmapId: Number(info.lastInsertRowid), timestampIso };
}
Loading
Loading