Skip to content

Commit d4e6185

Browse files
committed
fix: keep a self-referencing trait map from escaping trackEvent as an Error
Serialising traits and metadata with valueToTree at buffer time let a map or list that contains itself throw a raw StackOverflowError out of trackEvent. The older flush-time serialisation had reported the same input as a JsonMappingException, so this was a regression from moving serialisation earlier. Each event's traits and metadata are now written with writeValueAsString, which reports every cycle as a checked JsonMappingException, and are held as RawValue that Jackson emits verbatim in the batch. The offending event is dropped and logged; the rest are unaffected. Nothing catches Error, and the class Javadoc now says so. Buffered JSON text is also more compact to hold than a tree.
1 parent ac499a2 commit d4e6185

2 files changed

Lines changed: 53 additions & 16 deletions

File tree

‎src/main/java/com/flagsmith/threads/EventProcessor.java‎

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
package com.flagsmith.threads;
22

3+
import com.fasterxml.jackson.core.JsonProcessingException;
34
import com.fasterxml.jackson.core.type.TypeReference;
45
import com.fasterxml.jackson.databind.JsonNode;
5-
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import com.fasterxml.jackson.databind.util.RawValue;
67
import com.flagsmith.FlagsmithLogger;
78
import com.flagsmith.MapperFactory;
89
import com.flagsmith.Versions;
@@ -39,8 +40,9 @@
3940
* Buffers experimentation events and ships them to the Flagsmith events API.
4041
*
4142
* <p>Events are flushed on a fixed interval, when the buffer fills up, and on {@link #close()}.
42-
* Exposure events are deduplicated within a flush window. Nothing thrown here ever reaches caller
43-
* code: every failure is logged instead.
43+
* Exposure events are deduplicated within a flush window. No exception thrown here reaches caller
44+
* code: every failure is logged instead, including traits or metadata that cannot be serialised,
45+
* which drop only their own event. {@link Error}s such as {@code OutOfMemoryError} are not caught.
4446
*/
4547
public class EventProcessor {
4648

@@ -342,20 +344,24 @@ private void bufferEvent(String event, String featureName, String identifier, Ob
342344
eventMetadata.put(SDK_VERSION_KEY, Versions.getVersion());
343345
final Object experimentId = eventMetadata.get(EXPERIMENT_ID_KEY);
344346

345-
// Traits and metadata are caller objects of any shape. Turning them into JSON trees here,
346-
// rather than at flush time, means a value Jackson cannot serialise drops this one event
347-
// (logged below) instead of failing the whole batch it would later be sent in. It is also
348-
// a deep copy, so a caller mutating a nested map afterwards cannot change a buffered event.
349-
ObjectMapper mapper = MapperFactory.getMapper();
347+
// Traits and metadata are caller objects of any shape. Serialising them here, rather than
348+
// at flush time, means a value Jackson cannot serialise drops this one event (logged
349+
// below) instead of failing the whole batch it would later be sent in. The JSON text is
350+
// also a deep copy, so a caller mutating a nested map afterwards cannot change a buffered
351+
// event, and is more compact to hold than the objects or a JSON tree.
352+
//
353+
// It must be writeValueAsString, not valueToTree: on a map or list that contains itself,
354+
// valueToTree lets a raw StackOverflowError escape, where writeValueAsString reports it as
355+
// a JsonMappingException that the catch below handles.
350356
Map<String, Object> eventTraits = eventTraits(traits);
351357

352358
Map<String, Object> eventPayload = new LinkedHashMap<>();
353359
eventPayload.put("event", event);
354360
eventPayload.put("feature_name", featureName);
355361
eventPayload.put("identifier", identifier);
356362
eventPayload.put("value", stringValue);
357-
eventPayload.put("traits", eventTraits == null ? null : mapper.valueToTree(eventTraits));
358-
eventPayload.put("metadata", mapper.valueToTree(eventMetadata));
363+
eventPayload.put("traits", eventTraits == null ? null : toJson(eventTraits));
364+
eventPayload.put("metadata", toJson(eventMetadata));
359365
eventPayload.put("timestamp", System.currentTimeMillis());
360366

361367
boolean isFull;
@@ -379,11 +385,16 @@ private void bufferEvent(String event, String featureName, String identifier, Ob
379385
if (isFull) {
380386
flush();
381387
}
382-
} catch (RuntimeException e) {
388+
} catch (JsonProcessingException | RuntimeException e) {
383389
logger.error("Failed to buffer event " + event + ".", e);
384390
}
385391
}
386392

393+
/** JSON text Jackson writes out verbatim when the batch is serialised. */
394+
private static RawValue toJson(Object value) throws JsonProcessingException {
395+
return new RawValue(MapperFactory.getMapper().writeValueAsString(value));
396+
}
397+
387398
private void logClosed(String event) {
388399
logger.info("Not buffering event {}: the event processor is closed.", event);
389400
}

‎src/test/java/com/flagsmith/threads/EventProcessorTest.java‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import static org.mockito.Mockito.when;
2020

2121
import com.fasterxml.jackson.databind.JsonNode;
22+
import com.fasterxml.jackson.databind.util.RawValue;
2223
import com.flagsmith.FlagsmithLogger;
2324
import com.flagsmith.MapperFactory;
2425
import com.flagsmith.config.FlagsmithConfig;
@@ -117,6 +118,12 @@ private void flushAndWait(EventProcessor processor) {
117118
processor.flush().get(WAIT_SECONDS, TimeUnit.SECONDS);
118119
}
119120

121+
/** Parse a buffered event's pre-serialised traits or metadata. */
122+
@SneakyThrows
123+
private static JsonNode json(Object buffered) {
124+
return MapperFactory.getMapper().readTree(((RawValue) buffered).rawValue().toString());
125+
}
126+
120127
@Test
121128
public void trackEvent_buffersStringifiedValueSdkVersionAndTimestamp() {
122129
EventProcessor processor = newProcessor(1000, 0);
@@ -140,9 +147,9 @@ public void trackEvent_buffersStringifiedValueSdkVersionAndTimestamp() {
140147
assertNull(event.get("feature_name"));
141148
assertEquals("user-123", event.get("identifier"));
142149
assertEquals("49.0", event.get("value"));
143-
assertEquals(MapperFactory.getMapper().valueToTree(traits), event.get("traits"));
150+
assertEquals(MapperFactory.getMapper().valueToTree(traits), json(event.get("traits")));
144151

145-
JsonNode eventMetadata = (JsonNode) event.get("metadata");
152+
JsonNode eventMetadata = json(event.get("metadata"));
146153
assertEquals("checkout", eventMetadata.get("source").asText());
147154
assertNotNull(eventMetadata.get("sdk_version"));
148155

@@ -487,7 +494,7 @@ public void trackExposureEvent_unwrapsTraitConfigsAndDropsTransientTraits() {
487494

488495
processor.trackExposureEvent("checkout_cta", "user-1", "treatment", traits, null);
489496

490-
JsonNode buffered = (JsonNode) processor.bufferedEvents().get(0).get("traits");
497+
JsonNode buffered = json(processor.bufferedEvents().get(0).get("traits"));
491498
assertEquals(2, buffered.size());
492499
assertEquals("premium", buffered.get("plan").asText());
493500
assertEquals("gold", buffered.get("tier").asText());
@@ -513,9 +520,9 @@ public void trackEvent_copiesTraitsAndMetadataDeeplyAtBufferTime() {
513520
Map<String, Object> event = processor.bufferedEvents().get(0);
514521
assertEquals(
515522
MapperFactory.getMapper().valueToTree(Collections.singletonMap("plan", "premium")),
516-
event.get("traits"));
523+
json(event.get("traits")));
517524
assertEquals("payment",
518-
((JsonNode) event.get("metadata")).get("context").get("step").asText());
525+
json(event.get("metadata")).get("context").get("step").asText());
519526
}
520527

521528
@Test
@@ -542,6 +549,25 @@ public void trackEvent_dropsOnlyTheEventWhoseValuesCannotBeSerialised() {
542549
assertEquals("user-4", events.get(1).get("identifier").asText());
543550
}
544551

552+
@Test
553+
public void trackEvent_dropsAnEventWhoseTraitsOrMetadataContainThemselves() {
554+
EventProcessor processor = newProcessor(1000, 0);
555+
Map<String, Object> cyclic = new HashMap<>();
556+
cyclic.put("self", cyclic);
557+
List<Object> cyclicList = new ArrayList<>();
558+
cyclicList.add(cyclicList);
559+
560+
// A caller bug, but it must stay out of caller code: serialising these recurses without
561+
// end, and it has to surface as a dropped event, not a StackOverflowError.
562+
processor.trackEvent("purchase", "user-1", "1", cyclic, null);
563+
processor.trackEvent("purchase", "user-2", "2", null,
564+
Collections.singletonMap("list", cyclicList));
565+
processor.trackEvent("purchase", "user-3", "3", null, null);
566+
567+
assertEquals(1, processor.bufferedEvents().size());
568+
assertEquals("user-3", processor.bufferedEvents().get(0).get("identifier"));
569+
}
570+
545571
@Test
546572
@SneakyThrows
547573
public void start_flushesOnTheTimer() {

0 commit comments

Comments
 (0)