Skip to content

Commit 75e469e

Browse files
committed
refactor(agent): Refactor and optimize AppMapPackage
This commit comprehensively refactors the AppMapPackage class to improve readability, performance, and maintainability. Replace linear exclusion matching with a PrefixTrie data structure, reducing lookup complexity from O(N*M) to O(M), where N is the number of exclusion patterns and M is the length of the class name. This provides dramatic performance improvements for configurations with many exclusion patterns. Exclusion patterns can now be specified relative to the package path (e.g., "internal" instead of "com.example.internal"), improving configuration clarity while maintaining backward compatibility. Add comprehensive documentation explaining the two mutually exclusive configuration modes (exclude mode vs. methods mode). Refactor the complex find() method into three clear helpers with explicit mode detection. Add a warning when both 'exclude' and 'methods' are specified, making the precedence rules explicit to users. Enhance LabelConfig to support matching against both simple and fully qualified class names for better user experience. Remove unused class resolution logic. Add 42 comprehensive tests covering both configuration modes, edge cases, regex patterns, backward compatibility, and complex scenarios. - Fix NullPointerException when 'exclude' field is empty in appmap.yml - Fix package boundary matching (prevent "com.examples" matching "com.example") - Remove unused 'allMethods' field (added in 2022, never implemented) - Remove obsolete pattern threshold warning (no longer needed with PrefixTrie) - Clean up unused imports
1 parent 6704c09 commit 75e469e

7 files changed

Lines changed: 1251 additions & 76 deletions

File tree

agent/src/main/java/com/appland/appmap/config/AppMapConfig.java

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -143,15 +143,6 @@ static AppMapConfig load(Path configFile, boolean mustExist) {
143143
singleton.configFile = configFile;
144144
logger.debug("config: {}", singleton);
145145

146-
int count = singleton.packages.length;
147-
count = Arrays.stream(singleton.packages).map(p -> p.exclude).reduce(count,
148-
(acc, e) -> acc += e.length, Integer::sum);
149-
150-
int pattern_threshold = Properties.PatternThreshold;
151-
if (count > pattern_threshold) {
152-
logger.warn("{} patterns found in config, startup performance may be impacted", count);
153-
}
154-
155146
return singleton;
156147
}
157148

Lines changed: 202 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,96 @@
11
package com.appland.appmap.config;
22

3-
import static com.appland.appmap.util.ClassUtil.safeClassForName;
4-
53
import java.util.regex.Pattern;
64

75
import org.tinylog.TaggedLogger;
86

9-
import com.appland.appmap.transform.annotations.CtClassUtil;
107
import com.appland.appmap.util.FullyQualifiedName;
8+
import com.appland.appmap.util.PrefixTrie;
119
import com.fasterxml.jackson.annotation.JsonCreator;
1210
import com.fasterxml.jackson.annotation.JsonProperty;
1311

1412
import javassist.CtBehavior;
13+
14+
/**
15+
* Represents a package configuration for AppMap recording.
16+
*
17+
* <p>
18+
* Configuration modes (mutually exclusive):
19+
* <ul>
20+
* <li><b>Exclude mode:</b> When {@code methods} is null, records all methods in
21+
* the package
22+
* except those matching {@code exclude} patterns.</li>
23+
* <li><b>Methods mode:</b> When {@code methods} is set, records only methods
24+
* matching the
25+
* specified patterns. The {@code exclude} field is ignored in this mode.</li>
26+
* </ul>
27+
*
28+
* @see <a href=
29+
* "https://appmap.io/docs/reference/appmap-java.html#configuration">AppMap
30+
* Java Configuration</a>
31+
*/
1532
public class AppMapPackage {
1633
private static final TaggedLogger logger = AppMapConfig.getLogger(null);
1734
private static String tracePrefix = Properties.DebugClassPrefix;
1835

1936
public String path;
37+
public final String packagePrefix;
2038
public String[] exclude = new String[] {};
2139
public boolean shallow = false;
22-
public Boolean allMethods = true;
40+
private final PrefixTrie excludeTrie = new PrefixTrie();
2341

24-
public static class LabelConfig {
42+
@JsonCreator
43+
public AppMapPackage(@JsonProperty("path") String path,
44+
@JsonProperty("exclude") String[] exclude,
45+
@JsonProperty("shallow") Boolean shallow,
46+
@JsonProperty("methods") LabelConfig[] methods) {
47+
this.path = path;
48+
this.exclude = exclude == null ? new String[] {} : exclude;
49+
this.shallow = shallow != null && shallow;
50+
this.methods = methods;
51+
this.packagePrefix = this.path == null ? "!!dummy!!" : this.path + ".";
52+
53+
// Warn if both exclude and methods are specified (methods takes precedence)
54+
if (exclude != null && exclude.length > 0 && methods != null && methods.length > 0) {
55+
logger.warn("Package '{}': both 'exclude' and 'methods' are specified. " +
56+
"The 'exclude' field will be ignored when 'methods' is set.", path);
57+
}
58+
59+
// Build the exclusion trie only if we're in exclude mode
60+
if (exclude != null && methods == null) {
61+
for (String exclusion : exclude) {
62+
// Allow exclusions to use both '.' and '#' as separators
63+
// for backward compatibility
64+
exclusion = exclusion.replace('#', '.');
65+
if (exclusion.startsWith(this.packagePrefix)) {
66+
// Absolute path: strip the package prefix
67+
this.excludeTrie.insert(exclusion.substring(this.packagePrefix.length()));
68+
} else {
69+
// Relative path: use as-is
70+
this.excludeTrie.insert(exclusion);
71+
}
72+
}
73+
}
74+
}
2575

76+
/**
77+
* Configuration for matching specific methods with labels.
78+
* Used in "methods mode" to specify which methods to record.
79+
*/
80+
public static class LabelConfig {
2681
private Pattern className = null;
2782
private Pattern name = null;
28-
2983
private String[] labels = new String[] {};
30-
private Class<?> cls;
3184

85+
/** Empty constructor for exclude mode (no labels). */
3286
public LabelConfig() {}
3387

3488
@JsonCreator
35-
public LabelConfig(@JsonProperty("class") String className, @JsonProperty("name") String name,
89+
public LabelConfig(@JsonProperty("class") String className,
90+
@JsonProperty("name") String name,
3691
@JsonProperty("labels") String[] labels) {
92+
// Anchor patterns to match whole symbols only
3793
this.className = Pattern.compile("\\A(" + className + ")\\z");
38-
this.cls = safeClassForName(Thread.currentThread().getContextClassLoader(), className);
39-
logger.trace("this.cls: {}", this.cls);
4094
this.name = Pattern.compile("\\A(" + name + ")\\z");
4195
this.labels = labels;
4296
}
@@ -45,99 +99,183 @@ public String[] getLabels() {
4599
return this.labels;
46100
}
47101

48-
public boolean matches(FullyQualifiedName name) {
49-
return matches(name.className, name.methodName);
50-
}
51-
52-
public boolean matches(String className, String methodName) {
53-
boolean traceClass = tracePrefix == null || className.startsWith(tracePrefix);
54-
Class<?> cls = safeClassForName(Thread.currentThread().getContextClassLoader(), className);
55-
56-
if (traceClass) {
57-
logger.trace("this.cls: {} cls: {}, isChildOf?: {}", this.cls, cls, CtClassUtil.isChildOf(cls, this.cls));
102+
/**
103+
* Checks if the given fully qualified name matches this configuration.
104+
* Supports matching against both simple and fully qualified class names for
105+
* flexibility.
106+
*
107+
* @param fqn the fully qualified name to check
108+
* @return true if the patterns match
109+
*/
110+
public boolean matches(FullyQualifiedName fqn) {
111+
// Try matching with simple class name (package-relative)
112+
if (matches(fqn.className, fqn.methodName)) {
113+
return true;
58114
}
59115

60-
return this.className.matcher(className).matches() && this.name.matcher(methodName).matches();
116+
// Also try matching with fully qualified class name for better UX
117+
String fullyQualifiedClassName = fqn.getClassName();
118+
return matches(fullyQualifiedClassName, fqn.methodName);
61119
}
62120

121+
/**
122+
* Checks if the given class name and method name match this configuration.
123+
*
124+
* @param className the class name (simple or fully qualified)
125+
* @param methodName the method name
126+
* @return true if both patterns match
127+
*/
128+
public boolean matches(String className, String methodName) {
129+
return this.className.matcher(className).matches()
130+
&& this.name.matcher(methodName).matches();
131+
}
63132
}
64133

65134
public LabelConfig[] methods = null;
66135

67136
/**
68-
* Check if a class/method is included in the configuration.
69-
*
70-
* @param canonicalName the canonical name of the class/method to be checked
71-
* @return {@code true} if the class/method is included in the configuration. {@code false} if it
72-
* is not included or otherwise explicitly excluded.
137+
* Determines if a class/method should be recorded based on this package
138+
* configuration.
139+
*
140+
* <p>
141+
* Behavior depends on configuration mode:
142+
* <ul>
143+
* <li><b>Exclude mode</b> ({@code methods} is null): Returns a LabelConfig for
144+
* methods
145+
* in this package that are not explicitly excluded.</li>
146+
* <li><b>Methods mode</b> ({@code methods} is set): Returns a LabelConfig only
147+
* for methods
148+
* that match the specified patterns. The {@code exclude} field is ignored.</li>
149+
* </ul>
150+
*
151+
* @param canonicalName the fully qualified name of the method to check
152+
* @return the label config if the method should be recorded, or null otherwise
73153
*/
74154
public LabelConfig find(FullyQualifiedName canonicalName) {
75-
String className = canonicalName != null ? canonicalName.getClassName() : null;
76-
boolean traceClass = tracePrefix == null || className.startsWith(tracePrefix);
77-
if (traceClass) {
78-
logger.trace(canonicalName);
155+
// Early validation
156+
if (this.path == null || canonicalName == null) {
157+
return null;
79158
}
80159

81-
if (this.path == null) {
82-
return null;
160+
// Debug logging
161+
if (tracePrefix == null || canonicalName.getClassName().startsWith(tracePrefix)) {
162+
logger.trace("Checking {}", canonicalName);
83163
}
84164

85-
if (canonicalName == null) {
86-
return null;
165+
if (isExcludeMode()) {
166+
return findInExcludeMode(canonicalName);
167+
} else {
168+
return findInMethodsMode(canonicalName);
87169
}
170+
}
88171

89-
// If no method configs are set, use the old matching behavior.
90-
if (this.methods == null) {
91-
if (!canonicalName.toString().startsWith(this.path)) {
172+
/**
173+
* Checks if this package is configured in exclude mode (records everything
174+
* except exclusions).
175+
*/
176+
private boolean isExcludeMode() {
177+
return this.methods == null;
178+
}
179+
180+
/**
181+
* Finds a method in exclude mode: match if in package and not excluded.
182+
*/
183+
private LabelConfig findInExcludeMode(FullyQualifiedName canonicalName) {
184+
String canonicalString = canonicalName.toString();
185+
186+
// Check if the method is in this package or a subpackage
187+
if (!canonicalString.startsWith(this.path)) {
188+
return null;
189+
} else if (canonicalString.length() > this.path.length()) {
190+
// Must either equal the path exactly or start with "path." or "path#"
191+
// The "#" check is needed for unnamed packages
192+
// or when path specifies a class name
193+
final char nextChar = canonicalString.charAt(this.path.length());
194+
if (nextChar != '.' && nextChar != '#') {
92195
return null;
93196
}
197+
}
94198

95-
return this.excludes(canonicalName) ? null : new LabelConfig();
199+
// Check if it's explicitly excluded
200+
if (this.excludes(canonicalName)) {
201+
return null;
96202
}
97203

204+
// Include it (no labels in exclude mode)
205+
return new LabelConfig();
206+
}
207+
208+
/**
209+
* Finds a method in methods mode: match only if it matches a configured
210+
* pattern.
211+
*/
212+
private LabelConfig findInMethodsMode(FullyQualifiedName canonicalName) {
213+
// Must be in the exact package (not subpackages)
98214
if (!canonicalName.packageName.equals(this.path)) {
99215
return null;
100216
}
101217

102-
for (LabelConfig ls : this.methods) {
103-
if (ls.matches(canonicalName)) {
104-
return ls;
218+
// Check each method pattern
219+
for (LabelConfig config : this.methods) {
220+
if (config.matches(canonicalName)) {
221+
return config;
105222
}
106223
}
107224

108225
return null;
109226
}
110227

111228
/**
112-
* Returns whether or not the canonical name is explicitly excluded
113-
*
114-
* @param canonicalName the canonical name of the class/method to be checked
229+
* Converts a fully qualified class name to a package-relative name.
230+
* For example, "com.example.foo.Bar" with package "com.example" becomes
231+
* "foo.Bar".
232+
*
233+
* @param fqcn the fully qualified class name
234+
* @return the relative class name, or the original if it doesn't start with the
235+
* package prefix
236+
*/
237+
private String getRelativeClassName(String fqcn) {
238+
if (fqcn.startsWith(this.packagePrefix)) {
239+
return fqcn.substring(this.packagePrefix.length());
240+
}
241+
return fqcn;
242+
}
243+
244+
/**
245+
* Checks whether a behavior is explicitly excluded by this package
246+
* configuration.
247+
* Only meaningful in exclude mode; in methods mode, use {@link #find} instead.
248+
*
249+
* @param behavior the behavior to check
250+
* @return true if the behavior matches an exclusion pattern
115251
*/
116252
public Boolean excludes(CtBehavior behavior) {
117-
FullyQualifiedName fqn = null;
118-
for (String exclusion : this.exclude) {
119-
if (behavior.getDeclaringClass().getName().startsWith(exclusion)) {
120-
return true;
121-
} else {
122-
if (fqn == null) {
123-
fqn = new FullyQualifiedName(behavior);
124-
}
125-
if (fqn.toString().startsWith(exclusion)) {
126-
return true;
127-
}
128-
}
253+
String fqClass = behavior.getDeclaringClass().getName();
254+
String relativeClassName = getRelativeClassName(fqClass);
255+
256+
// Check if the class itself is excluded
257+
if (this.excludeTrie.startsWith(relativeClassName)) {
258+
return true;
129259
}
130260

131-
return false;
261+
// Check if the specific method is excluded
262+
String methodName = behavior.getName();
263+
String relativeMethodPath = String.format("%s.%s", relativeClassName, methodName);
264+
return this.excludeTrie.startsWith(relativeMethodPath);
132265
}
133266

267+
/**
268+
* Checks whether a fully qualified method name is explicitly excluded.
269+
* Only meaningful in exclude mode; in methods mode, use {@link #find} instead.
270+
*
271+
* @param canonicalName the fully qualified method name
272+
* @return true if the method matches an exclusion pattern
273+
*/
134274
public Boolean excludes(FullyQualifiedName canonicalName) {
135-
for (String exclusion : this.exclude) {
136-
if (canonicalName.toString().startsWith(exclusion)) {
137-
return true;
138-
}
139-
}
140-
141-
return false;
275+
String fqcn = canonicalName.toString();
276+
String relativeName = getRelativeClassName(fqcn);
277+
// Convert # to . to match the format stored in the trie
278+
relativeName = relativeName.replace('#', '.');
279+
return this.excludeTrie.startsWith(relativeName);
142280
}
143281
}

agent/src/main/java/com/appland/appmap/config/Properties.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,6 @@ public class Properties {
4141

4242
public static final String DefaultConfigFile = "appmap.yml";
4343
public static final String ConfigFile = resolveProperty("appmap.config.file", (String) null);
44-
public static final Integer PatternThreshold =
45-
resolveProperty("appmap.config.patternThreshold", 10);
4644

4745
public static final Boolean DisableValue = resolveProperty("appmap.event.disableValue", false);
4846
public static final Integer MaxValueSize = resolveProperty("appmap.event.valueSize", 1024);

0 commit comments

Comments
 (0)