Skip to content

Commit c58e618

Browse files
committed
feat: Full text search of AppMaps and Code Objects
1 parent 3d07dfb commit c58e618

File tree

8 files changed

+340
-2
lines changed

8 files changed

+340
-2
lines changed

packages/cli/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
"@types/jest": "^29.5.4",
4444
"@types/jest-sinon": "^1.0.2",
4545
"@types/jsdom": "^16.2.13",
46+
"@types/lunr": "^2.3.7",
47+
"@types/moo": "^0.5.5",
4648
"@types/node": "^16",
4749
"@types/semver": "^7.3.10",
4850
"@types/sinon": "^10.0.2",
@@ -86,7 +88,6 @@
8688
"@appland/sequence-diagram": "workspace:^1.7.0",
8789
"@octokit/rest": "^20.0.1",
8890
"@sidvind/better-ajv-errors": "^0.9.1",
89-
"@types/moo": "^0.5.5",
9091
"JSONStream": "^1.3.5",
9192
"ajv": "^8.6.3",
9293
"applicationinsights": "^2.1.4",
@@ -109,6 +110,7 @@
109110
"inquirer": "^8.1.2",
110111
"js-yaml": "^4.0.3",
111112
"jsdom": "^16.6.0",
113+
"lunr": "^2.3.9",
112114
"minimatch": "^5.1.2",
113115
"moo": "^0.5.1",
114116
"open": "^8.2.1",

packages/cli/src/fingerprint/canonicalize.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const algorithms = {
1010
packageDependencies: require('./canonicalize/packageDependencies'),
1111
sqlNormalized: require('./canonicalize/sqlNormalized'),
1212
sqlTables: require('./canonicalize/sqlTables'),
13+
parameters: require('./canonicalize/parameters'),
1314
};
1415
/* eslint-enable global-require */
1516

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/* eslint-disable class-methods-use-this */
2+
const Unique = require('./unique');
3+
const { collectParameters } = require('../../fulltext/collectParameters');
4+
5+
class Canonicalize extends Unique {
6+
functionCall(event) {
7+
return collectParameters(event);
8+
}
9+
10+
httpServerRequest(event) {
11+
return collectParameters(event);
12+
}
13+
}
14+
15+
module.exports = (appmap) => new Canonicalize(appmap).execute();

packages/cli/src/fingerprint/fingerprinter.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ const renameFile = promisify(gracefulFs.rename);
1616
/**
1717
* CHANGELOG
1818
*
19+
* * # 1.4.0
20+
*
21+
* * Include parameter names in the index.
22+
*
1923
* * # 1.3.0
2024
*
2125
* * Include exceptions in the index.
@@ -49,7 +53,7 @@ const renameFile = promisify(gracefulFs.rename);
4953
* * Fix handling of parent assignment in normalization.
5054
* * sql can contain the analysis (action, tables, columns), and/or the normalized query string.
5155
*/
52-
export const VERSION = '1.2.0';
56+
export const VERSION = '1.4.0';
5357

5458
const MAX_APPMAP_SIZE = 50 * 1024 * 1024;
5559

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { readFile } from 'fs/promises';
2+
import { exists, processNamedFiles, verbose } from '../utils';
3+
import { Metadata } from '@appland/models';
4+
import { dirname, join } from 'path';
5+
import { warn } from 'console';
6+
import lunr from 'lunr';
7+
import assert from 'assert';
8+
9+
const isCamelized = (str: string): boolean => {
10+
if (str.length < 3) return false;
11+
12+
const testStr = str.slice(1);
13+
return /[a-z][A-Z]/.test(testStr);
14+
};
15+
16+
export const splitCamelized = (str: string): string => {
17+
if (!isCamelized(str)) return str;
18+
19+
const result = new Array<string>();
20+
let last = 0;
21+
for (let i = 1; i < str.length; i++) {
22+
const pc = str[i - 1];
23+
const c = str[i];
24+
const isUpper = c >= 'A' && c <= 'Z';
25+
if (isUpper) {
26+
result.push(str.slice(last, i));
27+
last = i;
28+
}
29+
}
30+
result.push(str.slice(last));
31+
return result.join(' ');
32+
};
33+
34+
type SerializedCodeObject = {
35+
name: string;
36+
type: string;
37+
labels: string[];
38+
children: SerializedCodeObject[];
39+
static?: boolean;
40+
sourceLocation?: string;
41+
};
42+
43+
export type SearchOptions = {
44+
maxResults?: number;
45+
};
46+
47+
export type SearchResult = {
48+
appmap: string;
49+
score: number;
50+
};
51+
52+
export default class FindAppMaps {
53+
idx: lunr.Index | undefined;
54+
55+
constructor(public appmapDir: string) {}
56+
57+
async initialize() {
58+
const { appmapDir } = this;
59+
60+
const documents = new Array<any>();
61+
await processNamedFiles(appmapDir, 'metadata.json', async (metadataFile) => {
62+
const metadata = JSON.parse(await readFile(metadataFile, 'utf-8')) as Metadata;
63+
const indexDir = dirname(metadataFile);
64+
const classMap = JSON.parse(
65+
await readFile(join(indexDir, 'classMap.json'), 'utf-8')
66+
) as SerializedCodeObject[];
67+
const queries = new Array<string>();
68+
const codeObjects = new Array<string>();
69+
const routes = new Array<string>();
70+
const externalRoutes = new Array<string>();
71+
72+
const collectFunction = (co: SerializedCodeObject) => {
73+
if (co.type === 'query') queries.push(co.name);
74+
else if (co.type === 'route') routes.push(co.name);
75+
else if (co.type === 'external-route') externalRoutes.push(co.name);
76+
else codeObjects.push(splitCamelized(co.name));
77+
78+
co.children?.forEach((child) => {
79+
collectFunction(child);
80+
});
81+
};
82+
classMap.forEach((co) => collectFunction(co));
83+
84+
const parameters = new Array<string>();
85+
if (await exists(join(indexDir, 'canonical.parameters.json'))) {
86+
const canonicalParameters = JSON.parse(
87+
await readFile(join(indexDir, 'canonical.parameters.json'), 'utf-8')
88+
) as string[];
89+
canonicalParameters.forEach((cp) => {
90+
parameters.push(splitCamelized(cp));
91+
});
92+
}
93+
94+
documents.push({
95+
id: indexDir,
96+
name: metadata.name,
97+
source_location: metadata.source_location,
98+
code_objects: codeObjects.join(' '),
99+
queries: queries.join(' '),
100+
routes: routes.join(' '),
101+
external_routes: externalRoutes.join(' '),
102+
parameters: parameters,
103+
});
104+
});
105+
106+
if (verbose()) warn(`Indexing ${documents.length} diagrams`);
107+
108+
this.idx = lunr(function () {
109+
this.ref('id');
110+
this.field('name');
111+
this.field('source_location');
112+
this.field('code_objects');
113+
this.field('queries');
114+
this.field('routes');
115+
this.field('external_routes');
116+
this.field('parameters');
117+
118+
this.tokenizer.separator = /[\s/-_:#.]+/;
119+
120+
for (const doc of documents) this.add(doc);
121+
});
122+
}
123+
124+
search(search: string, options: SearchOptions = {}): SearchResult[] {
125+
assert(this.idx);
126+
let matches = this.idx.search(search);
127+
if (verbose()) warn(`Got ${matches.length} matches for search ${search}`);
128+
if (options.maxResults && matches.length > options.maxResults) {
129+
if (verbose()) warn(`Limiting to the top ${options.maxResults} matches`);
130+
matches = matches.slice(0, options.maxResults);
131+
}
132+
return matches.map((match) => ({ appmap: match.ref, score: match.score }));
133+
}
134+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { AppMap, AppMapFilter, Event, buildAppMap } from '@appland/models';
2+
import { warn } from 'console';
3+
import { readFile } from 'fs/promises';
4+
import { verbose } from '../utils';
5+
import lunr from 'lunr';
6+
import { splitCamelized } from './FindAppMaps';
7+
import { collectParameters } from './collectParameters';
8+
import assert from 'assert';
9+
10+
type IndexItem = {
11+
fqid: string;
12+
name: string;
13+
location?: string;
14+
parameters: string[];
15+
eventIds: number[];
16+
elapsed?: number;
17+
};
18+
19+
export type SearchOptions = {
20+
maxResults?: number;
21+
};
22+
23+
export type SearchResult = {
24+
fqid: string;
25+
location?: string;
26+
score: number;
27+
eventIds: number[];
28+
elapsed?: number;
29+
};
30+
31+
export default class FindEvents {
32+
public maxSize?: number;
33+
public filter?: AppMapFilter;
34+
35+
idx: lunr.Index | undefined;
36+
indexItemsByFqid = new Map<string, IndexItem>();
37+
filteredAppMap?: AppMap;
38+
39+
constructor(public appmapIndexDir: string) {}
40+
41+
get appmapId() {
42+
return this.appmapIndexDir;
43+
}
44+
45+
get appmap() {
46+
assert(this.filteredAppMap);
47+
return this.filteredAppMap;
48+
}
49+
50+
async initialize() {
51+
const appmapFile = [this.appmapId, 'appmap.json'].join('.');
52+
const builder = buildAppMap().source(await readFile(appmapFile, 'utf-8'));
53+
if (this.maxSize) builder.prune(this.maxSize);
54+
55+
const baseAppMap = builder.build();
56+
57+
if (verbose()) warn(`Built AppMap with ${baseAppMap.events.length} events.`);
58+
if (verbose()) warn(`Applying default AppMap filters.`);
59+
60+
let filter = this.filter;
61+
if (!filter) {
62+
filter = new AppMapFilter();
63+
if (baseAppMap.metadata.language?.name !== 'java')
64+
filter.declutter.hideExternalPaths.on = true;
65+
filter.declutter.limitRootEvents.on = true;
66+
}
67+
const filteredAppMap = filter.filter(baseAppMap, []);
68+
if (verbose()) warn(`Filtered AppMap has ${filteredAppMap.events.length} events.`);
69+
if (verbose()) warn(`Indexing AppMap`);
70+
71+
const indexEvent = (event: Event, depth = 0) => {
72+
const co = event.codeObject;
73+
const parameters = collectParameters(event);
74+
if (!this.indexItemsByFqid.has(co.fqid)) {
75+
const name = splitCamelized(co.id);
76+
const item: IndexItem = {
77+
fqid: co.fqid,
78+
name,
79+
parameters,
80+
location: co.location,
81+
eventIds: [event.id],
82+
};
83+
if (event.elapsedTime) item.elapsed = event.elapsedTime;
84+
this.indexItemsByFqid.set(co.fqid, item);
85+
} else {
86+
const existing = this.indexItemsByFqid.get(co.fqid);
87+
if (existing) {
88+
existing.eventIds.push(event.id);
89+
if (event.elapsedTime) existing.elapsed = (existing.elapsed || 0) + event.elapsedTime;
90+
for (const parameter of parameters)
91+
if (!existing.parameters.includes(parameter)) existing.parameters.push(parameter);
92+
}
93+
}
94+
event.children.forEach((child) => indexEvent(child, depth + 1));
95+
};
96+
filteredAppMap.rootEvents().forEach((event) => indexEvent(event));
97+
98+
this.filteredAppMap = filteredAppMap;
99+
const self = this;
100+
this.idx = lunr(function () {
101+
this.ref('fqid');
102+
this.field('name');
103+
this.tokenizer.separator = /[\s/\-_:#.]+/;
104+
105+
self.indexItemsByFqid.forEach((item) => {
106+
let boost = 1;
107+
if (item.location) boost += 1;
108+
if (item.eventIds.length > 1) boost += 1;
109+
this.add(item, { boost });
110+
});
111+
});
112+
}
113+
114+
search(search: string, options: SearchOptions = {}): SearchResult[] {
115+
assert(this.idx);
116+
let matches = this.idx.search(search);
117+
if (verbose()) warn(`Got ${matches.length} matches for search ${search}`);
118+
if (options.maxResults && matches.length > options.maxResults) {
119+
if (verbose()) warn(`Limiting to the top ${options.maxResults} matches`);
120+
matches = matches.slice(0, options.maxResults);
121+
}
122+
return matches.map((match) => {
123+
const indexItem = this.indexItemsByFqid.get(match.ref);
124+
assert(indexItem);
125+
const result: SearchResult = {
126+
fqid: match.ref,
127+
score: match.score,
128+
elapsed: indexItem?.elapsed,
129+
eventIds: indexItem?.eventIds ?? [],
130+
};
131+
if (indexItem?.location) result.location = indexItem.location;
132+
return result;
133+
});
134+
}
135+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Event, ParameterObject, ParameterProperty } from '@appland/models';
2+
3+
export function collectParameters(event: Event): string[] {
4+
const result = new Array<string>();
5+
if (event.parameters) collectParameterNames(event.parameters, result);
6+
if (event.message) collectProperties(event.message, result);
7+
return result;
8+
}
9+
10+
export function collectParameterNames(
11+
parameters: readonly ParameterObject[],
12+
result: string[] = []
13+
) {
14+
parameters.forEach((parameter) => (parameter.name ? result.push(parameter.name) : undefined));
15+
return result;
16+
}
17+
18+
export function collectProperties(properties: readonly ParameterProperty[], result: string[] = []) {
19+
for (const property of properties) {
20+
if (property.name) {
21+
result.push(property.name);
22+
}
23+
if (property.items) {
24+
collectProperties(property.items, result);
25+
}
26+
if (property.properties) {
27+
collectProperties(property.properties, result);
28+
}
29+
}
30+
return result;
31+
}

0 commit comments

Comments
 (0)