Skip to content

Introduce log maskers for AnnotatedService #6232

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

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

package com.linecorp.armeria.common.logging;

import java.lang.annotation.Annotation;

import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.annotation.UnstableApi;

/**
* Holds information about a POJO field.
* Users may use the information conveyed in this object to decide whether to mask a field
* via {@link FieldMasker}.
*
* <p>e.g. Assume a {@link BeanFieldInfo} representing the {@code inner} field.
* {@link #getFieldAnnotation(Class)} will hold information about the {@code Foo#inner} field,
* and {@link #getClassAnnotation(Class)} will hold information about the {@code Inner} class.
* <pre>{@code
* class Foo {
* public Inner inner;
* }
* }</pre>
*/
@UnstableApi
public interface BeanFieldInfo {

/**
* A convenience method which searches for all annotations associated with this
* {@link BeanFieldInfo}. This method invokes {@link #getFieldAnnotation(Class)}
* and {@link #getClassAnnotation(Class)} sequentially to find the first non-null
* annotation of type {@param annotationClass}.
*/
@Nullable
default <T extends Annotation> T getAnnotation(Class<T> annotationClass) {
final T propertyAnnotation = getFieldAnnotation(annotationClass);
if (propertyAnnotation != null) {
return propertyAnnotation;
}
return getClassAnnotation(annotationClass);
}

/**
* The name of the field.
*/
String name();

/**
* Returns an annotation on the specified field.
*/
@Nullable
<T extends Annotation> T getFieldAnnotation(Class<T> annotationClass);

/**
* Returns an annotation on the class of the field.
*/
@Nullable
<T extends Annotation> T getClassAnnotation(Class<T> annotationClass);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

package com.linecorp.armeria.common.logging;

import static java.util.Objects.requireNonNull;

import com.linecorp.armeria.common.annotation.UnstableApi;

/**
* A {@link FieldMaskerSelector} implementation for masking POJO data types.
* A simple use-case may look like the following:
* <pre>{@code
* @interface Masker {}
*
* class MyPojo {
* @Masker
* public String hello = "world";
* }
*
* BeanFieldMaskerSelector selector =
* FieldMaskerSelector.ofBean(fieldInfo -> {
* Masker maskerAnnotation = fieldInfo.getAnnotation(Masker.class);
* if (maskerAnnotation == null) {
* return FieldMasker.fallthrough();
* }
* return FieldMasker.nullify();
* });
* }</pre>
*/
@UnstableApi
@FunctionalInterface
public interface BeanFieldMaskerSelector extends FieldMaskerSelector<BeanFieldInfo> {

/**
* Delegates {@link FieldMasker} selection to a different {@link BeanFieldMaskerSelector}
* if the current {@link BeanFieldMaskerSelector} returns {@link FieldMasker#fallthrough()}.
*/
default BeanFieldMaskerSelector orElse(BeanFieldMaskerSelector other) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be in FieldMaskerSelector?

Copy link
Contributor Author

@jrhee17 jrhee17 Jun 19, 2025

Choose a reason for hiding this comment

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

This was intentional since it doesn't make sense to do a BeanFieldMaskerSelector .orElse(ThriftFieldMaskerSelector).
Let me know if you still feel like this should be in FieldMaskerSelector though

Copy link
Contributor

@minwoox minwoox Jun 19, 2025

Choose a reason for hiding this comment

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

BeanFieldMaskerSelector.orElse(ThriftFieldMaskerSelector)
Isn't it impossible because FieldMaskerSelector has type T? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point. Done

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I forgot about this, but the type of FieldMaskerSelector is checked when determining how to use them from the provider.

selectors.stream().filter(selector -> provider.supportedType().isInstance(selector))

I prefer that BeanFieldMaskerSelector is a functional interface. Even if a marker is added, I think the functional style may not work in some cases.

e.g.

public interface BeanFieldMaskerSelector extends ... {

    @Override
    default Class<?> supportedType() {
        return BeanFieldInfo.class;
    }
}

ContentSanitizer.builder()
                .fieldMaskerSelector((BeanFieldMaskerSelector) info -> {
                    acc.add(info);
                    return FieldMasker.fallthrough();
                })
                .fieldMaskerSelector(((BeanFieldMaskerSelector) info -> {

I think I prefer the original version unless you have another idea

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, let's use the original version, then.

requireNonNull(other, "other");
return beanFieldInfo -> {
final FieldMasker fieldMasker = fieldMasker(beanFieldInfo);
if (fieldMasker != FieldMasker.fallthrough()) {
return fieldMasker;
}
return other.fieldMasker(beanFieldInfo);
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

package com.linecorp.armeria.common.logging;

import java.util.function.BiFunction;

import com.fasterxml.jackson.databind.JsonNode;

import com.linecorp.armeria.common.RequestContext;
import com.linecorp.armeria.common.annotation.UnstableApi;

/**
* A content sanitizer implementation which sanitizes an object before serializing it into a
* log friendly format. The following illustrates a simple use-case.
* <pre>{@code
* LogFormatter
* .builderForText()
* .contentSanitizer(ContentSanitizer.builder()
* .fieldMaskerSelector(...)
* .buildForText())
* .build();
* }</pre>
*
* @see TextLogFormatterBuilder#contentSanitizer(BiFunction)
* @see JsonLogFormatterBuilder#contentSanitizer(BiFunction)
*/
@UnstableApi
@FunctionalInterface
public interface ContentSanitizer<T> extends BiFunction<RequestContext, Object, T> {

/**
* Returns a {@link ContentSanitizerBuilder} instance.
*/
static ContentSanitizerBuilder builder() {
return new ContentSanitizerBuilder();
}

/**
* Returns a {@link ContentSanitizer} instance which doesn't mask fields.
*/
static ContentSanitizer<String> forText() {
return new ContentSanitizerBuilder().buildForText();
}

/**
* Returns a {@link ContentSanitizer} instance which doesn't mask fields.
*/
static ContentSanitizer<JsonNode> forJson() {
return new ContentSanitizerBuilder().buildForJson();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

package com.linecorp.armeria.common.logging;

import static com.linecorp.armeria.internal.common.logging.MaskerAttributeKeys.REQUEST_CONTEXT_KEY;
import static java.util.Objects.requireNonNull;

import java.util.List;
import java.util.Map;
import java.util.ServiceLoader;
import java.util.function.BiFunction;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.util.RawValue;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;

import com.linecorp.armeria.common.annotation.UnstableApi;
import com.linecorp.armeria.internal.common.JacksonUtil;
import com.linecorp.armeria.internal.common.logging.FieldMaskerSelectorProvider;
import com.linecorp.armeria.server.annotation.AnnotatedService;

/**
* A {@link ContentSanitizer} builder which allows users to specify {@link FieldMaskerSelector}s
* to decide whether to mask a field.
*/
@UnstableApi
public final class ContentSanitizerBuilder {

private static final Map<Class<?>, FieldMaskerSelectorProvider<?>> customizersMap;

static {
final ImmutableMap.Builder<Class<?>, FieldMaskerSelectorProvider<?>> customizersMapBuilder =
ImmutableMap.builder();
@SuppressWarnings("rawtypes")
final ServiceLoader<FieldMaskerSelectorProvider> loader = ServiceLoader.load(
FieldMaskerSelectorProvider.class,
ContentSanitizerBuilder.class.getClassLoader());
for (FieldMaskerSelectorProvider<?> customizer: loader) {
customizersMapBuilder.put(customizer.supportedType(), customizer);
}
customizersMap = customizersMapBuilder.buildKeepingLast();
}

private final ImmutableList.Builder<FieldMaskerSelector<?>> maskerListBuilder = ImmutableList.builder();

ContentSanitizerBuilder() {}

/**
* Adds a {@link FieldMaskerSelector} which decides whether each content field should be masked.
* The specified {@link FieldMaskerSelector} should be an instance of a pre-defined type corresponding to a
* service type. e.g. {@link BeanFieldMaskerSelector} should be used to mask content produced by
* {@link AnnotatedService}.
* If multiple {@link FieldMaskerSelector}s of the same type are registered, each selector will be
* sequentially queried for a {@link FieldMasker}. If all {@link FieldMaskerSelector}s return
* {@link FieldMasker#fallthrough()}, no masking will occur for the field.
*/
public ContentSanitizerBuilder fieldMaskerSelector(FieldMaskerSelector<?> masker) {
requireNonNull(masker, "masker");
Preconditions.checkArgument(!customizersMap.containsKey(masker.getClass()),
"Specified masker should be one of the following types: %s",
customizersMap.keySet());
maskerListBuilder.add(masker);
return this;
}

/**
* Builds a {@link ContentSanitizer} which can be used with
* {@link TextLogFormatterBuilder#contentSanitizer(BiFunction)}.
*/
public ContentSanitizer<String> buildForText() {
final ObjectMapper objectMapper = buildObjectMapper();
return (requestContext, o) -> {
try {
return objectMapper.writer()
.withAttribute(REQUEST_CONTEXT_KEY, requestContext)
.writeValueAsString(o);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
};
}

/**
* Builds a {@link ContentSanitizer} which can be used with
* {@link JsonLogFormatterBuilder#contentSanitizer(BiFunction)}.
*/
public ContentSanitizer<JsonNode> buildForJson() {
final ObjectMapper objectMapper = buildObjectMapper();
return (requestContext, o) -> {
try {
final String ser = objectMapper.writer()
.withAttribute(REQUEST_CONTEXT_KEY, requestContext)
.writeValueAsString(o);
return objectMapper.createObjectNode().rawValueNode(new RawValue(ser));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
};
}

/**
* Constructs an underlying {@link ObjectMapper} used to mask or unmask content.
*/
public ObjectMapper buildObjectMapper() {
final List<FieldMaskerSelector<?>> fieldMaskerSelectors = maskerListBuilder.build();
final ObjectMapper objectMapper = JacksonUtil.newDefaultObjectMapper();
for (FieldMaskerSelectorProvider<?> customizer : customizersMap.values()) {
applyProvider(customizer, objectMapper, fieldMaskerSelectors);
}
return objectMapper;
}

@SuppressWarnings("unchecked")
private static <T extends FieldMaskerSelector<?>> void applyProvider(
FieldMaskerSelectorProvider<T> provider, ObjectMapper objectMapper,
List<FieldMaskerSelector<?>> selectors) {
final List<T> filtered =
selectors.stream().filter(selector -> provider.supportedType().isInstance(selector))
.map(selector -> (T) selector)
.collect(ImmutableList.toImmutableList());
provider.customize(filtered, objectMapper);
}
}
Loading
Loading