Skip to content

Commit e8164aa

Browse files
committed
fix: Redact anonymous context attributes in custom events (server-side, opt-in)
Server-side SDKs inline the full context in custom events and must redact an anonymous context's attributes into _meta.redactedAttributes. Client-side SDKs use the current context for custom events and must NOT redact them (only feature events redact anonymous contexts on the client), per sdk-test-harness #388. Because this internal module is shared with client/mobile SDKs (e.g. Android), custom-event anonymous redaction is gated behind a new EventsConfiguration flag (redactAnonymousAttributesInCustomEvents, default false). The existing EventsConfiguration constructors default the flag to false, so client/mobile consumers are unaffected. The server SDK opts in as a follow-up once this change is released. migration_op events are only produced by server-side SDKs, so those redact unconditionally. Feature-event redaction is unchanged (both client and server). - EventsConfiguration: new flag + full constructor overload (older overloads delegate with false). - EventOutputFormatter: custom events honor the flag; migration_op unconditional. - Tests: server-behavior redacts, client-behavior (flag off) does not, migration redacts. SDK-2732
1 parent ad2ac08 commit e8164aa

4 files changed

Lines changed: 168 additions & 4 deletions

File tree

lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@
2727
*/
2828
final class EventOutputFormatter {
2929
private final EventContextFormatter contextFormatter;
30+
private final boolean redactAnonymousAttributesInCustomEvents;
3031

3132
EventOutputFormatter(EventsConfiguration config) {
3233
this.contextFormatter = new EventContextFormatter(
3334
config.allAttributesPrivate,
3435
config.privateAttributes.toArray(new AttributeRef[config.privateAttributes.size()]));
36+
this.redactAnonymousAttributesInCustomEvents = config.redactAnonymousAttributesInCustomEvents;
3537
}
3638

3739
int writeOutputEvents(Event[] events, List<EventSummarizer.EventSummary> summaries, Writer writer) throws IOException {
@@ -92,7 +94,10 @@ private boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException
9294
jw.beginObject();
9395
writeKindAndCreationDate(jw, "custom", event.getCreationDate());
9496
jw.name("key").value(ce.getKey());
95-
writeContext(ce.getContext(), jw, false);
97+
// Anonymous-context attribute redaction in custom events is server-side SDK behavior:
98+
// server-side SDKs inline the full context and redact, while client-side SDKs use the
99+
// current context and do not redact (only feature events redact anonymous on the client).
100+
writeContext(ce.getContext(), jw, redactAnonymousAttributesInCustomEvents);
96101
writeLDValue("data", ce.getData(), jw);
97102
if (ce.getMetricValue() != null) {
98103
jw.name("metricValue");
@@ -107,7 +112,9 @@ private boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException
107112
} else if (event instanceof Event.MigrationOp) {
108113
jw.beginObject();
109114
writeKindAndCreationDate(jw, "migration_op", event.getCreationDate());
110-
writeContext(event.getContext(), jw, false);
115+
// migration_op events are only produced by server-side SDKs, which always redact
116+
// anonymous context attributes.
117+
writeContext(event.getContext(), jw, true);
111118

112119
Event.MigrationOp me = (Event.MigrationOp)event;
113120
jw.name("operation").value(me.getOperation());

lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ public final class EventsConfiguration {
3131
final boolean initiallyOffline;
3232
final List<AttributeRef> privateAttributes;
3333
final boolean perContextSummarization;
34-
34+
final boolean redactAnonymousAttributesInCustomEvents;
35+
3536
/**
3637
* Creates an instance.
3738
*
@@ -103,6 +104,49 @@ public EventsConfiguration(
103104
Collection<AttributeRef> privateAttributes,
104105
boolean perContextSummarization
105106
) {
107+
this(allAttributesPrivate, capacity, contextDeduplicator, diagnosticRecordingIntervalMillis,
108+
diagnosticStore, eventSender, eventSendingThreadPoolSize, eventsUri, flushIntervalMillis,
109+
initiallyInBackground, initiallyOffline, privateAttributes, perContextSummarization, false);
110+
}
111+
112+
/**
113+
* Creates an instance.
114+
*
115+
* @param allAttributesPrivate true if all attributes are private
116+
* @param capacity event buffer capacity (if zero or negative, a value of 1 is used to prevent errors)
117+
* @param contextDeduplicator optional EventContextDeduplicator; null for client-side SDK
118+
* @param diagnosticRecordingIntervalMillis diagnostic recording interval
119+
* @param diagnosticStore optional DiagnosticStore; null if diagnostics are disabled
120+
* @param eventSender event delivery component; must not be null
121+
* @param eventSendingThreadPoolSize number of worker threads for event delivery; zero to use the default
122+
* @param eventsUri events base URI
123+
* @param flushIntervalMillis event flush interval
124+
* @param initiallyInBackground true if we should start out in background mode (see
125+
* {@link DefaultEventProcessor#setInBackground(boolean)})
126+
* @param initiallyOffline true if we should start out in offline mode (see
127+
* {@link DefaultEventProcessor#setOffline(boolean)})
128+
* @param privateAttributes list of private attribute references; may be null
129+
* @param perContextSummarization true to generate separate summary events per context
130+
* @param redactAnonymousAttributesInCustomEvents true to redact anonymous context attributes from
131+
* custom events. This is server-side SDK behavior (server-side SDKs inline the full context in
132+
* custom events); client-side SDKs use the current context for custom events and leave this false.
133+
*/
134+
public EventsConfiguration(
135+
boolean allAttributesPrivate,
136+
int capacity,
137+
EventContextDeduplicator contextDeduplicator,
138+
long diagnosticRecordingIntervalMillis,
139+
DiagnosticStore diagnosticStore,
140+
EventSender eventSender,
141+
int eventSendingThreadPoolSize,
142+
URI eventsUri,
143+
long flushIntervalMillis,
144+
boolean initiallyInBackground,
145+
boolean initiallyOffline,
146+
Collection<AttributeRef> privateAttributes,
147+
boolean perContextSummarization,
148+
boolean redactAnonymousAttributesInCustomEvents
149+
) {
106150
super();
107151
this.allAttributesPrivate = allAttributesPrivate;
108152
this.capacity = capacity >= 0 ? capacity : 1;
@@ -118,5 +162,6 @@ public EventsConfiguration(
118162
this.initiallyOffline = initiallyOffline;
119163
this.privateAttributes = privateAttributes == null ? Collections.emptyList() : new ArrayList<>(privateAttributes);
120164
this.perContextSummarization = perContextSummarization;
165+
this.redactAnonymousAttributesInCustomEvents = redactAnonymousAttributesInCustomEvents;
121166
}
122167
}

lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ public static class EventsConfigurationBuilder {
334334
private boolean initiallyOffline = false;
335335
private Set<AttributeRef> privateAttributes = new HashSet<>();
336336
private boolean perContextSummarization = false;
337+
private boolean redactAnonymousAttributesInCustomEvents = false;
337338

338339
public EventsConfiguration build() {
339340
return new EventsConfiguration(
@@ -349,10 +350,16 @@ public EventsConfiguration build() {
349350
initiallyInBackground,
350351
initiallyOffline,
351352
privateAttributes,
352-
perContextSummarization
353+
perContextSummarization,
354+
redactAnonymousAttributesInCustomEvents
353355
);
354356
}
355357

358+
public EventsConfigurationBuilder redactAnonymousAttributesInCustomEvents(boolean redactAnonymousAttributesInCustomEvents) {
359+
this.redactAnonymousAttributesInCustomEvents = redactAnonymousAttributesInCustomEvents;
360+
return this;
361+
}
362+
356363
public EventsConfigurationBuilder allAttributesPrivate(boolean allAttributesPrivate) {
357364
this.allAttributesPrivate = allAttributesPrivate;
358365
return this;

lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,111 @@ public void customEventIsSerialized() throws IOException {
291291
assertJsonEquals(ceJson4, getSingleOutputEvent(f, ceWithDataAndMetric));
292292
}
293293

294+
@Test
295+
public void customEventRedactsAnonymousContextAttributes() throws Exception {
296+
// Server-side SDK behavior: redactAnonymousAttributesInCustomEvents is enabled.
297+
EventOutputFormatter f = new EventOutputFormatter(
298+
new EventsConfigurationBuilder().redactAnonymousAttributesInCustomEvents(true).build());
299+
300+
// Single-kind anonymous context: every optional attribute is redacted.
301+
LDContext userContext = LDContext.builder("userkey").anonymous(true).name("me").set("age", 42).build();
302+
Event.Custom singleKind = customEvent(userContext, "customkey").build();
303+
LDValue singleContextJson = LDValue.buildObject()
304+
.put("kind", "user")
305+
.put("key", "userkey")
306+
.put("anonymous", true)
307+
.put("_meta", LDValue.parse("{\"redactedAttributes\":[\"name\", \"age\"]}"))
308+
.build();
309+
LDValue singleJson = parseValue("{" +
310+
"\"kind\":\"custom\"," +
311+
"\"creationDate\":100000," +
312+
"\"key\":\"customkey\"," +
313+
"\"context\":" + singleContextJson +
314+
"}");
315+
assertJsonEquals(singleJson, getSingleOutputEvent(f, singleKind));
316+
317+
// Multi-kind context: only the anonymous kind (user) is redacted; org is untouched.
318+
LDContext orgContext = LDContext.builder("orgkey").anonymous(false).kind("org").name("me").set("age", 42).build();
319+
LDContext multiContext = LDContext.createMulti(userContext, orgContext);
320+
Event.Custom multiKind = customEvent(multiContext, "customkey").build();
321+
LDValue userJson = LDValue.buildObject()
322+
.put("key", "userkey")
323+
.put("anonymous", true)
324+
.put("_meta", LDValue.parse("{\"redactedAttributes\":[\"name\", \"age\"]}"))
325+
.build();
326+
LDValue orgJson = LDValue.buildObject()
327+
.put("key", "orgkey")
328+
.put("name", "me")
329+
.put("age", 42)
330+
.build();
331+
LDValue multiContextJson = LDValue.buildObject()
332+
.put("kind", "multi")
333+
.put("user", userJson)
334+
.put("org", orgJson)
335+
.build();
336+
LDValue multiJson = parseValue("{" +
337+
"\"kind\":\"custom\"," +
338+
"\"creationDate\":100000," +
339+
"\"key\":\"customkey\"," +
340+
"\"context\":" + multiContextJson +
341+
"}");
342+
assertJsonEquals(multiJson, getSingleOutputEvent(f, multiKind));
343+
}
344+
345+
@Test
346+
public void customEventDoesNotRedactAnonymousContextWhenNotEnabled() throws Exception {
347+
// Client-side SDK behavior: with redactAnonymousAttributesInCustomEvents left false (the
348+
// default), custom events carry the full anonymous context, including its attributes.
349+
EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig());
350+
351+
LDContext userContext = LDContext.builder("userkey").anonymous(true).name("me").set("age", 42).build();
352+
Event.Custom ce = customEvent(userContext, "customkey").build();
353+
LDValue contextJson = LDValue.buildObject()
354+
.put("kind", "user")
355+
.put("key", "userkey")
356+
.put("anonymous", true)
357+
.put("name", "me")
358+
.put("age", 42)
359+
.build();
360+
LDValue ceJson = parseValue("{" +
361+
"\"kind\":\"custom\"," +
362+
"\"creationDate\":100000," +
363+
"\"key\":\"customkey\"," +
364+
"\"context\":" + contextJson +
365+
"}");
366+
assertJsonEquals(ceJson, getSingleOutputEvent(f, ce));
367+
}
368+
369+
@Test
370+
public void migrationOpEventRedactsAnonymousContextAttributes() throws Exception {
371+
EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig());
372+
373+
LDContext userContext = LDContext.builder("userkey").anonymous(true).name("me").set("age", 42).build();
374+
Event.MigrationOp migrationEvent = new Event.MigrationOp(
375+
100000,
376+
userContext,
377+
"migration-key",
378+
1,
379+
2,
380+
LDValue.of("live"),
381+
LDValue.of("off"),
382+
EvaluationReason.fallthrough(false),
383+
1,
384+
"read",
385+
new Event.MigrationOp.InvokedMeasurement(true, false),
386+
null,
387+
null,
388+
null
389+
);
390+
LDValue expectedContext = LDValue.buildObject()
391+
.put("kind", "user")
392+
.put("key", "userkey")
393+
.put("anonymous", true)
394+
.put("_meta", LDValue.parse("{\"redactedAttributes\":[\"name\", \"age\"]}"))
395+
.build();
396+
assertJsonEquals(expectedContext, getSingleOutputEvent(f, migrationEvent).get("context"));
397+
}
398+
294399
@Test
295400
public void summaryEventIsSerialized() throws Exception {
296401
LDValue value1a = LDValue.of("value1a"), value2a = LDValue.of("value2a"), value2b = LDValue.of("value2b"),

0 commit comments

Comments
 (0)