Serge Huber created UNOMI-920:
---------------------------------
Summary: Improve toString() Methods with YAML Formatting for
Better Debugging
Key: UNOMI-920
URL: https://issues.apache.org/jira/browse/UNOMI-920
Project: Apache Unomi
Issue Type: Bug
Components: unomi(-core)
Affects Versions: unomi-3.0.0, unomi-3.1.0
Reporter: Serge Huber
Fix For: unomi-3.1.0
h2. Problem Statement
Many core API classes in Unomi currently have poor or missing {{toString()}}
implementations, making debugging and logging difficult. When these objects are
logged or inspected, the output is either:
* Missing entirely (uses {{Object.toString()}})
* Hard to read (StringBuilder-based output with nested structures)
* Incomplete (inherited implementations that only show metadata, not nested
objects)
This is particularly problematic for complex objects with nested structures
like:
* {{Profile}} with nested {{Consent}} objects, maps, and sets
* {{Event}} with nested {{Profile}}, {{Session}}, and complex property maps
* {{ContextRequest}}/{{ContextResponse}} with multiple nested lists and maps
* {{Rule}}/{{Segment}}/{{Campaign}} with nested {{Condition}} objects
h2. Proposed Solution
Implement YAML-formatted {{toString()}} methods for all complex API classes,
following a consistent pattern that:
* Uses a lightweight YAML formatter (SnakeYaml) for readable output
* Handles circular references properly (e.g., Profile ↔ Event ↔ Session)
* Shows nested structures clearly with proper indentation
* Provides a fluent API for building YAML maps
h2. Proposed Pattern (Example Implementation)
The following pattern should be implemented first for Condition and
ConditionType classes, which will serve as reference implementations:
{code:java}
public class Condition {
public Map<String, Object> toYaml(Set<Object> visited) {
// Use IdentityHashMap for visited set to ensure identity-based
comparison
if (visited.contains(this)) {
return YamlUtils.circularRef(); // Returns {"$ref": "circular"}
}
visited.add(this);
try {
return YamlUtils.YamlMapBuilder.create()
.put("type", conditionTypeId)
.putIfNotEmpty("parameters", parameterValues)
.putIfNotNull("nestedCondition", nestedCondition != null ?
nestedCondition.toYaml(visited) : null) // Pass visited set
.build();
} finally {
visited.remove(this); // Clean up to allow same object in different
branches
}
}
@Override
public String toString() {
// Use IdentityHashMap for proper identity-based comparison
return YamlUtils.format(toYaml(Collections.newSetFromMap(new
IdentityHashMap<>())));
}
}
{code}
Key aspects of this pattern:
* {{toYaml(Set<Object> visited)}} method returns {{Map<String, Object>}} for
conversion
* Uses {{IdentityHashMap}}-based visited set for circular reference detection
(identity-based, not equality-based)
* Uses {{YamlUtils.YamlMapBuilder}} fluent API for conditional field inclusion
* Try/finally ensures visited set cleanup and allows same object in different
branches
* Passes visited set to nested object's {{toYaml()}} method
* {{toString()}} delegates to {{YamlUtils.format()}} with IdentityHashMap-based
visited set
*Note:* Condition and ConditionType should be implemented first in Phase 1 to
establish this pattern, which will then be followed for all other classes.
h2. Classes That Would Benefit
h3. High Priority (Critical - No toString())
* *Condition* - Core class used throughout Unomi, has nested ConditionType and
recursive Condition structures. This should be implemented first as it serves
as the reference pattern for other classes.
* *ConditionType* - Defines condition structure, has nested Parameter and
ConditionValidation objects. Should be implemented alongside Condition as they
are closely related.
* *Event* - Frequently logged/debugged, has nested Profile, Session, Item
objects
* *ContextRequest* - API request class with nested Event lists,
PersonalizedContent, Profile
* *ContextResponse* - API response class with multiple maps, sets, and TraceNode
* *Tenant* - Complex configuration with nested ResourceQuota and ApiKey lists
* *Session* - Frequently debugged, has nested Profile
* *Action* - Similar structure to Condition, used in Rules
* *TraceNode* - Recursive structure (List<TraceNode> children), used in
ContextResponse
h3. Medium Priority (Poor toString() - Inherits Basic Implementation)
* *Profile* - Has StringBuilder-based toString() but nested structures are hard
to read
* *Rule* - Inherits MetadataItem.toString(), doesn't show Condition or Actions
* *Segment* - Inherits MetadataItem.toString(), doesn't show Condition
* *Campaign* - Inherits MetadataItem.toString(), doesn't show entryCondition or
dates
* *Goal* - Inherits MetadataItem.toString(), doesn't show
startEvent/targetEvent Conditions
* *ActionType* - Inherits MetadataItem.toString(), doesn't show parameters
h3. Lower Priority (Nested Classes and Supporting Classes)
* *Query* - Has nested Condition
* *BatchUpdate* - Has nested Condition
* *Patch* - Has complex data field
* *EventsCollectorRequest* - Has List<Event>
* *PersonalizationService.PersonalizedContent* - Inner class, nested in
ContextRequest
* *PersonalizationService.PersonalizationRequest* - Inner class, nested in
ContextRequest
* *PersonalizationService.Filter* - Inner class, has nested Condition
* *PersonalizationService.Target* - Simple inner class
* *PersonalizationResult* - Used in ContextResponse
* *ApiKey* - Used in Tenant
* *ResourceQuota* - Used in Tenant
* *Consent* - Has basic toString() but could be improved for consistency
h2. Implementation Notes
h3. Infrastructure
The following utilities would be needed (or already exist as examples):
* {{YamlUtils}} - Wrapper around SnakeYaml with fluent API
* {{YamlUtils.YamlMapBuilder}} - Fluent builder for conditional map construction
* {{YamlUtils.toYamlValue()}} - Recursive value conversion
* {{YamlUtils.circularRef()}} - Creates circular reference markers
h3. Circular Reference Handling
Classes with bidirectional references need special handling:
* Profile ↔ Event ↔ Session (bidirectional references)
* TraceNode has recursive structure: List<TraceNode> children (parent-child
relationships)
*Solution Pattern:*
{code:java}
public Map<String, Object> toYaml(Set<Object> visited) {
// Use IdentityHashMap for visited set to ensure identity-based comparison
if (visited.contains(this)) {
return YamlUtils.circularRef(); // Returns {"$ref": "circular"}
}
visited.add(this);
try {
return YamlUtils.YamlMapBuilder.create()
.put("field1", value1)
.put("nestedObject", nestedObject != null ?
nestedObject.toYaml(visited) : null)
.build();
} finally {
visited.remove(this); // Clean up to allow same object in different
branches
}
}
@Override
public String toString() {
// Use IdentityHashMap for proper identity-based comparison
return YamlUtils.format(toYaml(Collections.newSetFromMap(new
IdentityHashMap<>())));
}
{code}
*Key Implementation Points:*
* *IdentityHashMap for visited sets* - Critical for proper identity comparison
(not equality-based). Regular HashSet uses {{equals()}} which may not detect
cycles correctly for objects with custom equality.
* *Cycle detection* - Check {{visited.contains(this)}} at the start of
{{toYaml()}} method
* *Try/finally cleanup* - Ensures visited set is cleaned up even if exceptions
occur, and allows same object to appear in different branches of the object
graph
* *Circular reference marker* - Return {{YamlUtils.circularRef()}} when cycle
detected (produces {{"$ref": "circular"}} in YAML output)
* *Pass visited set* - Always pass the visited set to nested object's
{{toYaml()}} method when traversing object graphs
*Special Cases:*
* *Profile ↔ Event ↔ Session:* Each class should use {{Set<Object>}} with
IdentityHashMap to handle heterogeneous object graphs
* *TraceNode recursive structure:* Use {{Set<TraceNode>}} with IdentityHashMap
for children traversal, or {{Set<Object>}} if mixing with other types
* *Mixed object graphs:* Use {{Set<Object>}} with IdentityHashMap when
traversing heterogeneous object graphs (most common case)
*Example for Event with Profile/Session references:*
{code:java}
public Map<String, Object> toYaml(Set<Object> visited) {
if (visited.contains(this)) {
return YamlUtils.circularRef();
}
visited.add(this);
try {
return YamlUtils.YamlMapBuilder.create()
.put("itemId", itemId)
.put("eventType", eventType)
.put("properties", properties)
.putIfNotNull("profile", profile != null ?
profile.toYaml(visited) : null) // Pass visited set
.putIfNotNull("session", session != null ?
session.toYaml(visited) : null) // Pass visited set
.putIfNotNull("source", source != null ?
source.toYaml(visited) : null) // Pass visited set
.build();
} finally {
visited.remove(this);
}
}
{code}
*Why IdentityHashMap?*
Regular {{HashSet}} uses {{equals()}} and {{hashCode()}} for comparison. For
objects with custom equality (like many Unomi classes), two different object
instances might be considered "equal" but are actually different objects in
memory. This can cause:
* False cycle detection (thinks it's seen an object when it hasn't)
* Missing cycle detection (doesn't detect actual cycles)
{{IdentityHashMap}} uses object identity ({{==}}) for comparison, ensuring we
track actual object instances, not just "equal" objects. This is critical for
proper cycle detection in object graphs.
h3. Inheritance Considerations
* Classes extending {{Item}} should include Item fields (itemId, itemType,
scope, version, systemMetadata, audit fields)
* Classes extending {{MetadataItem}} should include metadata fields
* Consider helper methods in base classes if many classes need similar handling
h2. Benefits
* *Improved Debugging* - Logs and stack traces will show readable YAML instead
of object references
* *Better Developer Experience* - Developers can easily inspect complex objects
during development
* *Consistency* - All complex objects follow the same output format
* *Copy-Paste Friendly* - YAML output can be easily copied and reformatted for
documentation or testing
h2. Implementation Phases
h3. Phase 1: Critical Classes (No toString())
Implement for: Condition, ConditionType (reference implementations), Event,
ContextRequest, ContextResponse, Tenant, Session, Action, TraceNode
*Note:* Condition and ConditionType should be implemented first as they
establish the pattern and serve as reference implementations for other classes.
h3. Phase 2: Poor toString() Classes
Replace existing implementations for: Profile, Rule, Segment, Campaign, Goal,
ActionType
h3. Phase 3: Nested Classes
Implement for PersonalizationService inner classes when implementing
ContextRequest
h3. Phase 4: Supporting Classes
Implement for Query, BatchUpdate, Patch, EventsCollectorRequest, and other
supporting classes
h2. Example Output
Instead of:
{code}
org.apache.unomi.api.Profile@12345678
{code}
Or:
{code}
Profile{itemId=profile-123, properties={key1=value1, key2=value2}, ...}
{code}
We would get:
{code:yaml}
itemId: profile-123
itemType: profile
scope: systemscope
properties:
key1: value1
key2: value2
segments:
- segment1
- segment2
consents:
consent1:
scope: systemscope
typeIdentifier: type1
status: GRANTED
{code}
When circular references are detected, they are marked clearly:
{code:yaml}
itemId: event-123
eventType: view
profile:
itemId: profile-123
# ... profile fields ...
# If this profile references back to the event, it would show:
session:
itemId: session-123
profile:
$ref: circular # Circular reference marker
{code}
h2. Acceptance Criteria
* All high-priority classes have YAML-based {{toString()}} implementations
* Circular references are handled correctly (no infinite loops)
* Output is readable and properly formatted YAML
* Implementation follows the established pattern (toYaml() method + toString()
delegation)
* No performance regression in logging/debugging scenarios
* Unit tests verify YAML output format for key classes
h2. Related Work
This improvement would complement existing work on improving condition
evaluation and debugging capabilities. Condition and ConditionType should be
implemented first in Phase 1 to establish the pattern and serve as reference
implementations for other classes.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)