Skip to content

Commit

Permalink
feat: add logging hook, rm logging from evaluation (#1084)
Browse files Browse the repository at this point in the history
* feat: add logging hook, rm logging from evaluation

Signed-off-by: Todd Baert <[email protected]>

* fixup: remove public

Signed-off-by: Todd Baert <[email protected]>

* fixup: debug, not info

Signed-off-by: Todd Baert <[email protected]>

* fixup: move comment

Signed-off-by: Todd Baert <[email protected]>

* fixup: underscoes

Signed-off-by: Todd Baert <[email protected]>

* fixup: add to readme

Signed-off-by: Todd Baert <[email protected]>

---------

Signed-off-by: Todd Baert <[email protected]>
  • Loading branch information
toddbaert authored Sep 5, 2024
1 parent 78e3371 commit 037826f
Show file tree
Hide file tree
Showing 6 changed files with 297 additions and 29 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,14 @@ Once you've added a hook as a dependency, it can be registered at the global, cl
### Logging

The Java SDK uses SLF4J. See the [SLF4J manual](https://slf4j.org/manual.html) for complete documentation.
Note that in accordance with the OpenFeature specification, the SDK doesn't generally log messages during flag evaluation.

#### Logging Hook

The Java SDK includes a `LoggingHook`, which logs detailed information at key points during flag evaluation, using SLF4J's structured logging API.
This hook can be particularly helpful for troubleshooting and debugging; simply attach it at the global, client or invocation level and ensure your log level is set to "debug".

See [hooks](#hooks) for more information on configuring hooks.

### Domains

Expand Down
47 changes: 25 additions & 22 deletions src/main/java/dev/openfeature/sdk/HookSupport.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,28 @@
@SuppressWarnings({ "unchecked", "rawtypes" })
class HookSupport {

public void errorHooks(FlagValueType flagValueType, HookContext hookCtx, Exception e, List<Hook> hooks,
public EvaluationContext beforeHooks(FlagValueType flagValueType, HookContext hookCtx, List<Hook> hooks,
Map<String, Object> hints) {
executeHooks(flagValueType, hooks, "error", hook -> hook.error(hookCtx, e, hints));
Stream<EvaluationContext> result = callBeforeHooks(flagValueType, hookCtx, hooks, hints);
return hookCtx.getCtx().merge(
result.reduce(hookCtx.getCtx(), (EvaluationContext accumulated, EvaluationContext current) -> {
return accumulated.merge(current);
}));
}

public void afterHooks(FlagValueType flagValueType, HookContext hookContext, FlagEvaluationDetails details,
List<Hook> hooks, Map<String, Object> hints) {
executeHooksUnchecked(flagValueType, hooks, hook -> hook.after(hookContext, details, hints));
}

public void afterAllHooks(FlagValueType flagValueType, HookContext hookCtx, List<Hook> hooks,
Map<String, Object> hints) {
executeHooks(flagValueType, hooks, "finally", hook -> hook.finallyAfter(hookCtx, hints));
}

public void afterHooks(FlagValueType flagValueType, HookContext hookContext, FlagEvaluationDetails details,
List<Hook> hooks, Map<String, Object> hints) {
executeHooksUnchecked(flagValueType, hooks, hook -> hook.after(hookContext, details, hints));
public void errorHooks(FlagValueType flagValueType, HookContext hookCtx, Exception e, List<Hook> hooks,
Map<String, Object> hints) {
executeHooks(flagValueType, hooks, "error", hook -> hook.error(hookCtx, e, hints));
}

private <T> void executeHooks(
Expand All @@ -44,6 +53,17 @@ private <T> void executeHooks(
}
}

// before, error, and finally hooks shouldn't throw
private <T> void executeChecked(Hook<T> hook, Consumer<Hook<T>> hookCode, String hookMethod) {
try {
hookCode.accept(hook);
} catch (Exception exception) {
log.error("Unhandled exception when running {} hook {} (only 'after' hooks should throw)", hookMethod,
hook.getClass(), exception);
}
}

// after hooks can throw in order to do validation
private <T> void executeHooksUnchecked(
FlagValueType flagValueType, List<Hook> hooks,
Consumer<Hook<T>> hookCode) {
Expand All @@ -55,23 +75,6 @@ private <T> void executeHooksUnchecked(
}
}

private <T> void executeChecked(Hook<T> hook, Consumer<Hook<T>> hookCode, String hookMethod) {
try {
hookCode.accept(hook);
} catch (Exception exception) {
log.error("Exception when running {} hooks {}", hookMethod, hook.getClass(), exception);
}
}

public EvaluationContext beforeHooks(FlagValueType flagValueType, HookContext hookCtx, List<Hook> hooks,
Map<String, Object> hints) {
Stream<EvaluationContext> result = callBeforeHooks(flagValueType, hookCtx, hooks, hints);
return hookCtx.getCtx().merge(
result.reduce(hookCtx.getCtx(), (EvaluationContext accumulated, EvaluationContext current) -> {
return accumulated.merge(current);
}));
}

private Stream<EvaluationContext> callBeforeHooks(FlagValueType flagValueType, HookContext hookCtx,
List<Hook> hooks, Map<String, Object> hints) {
// These traverse backwards from normal.
Expand Down
1 change: 0 additions & 1 deletion src/main/java/dev/openfeature/sdk/OpenFeatureClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ private <T> FlagEvaluationDetails<T> evaluateFlag(FlagValueType type, String key
hookSupport.afterHooks(type, afterHookContext, details, mergedHooks, hints);
}
} catch (Exception e) {
log.error("Unable to correctly evaluate flag with key '{}'", key, e);
if (details == null) {
details = FlagEvaluationDetails.<T>builder().build();
}
Expand Down
92 changes: 92 additions & 0 deletions src/main/java/dev/openfeature/sdk/hooks/logging/LoggingHook.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package dev.openfeature.sdk.hooks.logging;

import dev.openfeature.sdk.ErrorCode;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.FlagEvaluationDetails;
import dev.openfeature.sdk.Hook;
import dev.openfeature.sdk.HookContext;
import dev.openfeature.sdk.exceptions.OpenFeatureError;
import java.util.Map;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.spi.LoggingEventBuilder;

/**
* A hook for logging flag evaluations.
* Useful for debugging.
* Flag evaluation data is logged at debug and error in before/after stages and error stages, respectively.
*/
@Slf4j
@edu.umd.cs.findbugs.annotations.SuppressFBWarnings(value = "RV_RETURN_VALUE_IGNORED",
justification = "we can ignore return values of chainables (builders) here")
public class LoggingHook implements Hook<Object> {

static final String DOMAIN_KEY = "domain";
static final String PROVIDER_NAME_KEY = "provider_name";
static final String FLAG_KEY_KEY = "flag_key";
static final String DEFAULT_VALUE_KEY = "default_value";
static final String EVALUATION_CONTEXT_KEY = "evaluation_context";
static final String ERROR_CODE_KEY = "error_code";
static final String ERROR_MESSAGE_KEY = "error_message";
static final String REASON_KEY = "reason";
static final String VARIANT_KEY = "variant";
static final String VALUE_KEY = "value";

private boolean includeEvaluationContext;

/**
* Construct a new LoggingHook.
*/
public LoggingHook() {
this(false);
}

/**
* Construct a new LoggingHook.
* @param includeEvaluationContext include a serialized evaluation context in the log message (defaults to false)
*/
public LoggingHook(boolean includeEvaluationContext) {
this.includeEvaluationContext = includeEvaluationContext;
}

@Override
public Optional<EvaluationContext> before(HookContext<Object> hookContext, Map<String, Object> hints) {
LoggingEventBuilder builder = log.atDebug();
addCommonProps(builder, hookContext);
builder.log("Before stage");

return Optional.empty();
}

@Override
public void after(HookContext<Object> hookContext, FlagEvaluationDetails<Object> details,
Map<String, Object> hints) {
LoggingEventBuilder builder = log.atDebug()
.addKeyValue(REASON_KEY, details.getReason())
.addKeyValue(VARIANT_KEY, details.getVariant())
.addKeyValue(VALUE_KEY, details.getValue());
addCommonProps(builder, hookContext);
builder.log("After stage");
}

@Override
public void error(HookContext<Object> hookContext, Exception error, Map<String, Object> hints) {
LoggingEventBuilder builder = log.atError()
.addKeyValue(ERROR_MESSAGE_KEY, error.getMessage());
addCommonProps(builder, hookContext);
ErrorCode errorCode = error instanceof OpenFeatureError ? ((OpenFeatureError) error).getErrorCode() : null;
builder.addKeyValue(ERROR_CODE_KEY, errorCode);
builder.log("Error stage", error);
}

private void addCommonProps(LoggingEventBuilder builder, HookContext<Object> hookContext) {
builder.addKeyValue(DOMAIN_KEY, hookContext.getClientMetadata().getDomain())
.addKeyValue(PROVIDER_NAME_KEY, hookContext.getProviderMetadata().getName())
.addKeyValue(FLAG_KEY_KEY, hookContext.getFlagKey())
.addKeyValue(DEFAULT_VALUE_KEY, hookContext.getDefaultValue());

if (includeEvaluationContext) {
builder.addKeyValue(EVALUATION_CONTEXT_KEY, hookContext.getCtx());
}
}
}
11 changes: 5 additions & 6 deletions src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
Expand All @@ -25,12 +26,10 @@
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
import org.simplify4u.slf4jmock.LoggerMock;
import org.slf4j.Logger;

import dev.openfeature.sdk.exceptions.FlagNotFoundError;
import dev.openfeature.sdk.exceptions.GeneralError;
import dev.openfeature.sdk.fixtures.HookFixtures;
import dev.openfeature.sdk.providers.memory.InMemoryProvider;
Expand Down Expand Up @@ -266,17 +265,17 @@ public void initialize(EvaluationContext evaluationContext) throws Exception {
assertEquals(TestConstants.BROKEN_MESSAGE, details.getErrorMessage());
}

@Specification(number="1.4.11", text="In the case of abnormal execution, the client SHOULD log an informative error message.")
@Specification(number="1.4.11", text="Methods, functions, or operations on the client SHOULD NOT write log messages.")
@Test void log_on_error() throws NotImplementedException {
FeatureProviderTestUtils.setFeatureProvider(new AlwaysBrokenProvider());
Client c = api.getClient();
FlagEvaluationDetails<Boolean> result = c.getBooleanDetails("test", false);

assertEquals(Reason.ERROR.toString(), result.getReason());
Mockito.verify(logger).error(
ArgumentMatchers.contains("Unable to correctly evaluate flag with key"),
Mockito.verify(logger, never()).error(
any(String.class),
any(),
ArgumentMatchers.isA(FlagNotFoundError.class));
any());
}

@Specification(number="1.2.2", text="The client interface MUST define a metadata member or accessor, containing an immutable domain field or accessor of type string, which corresponds to the domain value supplied during client creation. In previous drafts, this property was called name. For backwards compatibility, implementations should consider name an alias to domain.")
Expand Down
Loading

0 comments on commit 037826f

Please sign in to comment.