Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add logging hook, rm logging from evaluation #1084

Merged
merged 7 commits into from
Sep 5, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No functional changes in this class, I just re-ordered some methods to make the class read more sensibly, and added a bit more information to the log statement.


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,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ONLY runs for hooks which we expect not to throw (before/finally/error).

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
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> {
aepfli marked this conversation as resolved.
Show resolved Hide resolved

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";
toddbaert marked this conversation as resolved.
Show resolved Hide resolved

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) {
Kavindu-Dodan marked this conversation as resolved.
Show resolved Hide resolved
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
Loading