From 848831d8217ef8d70ec27c6988c30101ee567057 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Mon, 6 Jan 2025 19:05:06 -0700 Subject: [PATCH] Add ObservationContextSource Closes gh-991 --- core/build.gradle | 5 + ...ContextSourceObservationPostProcessor.java | 57 ++ .../support/ObservationContextSource.java | 755 ++++++++++++++++++ .../ObservationContextSourceTests.java | 61 ++ dependencies/build.gradle | 2 + modules/ROOT/pages/observability.adoc | 44 + 6 files changed, 924 insertions(+) create mode 100644 core/src/main/java/org/springframework/ldap/core/support/ContextSourceObservationPostProcessor.java create mode 100644 core/src/main/java/org/springframework/ldap/core/support/ObservationContextSource.java create mode 100644 core/src/test/java/org/springframework/ldap/core/support/ObservationContextSourceTests.java create mode 100644 modules/ROOT/pages/observability.adoc diff --git a/core/build.gradle b/core/build.gradle index 00665a47c..79474443c 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -17,6 +17,7 @@ dependencies { api "org.springframework:spring-core" api "org.springframework:spring-beans" api "org.springframework:spring-tx" + api "io.micrometer:micrometer-core" implementation "org.slf4j:slf4j-api" @@ -31,6 +32,10 @@ dependencies { optional "commons-pool:commons-pool" optional "org.apache.commons:commons-pool2" + testImplementation ("io.micrometer:micrometer-test") { + exclude group: 'org.mockito' + exclude group: 'org.assertj' + } testImplementation platform('org.junit:junit-bom') testImplementation "org.junit.vintage:junit-vintage-engine" diff --git a/core/src/main/java/org/springframework/ldap/core/support/ContextSourceObservationPostProcessor.java b/core/src/main/java/org/springframework/ldap/core/support/ContextSourceObservationPostProcessor.java new file mode 100644 index 000000000..2dfdf5256 --- /dev/null +++ b/core/src/main/java/org/springframework/ldap/core/support/ContextSourceObservationPostProcessor.java @@ -0,0 +1,57 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 org.springframework.ldap.core.support; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.ldap.core.ContextSource; + +/** + * A {@link BeanPostProcessor} that makes any {@link ContextSource} bean observable by + * Micrometer + * + * @author Josh Cummings + * @since 3.3 + */ +public final class ContextSourceObservationPostProcessor implements BeanPostProcessor { + + private final ObjectProvider observationRegistry; + + public ContextSourceObservationPostProcessor(ObjectProvider observationRegistry) { + this.observationRegistry = observationRegistry; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof ObservationContextSource) { + return bean; + } + if (!(bean instanceof BaseLdapPathContextSource ldap)) { + return bean; + } + ObservationRegistry observationRegistry = this.observationRegistry + .getIfAvailable(() -> ObservationRegistry.NOOP); + if (observationRegistry.isNoop()) { + return bean; + } + return new ObservationContextSource(ldap, observationRegistry); + } + +} diff --git a/core/src/main/java/org/springframework/ldap/core/support/ObservationContextSource.java b/core/src/main/java/org/springframework/ldap/core/support/ObservationContextSource.java new file mode 100644 index 000000000..bb11cd46f --- /dev/null +++ b/core/src/main/java/org/springframework/ldap/core/support/ObservationContextSource.java @@ -0,0 +1,755 @@ +/* + * Copyright 2005-2025 the original author or authors. + * + * Licensed 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 org.springframework.ldap.core.support; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Hashtable; +import java.util.List; +import java.util.Objects; + +import javax.naming.Binding; +import javax.naming.Context; +import javax.naming.Name; +import javax.naming.NameClassPair; +import javax.naming.NameParser; +import javax.naming.NamingEnumeration; +import javax.naming.directory.Attributes; +import javax.naming.directory.DirContext; +import javax.naming.directory.ModificationItem; +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; +import javax.naming.ldap.Control; +import javax.naming.ldap.ExtendedRequest; +import javax.naming.ldap.ExtendedResponse; +import javax.naming.ldap.LdapContext; +import javax.naming.ldap.LdapName; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ldap.NamingException; +import org.springframework.ldap.core.ContextSource; +import org.springframework.ldap.core.DirContextOperations; +import org.springframework.ldap.core.DirContextProxy; +import org.springframework.ldap.core.DistinguishedName; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * An implementation of {@link ContextSource} that returns {@link DirContext} instances + * that record the time each operation takes. + * + * @author Josh Cummings + * @since 3.3 + */ +public final class ObservationContextSource implements BaseLdapPathContextSource { + + private final BaseLdapPathContextSource contextSource; + + private final DirContextOperationObservationContext.Builder builder; + + private final ObservationRegistry registry; + + public ObservationContextSource(BaseLdapPathContextSource contextSource, ObservationRegistry registry) { + this.contextSource = contextSource; + this.registry = registry; + this.builder = DirContextOperationObservationContext.withContextSource(this.contextSource); + } + + @Override + public DirContext getReadOnlyContext() throws NamingException { + return wrapDirContext(this.contextSource.getReadOnlyContext()); + } + + @Override + public DirContext getReadWriteContext() throws NamingException { + return wrapDirContext(this.contextSource.getReadWriteContext()); + } + + @Override + public DirContext getContext(String principal, String credentials) throws NamingException { + return wrapDirContext(this.contextSource.getContext(principal, credentials)); + } + + private DirContext wrapDirContext(DirContext delegate) { + if (delegate instanceof DirContextOperations operations) { + return operations; + } + if (delegate instanceof LdapContext ldap) { + return new ObservationLdapContext(this.builder, ldap, this.registry); + } + return new ObservationDirContext(this.builder, delegate, this.registry); + } + + @Deprecated + @Override + public DistinguishedName getBaseLdapPath() { + return this.contextSource.getBaseLdapPath(); + } + + @Override + public LdapName getBaseLdapName() { + return this.contextSource.getBaseLdapName(); + } + + @Override + public String getBaseLdapPathAsString() { + return this.contextSource.getBaseLdapPathAsString(); + } + + private static class DirContextOperationObservationContext extends Observation.Context { + + private final String urls; + + private final String base; + + private final String operation; + + DirContextOperationObservationContext(String urls, String base, String operation) { + this.urls = urls; + this.base = base; + this.operation = operation; + } + + static Builder withContextSource(BaseLdapPathContextSource contextSource) { + return new Builder(contextSource); + } + + String getUrls() { + return this.urls; + } + + String getBase() { + return this.base; + } + + String getOperation() { + return this.operation; + } + + private static class Builder { + + private final String urls; + + private final String base; + + Builder(BaseLdapPathContextSource contextSource) { + this.base = contextSource.getBaseLdapPathAsString(); + if (contextSource instanceof AbstractContextSource acs) { + this.urls = Arrays.toString(acs.getUrls()); + } + else { + this.urls = "unknown"; + } + } + + DirContextOperationObservationContext operation(String operation) { + return new DirContextOperationObservationContext(this.urls, this.base, operation); + } + + } + + } + + private static class DirContextOperationObservationConvention + implements ObservationConvention { + + static final String OBSERVATION_NAME = "spring.ldap.dir.context.operations"; + + @Override + public KeyValues getLowCardinalityKeyValues(DirContextOperationObservationContext context) { + return KeyValues.of("urls", context.getUrls()) + .and("base", context.getBase()) + .and("operation", context.getOperation()); + } + + @Override + public boolean supportsContext(io.micrometer.observation.Observation.Context context) { + return context instanceof DirContextOperationObservationContext; + } + + @Override + public String getName() { + return OBSERVATION_NAME; + } + + @Override + public String getContextualName(DirContextOperationObservationContext context) { + return "perform " + context.getOperation(); + } + + } + + private static class ObservationLdapContext extends ObservationDirContext implements LdapContext { + + private final LdapContext delegate; + + ObservationLdapContext(DirContextOperationObservationContext.Builder builder, LdapContext delegate, + ObservationRegistry registry) { + super(builder, delegate, registry); + this.delegate = delegate; + } + + @Override + public ExtendedResponse extendedOperation(ExtendedRequest request) throws javax.naming.NamingException { + Observation observation = observation("extended.operation"); + observation.highCardinalityKeyValue("request.id", request.getID()); + return observe(observation, () -> this.delegate.extendedOperation(request)); + } + + @Override + public LdapContext newInstance(Control[] requestControls) throws javax.naming.NamingException { + return this.delegate.newInstance(requestControls); + } + + @Override + public void reconnect(Control[] connCtls) throws javax.naming.NamingException { + Observation observation = observation("reconnect"); + List ids = new ArrayList<>(); + for (Control control : connCtls) { + ids.add(control.getID()); + } + observation.highCardinalityKeyValue("control.ids", ids.toString()); + observe(observation, () -> this.delegate.reconnect(connCtls)); + } + + @Override + public Control[] getConnectControls() throws javax.naming.NamingException { + return this.delegate.getConnectControls(); + } + + @Override + public void setRequestControls(Control[] requestControls) throws javax.naming.NamingException { + this.delegate.setRequestControls(requestControls); + } + + @Override + public Control[] getRequestControls() throws javax.naming.NamingException { + return this.delegate.getRequestControls(); + } + + @Override + public Control[] getResponseControls() throws javax.naming.NamingException { + return this.delegate.getResponseControls(); + } + + } + + private static class ObservationDirContext implements DirContext, DirContextProxy { + + private final ObservationRegistry registry; + + private final DirContextOperationObservationConvention convention = new DirContextOperationObservationConvention(); + + private final DirContextOperationObservationContext.Builder builder; + + private final DirContext delegate; + + ObservationDirContext(DirContextOperationObservationContext.Builder builder, DirContext delegate, + ObservationRegistry registry) { + this.builder = builder; + this.delegate = delegate; + this.registry = registry; + } + + private static KeyValue name(String tagName, Name name) { + return KeyValue.of(tagName, String.valueOf(name)); + } + + private static KeyValue name(String tagName, String name) { + return KeyValue.of(tagName, name); + } + + private static KeyValue name(Name name) { + return name("name", name); + } + + private static KeyValue name(String name) { + return name("name", name); + } + + private static KeyValue attributeIds(String[] attributes) { + return KeyValue.of("attribute.ids", Arrays.toString(attributes)); + } + + private static KeyValue attributeIds(Attributes attributes) { + if (attributes == null) { + return KeyValue.of("attribute.ids", Collections.emptyList().toString()); + } + else { + return KeyValue.of("attribute.ids", Collections.list(attributes.getIDs()).toString()); + } + } + + private static KeyValue attributeIds(int mod_op, Attributes attrs) { + List attributes = Collections.list(attrs.getIDs()); + String value = Collections.singletonMap(modify(mod_op), attributes).toString(); + return KeyValue.of("attribute.ids", value); + } + + private static KeyValue attributeIds(ModificationItem[] items) { + MultiValueMap ids = new LinkedMultiValueMap<>(); + for (ModificationItem item : items) { + ids.add(modify(item.getModificationOp()), item.getAttribute().getID()); + } + String value = ids.toString(); + return KeyValue.of("attribute.ids", value); + } + + private static KeyValue attributeIdsReturn(String[] attrs) { + return KeyValue.of("attribute.ids", Arrays.toString(attrs)); + } + + private static KeyValue attributeIdsReturn(SearchControls searchControls) { + return attributeIdsReturn(searchControls.getReturningAttributes()); + } + + private static KeyValue searchControls(SearchControls searchControls) { + if (searchControls == null) { + return KeyValue.of("search.controls", "none"); + } + return KeyValue.of("search.controls", Objects.toString(searchControls)); + } + + private static String modify(int mod_op) { + return switch (mod_op) { + case DirContext.ADD_ATTRIBUTE -> "add"; + case DirContext.REPLACE_ATTRIBUTE -> "replace"; + case DirContext.REMOVE_ATTRIBUTE -> "remove"; + default -> throw new IllegalArgumentException("Unsupported operation: " + mod_op); + }; + } + + @Override + public DirContext getTargetContext() { + return this.delegate; + } + + @Override + public Attributes getAttributes(Name name) throws javax.naming.NamingException { + DirContextOperationObservationContext context = this.builder.operation("get.attributes"); + io.micrometer.observation.Observation observation = io.micrometer.observation.Observation + .createNotStarted(this.convention, () -> context, this.registry) + .highCardinalityKeyValue("name", name.toString()); + return observe(observation, () -> this.delegate.getAttributes(name)); + } + + @Override + public Attributes getAttributes(String name) throws javax.naming.NamingException { + Observation observation = observation("get.attributes").highCardinalityKeyValue("name", name); + return observe(observation, () -> this.delegate.getAttributes(name)); + } + + Observation observation(String operation) { + DirContextOperationObservationContext context = this.builder.operation(operation); + return io.micrometer.observation.Observation.createNotStarted(this.convention, () -> context, + this.registry); + } + + @Override + public Attributes getAttributes(Name name, String[] attrIds) throws javax.naming.NamingException { + Observation observation = observation("get.attributes").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(attrIds)); + return observe(observation, () -> this.delegate.getAttributes(name, attrIds)); + } + + @Override + public Attributes getAttributes(String name, String[] attrIds) throws javax.naming.NamingException { + Observation observation = observation("get.attributes").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(attrIds)); + return observe(observation, () -> this.delegate.getAttributes(name, attrIds)); + } + + @Override + public void modifyAttributes(Name name, int mod_op, Attributes attrs) throws javax.naming.NamingException { + Observation observation = observation("modify.attributes").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(mod_op, attrs)); + observe(observation, () -> this.delegate.modifyAttributes(name, mod_op, attrs)); + } + + @Override + public void modifyAttributes(String name, int mod_op, Attributes attrs) throws javax.naming.NamingException { + Observation observation = observation("modify.attributes").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(mod_op, attrs)); + observe(observation, () -> this.delegate.modifyAttributes(name, mod_op, attrs)); + } + + @Override + public void modifyAttributes(Name name, ModificationItem[] mods) throws javax.naming.NamingException { + Observation observation = observation("modify.attributes").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(mods)); + observe(observation, () -> this.delegate.modifyAttributes(name, mods)); + } + + @Override + public void modifyAttributes(String name, ModificationItem[] mods) throws javax.naming.NamingException { + Observation observation = observation("modify.attributes").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(mods)); + observe(observation, () -> this.delegate.modifyAttributes(name, mods)); + } + + @Override + public void bind(Name name, Object obj, Attributes attrs) throws javax.naming.NamingException { + Observation observation = observation("bind").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(attrs)); + observe(observation, () -> this.delegate.bind(name, obj, attrs)); + } + + @Override + public void bind(String name, Object obj, Attributes attrs) throws javax.naming.NamingException { + Observation observation = observation("bind").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(attrs)); + observe(observation, () -> this.delegate.bind(name, obj, attrs)); + } + + @Override + public void rebind(Name name, Object obj, Attributes attrs) throws javax.naming.NamingException { + Observation observation = observation("rebind").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(attrs)); + observe(observation, () -> this.delegate.rebind(name, obj, attrs)); + } + + @Override + public void rebind(String name, Object obj, Attributes attrs) throws javax.naming.NamingException { + Observation observation = observation("rebind").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(attrs)); + observe(observation, () -> this.delegate.rebind(name, obj, attrs)); + } + + @Override + public DirContext createSubcontext(Name name, Attributes attrs) throws javax.naming.NamingException { + Observation observation = observation("create.subcontext").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(attrs)); + return observe(observation, () -> this.delegate.createSubcontext(name, attrs)); + } + + @Override + public DirContext createSubcontext(String name, Attributes attrs) throws javax.naming.NamingException { + Observation observation = observation("create.subcontext").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(attrs)); + return observe(observation, () -> this.delegate.createSubcontext(name, attrs)); + } + + @Override + public DirContext getSchema(Name name) throws javax.naming.NamingException { + Observation observation = observation("get.schema").highCardinalityKeyValue(name(name)); + return observe(observation, () -> this.delegate.getSchema(name)); + } + + @Override + public DirContext getSchema(String name) throws javax.naming.NamingException { + Observation observation = observation("get.schema").highCardinalityKeyValue(name(name)); + return observe(observation, () -> this.delegate.getSchema(name)); + } + + @Override + public DirContext getSchemaClassDefinition(Name name) throws javax.naming.NamingException { + Observation observation = observation("get.schema.class.definition").highCardinalityKeyValue(name(name)); + return observe(observation, () -> this.delegate.getSchemaClassDefinition(name)); + } + + @Override + public DirContext getSchemaClassDefinition(String name) throws javax.naming.NamingException { + Observation observation = observation("get.schema.class.definition").highCardinalityKeyValue(name(name)); + return observe(observation, () -> this.delegate.getSchemaClassDefinition(name)); + } + + @Override + public NamingEnumeration search(Name name, Attributes matchingAttributes, + String[] attributesToReturn) throws javax.naming.NamingException { + Observation observation = observation("search").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIdsReturn(attributesToReturn)); + return observe(observation, () -> this.delegate.search(name, matchingAttributes, attributesToReturn)); + } + + @Override + public NamingEnumeration search(String name, Attributes matchingAttributes, + String[] attributesToReturn) throws javax.naming.NamingException { + Observation observation = observation("search").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(matchingAttributes)) + .highCardinalityKeyValue(attributeIdsReturn(attributesToReturn)); + return observe(observation, () -> this.delegate.search(name, matchingAttributes, attributesToReturn)); + } + + @Override + public NamingEnumeration search(Name name, Attributes matchingAttributes) + throws javax.naming.NamingException { + Observation observation = observation("search").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(matchingAttributes)); + return observe(observation, () -> this.delegate.search(name, matchingAttributes)); + } + + @Override + public NamingEnumeration search(String name, Attributes matchingAttributes) + throws javax.naming.NamingException { + Observation observation = observation("search").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIds(matchingAttributes)); + return observe(observation, () -> this.delegate.search(name, matchingAttributes)); + } + + @Override + public NamingEnumeration search(Name name, String filter, SearchControls cons) + throws javax.naming.NamingException { + Observation observation = observation("search").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIdsReturn(cons)) + .highCardinalityKeyValue(searchControls(cons)); + return observe(observation, () -> this.delegate.search(name, filter, cons)); + } + + @Override + public NamingEnumeration search(String name, String filter, SearchControls cons) + throws javax.naming.NamingException { + Observation observation = observation("search").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIdsReturn(cons)) + .highCardinalityKeyValue(searchControls(cons)); + return observe(observation, () -> this.delegate.search(name, filter, cons)); + } + + @Override + public NamingEnumeration search(Name name, String filterExpr, Object[] filterArgs, + SearchControls cons) throws javax.naming.NamingException { + Observation observation = observation("search").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIdsReturn(cons)) + .highCardinalityKeyValue(searchControls(cons)); + return observe(observation, () -> this.delegate.search(name, filterExpr, filterArgs, cons)); + } + + @Override + public NamingEnumeration search(String name, String filterExpr, Object[] filterArgs, + SearchControls cons) throws javax.naming.NamingException { + Observation observation = observation("search").highCardinalityKeyValue(name(name)) + .highCardinalityKeyValue(attributeIdsReturn(cons)) + .highCardinalityKeyValue(searchControls(cons)); + return observe(observation, () -> this.delegate.search(name, filterExpr, filterArgs, cons)); + } + + @Override + public Object lookup(Name name) throws javax.naming.NamingException { + Observation observation = observation("lookup").highCardinalityKeyValue(name(name)); + return observe(observation, () -> this.delegate.lookup(name)); + } + + @Override + public Object lookup(String name) throws javax.naming.NamingException { + Observation observation = observation("lookup").highCardinalityKeyValue(name(name)); + return observe(observation, () -> this.delegate.lookup(name)); + } + + @Override + public void bind(Name name, Object obj) throws javax.naming.NamingException { + Observation observation = observation("bind").highCardinalityKeyValue(name(name)); + observe(observation, () -> this.delegate.bind(name, obj)); + } + + @Override + public void bind(String name, Object obj) throws javax.naming.NamingException { + Observation observation = observation("bind").highCardinalityKeyValue(name(name)); + observe(observation, () -> this.delegate.bind(name, obj)); + } + + @Override + public void rebind(Name name, Object obj) throws javax.naming.NamingException { + Observation observation = observation("rebind").highCardinalityKeyValue(name(name)); + observe(observation, () -> this.delegate.rebind(name, obj)); + } + + @Override + public void rebind(String name, Object obj) throws javax.naming.NamingException { + Observation observation = observation("rebind").highCardinalityKeyValue(name(name)); + observe(observation, () -> this.delegate.rebind(name, obj)); + } + + @Override + public void unbind(Name name) throws javax.naming.NamingException { + Observation observation = observation("unbind").highCardinalityKeyValue(name(name)); + observe(observation, () -> this.delegate.unbind(name)); + } + + @Override + public void unbind(String name) throws javax.naming.NamingException { + Observation observation = observation("unbind").highCardinalityKeyValue(name(name)); + observe(observation, () -> this.delegate.unbind(name)); + } + + @Override + public void rename(Name oldName, Name newName) throws javax.naming.NamingException { + Observation observation = observation("rename").highCardinalityKeyValue(name("old.name", oldName)) + .highCardinalityKeyValue(name("new.name", newName)); + observe(observation, () -> this.delegate.rename(oldName, newName)); + } + + @Override + public void rename(String oldName, String newName) throws javax.naming.NamingException { + Observation observation = observation("rename").highCardinalityKeyValue(name("old.name", oldName)) + .highCardinalityKeyValue(name("new.name", newName)); + observe(observation, () -> this.delegate.rename(oldName, newName)); + } + + @Override + public NamingEnumeration list(Name name) throws javax.naming.NamingException { + Observation observation = observation("list").highCardinalityKeyValue(name(name)); + return observe(observation, () -> this.delegate.list(name)); + } + + @Override + public NamingEnumeration list(String name) throws javax.naming.NamingException { + Observation observation = observation("list").highCardinalityKeyValue(name(name)); + return observe(observation, () -> this.delegate.list(name)); + } + + @Override + public NamingEnumeration listBindings(Name name) throws javax.naming.NamingException { + Observation observation = observation("list.bindings").highCardinalityKeyValue(name(name)); + return observe(observation, () -> this.delegate.listBindings(name)); + } + + @Override + public NamingEnumeration listBindings(String name) throws javax.naming.NamingException { + Observation observation = observation("list.bindings").highCardinalityKeyValue(name(name)); + return observe(observation, () -> this.delegate.listBindings(name)); + } + + @Override + public void destroySubcontext(Name name) throws javax.naming.NamingException { + Observation observation = observation("destroy.subcontext").highCardinalityKeyValue(name(name)); + observe(observation, () -> this.delegate.destroySubcontext(name)); + } + + @Override + public void destroySubcontext(String name) throws javax.naming.NamingException { + Observation observation = observation("destroy.subcontext").highCardinalityKeyValue(name(name)); + observe(observation, () -> this.delegate.destroySubcontext(name)); + } + + @Override + public Context createSubcontext(Name name) throws javax.naming.NamingException { + Observation observation = observation("create.subcontext").highCardinalityKeyValue(name(name)); + return observe(observation, () -> this.delegate.createSubcontext(name)); + } + + @Override + public Context createSubcontext(String name) throws javax.naming.NamingException { + Observation observation = observation("create.subcontext").highCardinalityKeyValue(name(name)); + return observe(observation, () -> this.delegate.createSubcontext(name)); + } + + @Override + public Object lookupLink(Name name) throws javax.naming.NamingException { + Observation observation = observation("lookup.link").highCardinalityKeyValue(name(name)); + return observe(observation, () -> this.delegate.lookupLink(name)); + } + + @Override + public Object lookupLink(String name) throws javax.naming.NamingException { + Observation observation = observation("lookup.link").highCardinalityKeyValue(name(name)); + return observe(observation, () -> this.delegate.lookupLink(name)); + } + + @Override + public NameParser getNameParser(Name name) throws javax.naming.NamingException { + return this.delegate.getNameParser(name); + } + + @Override + public NameParser getNameParser(String name) throws javax.naming.NamingException { + return this.delegate.getNameParser(name); + } + + @Override + public Name composeName(Name name, Name prefix) throws javax.naming.NamingException { + return this.delegate.composeName(name, prefix); + } + + @Override + public String composeName(String name, String prefix) throws javax.naming.NamingException { + return this.delegate.composeName(name, prefix); + } + + @Override + public Object addToEnvironment(String propName, Object propVal) throws javax.naming.NamingException { + return this.delegate.addToEnvironment(propName, propVal); + } + + @Override + public Object removeFromEnvironment(String propName) throws javax.naming.NamingException { + return this.removeFromEnvironment(propName); + } + + @Override + public Hashtable getEnvironment() throws javax.naming.NamingException { + return this.delegate.getEnvironment(); + } + + @Override + public void close() throws javax.naming.NamingException { + this.delegate.close(); + } + + @Override + public String getNameInNamespace() throws javax.naming.NamingException { + Observation observation = observation("get.name.in.namespace"); + return observe(observation, () -> this.delegate.getNameInNamespace()); + } + + T observe(Observation observation, ThrowableSupplier supplier) throws javax.naming.NamingException { + observation.start(); + try (io.micrometer.observation.Observation.Scope scope = observation.openScope()) { + return supplier.get(); + } + catch (Throwable ex) { + observation.error(ex); + throw ex; + } + finally { + observation.stop(); + } + } + + void observe(Observation observation, ThrowableRunnable runnable) throws javax.naming.NamingException { + observation.start(); + try (io.micrometer.observation.Observation.Scope scope = observation.openScope()) { + runnable.run(); + } + catch (Throwable ex) { + observation.error(ex); + throw ex; + } + finally { + observation.stop(); + } + } + + interface ThrowableSupplier { + + T get() throws javax.naming.NamingException; + + } + + interface ThrowableRunnable { + + void run() throws javax.naming.NamingException; + + } + + } + +} diff --git a/core/src/test/java/org/springframework/ldap/core/support/ObservationContextSourceTests.java b/core/src/test/java/org/springframework/ldap/core/support/ObservationContextSourceTests.java new file mode 100644 index 000000000..4c3f79324 --- /dev/null +++ b/core/src/test/java/org/springframework/ldap/core/support/ObservationContextSourceTests.java @@ -0,0 +1,61 @@ +/* + * Copyright 2005-2025 the original author or authors. + * + * Licensed 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 org.springframework.ldap.core.support; + +import java.util.Hashtable; + +import javax.naming.NamingException; +import javax.naming.directory.DirContext; + +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.junit.Test; + +import static org.mockito.Mockito.mock; + +public class ObservationContextSourceTests { + + private final TestObservationRegistry registry = TestObservationRegistry.create(); + + @Test + public void dirContextGetAttributesWhenObservingThenObserves() throws Exception { + TestContextSource observed = new TestContextSource(); + ObservationContextSource observing = new ObservationContextSource(observed, this.registry); + observing.getReadOnlyContext().getAttributes("ou=user,ou=people"); + // @formatter:off + TestObservationRegistryAssert.assertThat(this.registry) + .hasObservationWithNameEqualTo("spring.ldap.dir.context.operations").that() + .hasContextualNameEqualTo("perform get.attributes"); + // @formatter:on + } + + private final class TestContextSource extends AbstractContextSource { + + TestContextSource() { + setUrls(new String[] { "ldap://localhost:1234" }); + setBase("dc=example,dc=org"); + afterPropertiesSet(); + } + + @Override + protected DirContext getDirContextInstance(Hashtable environment) throws NamingException { + return mock(DirContext.class); + } + + } + +} diff --git a/dependencies/build.gradle b/dependencies/build.gradle index 39eec70d6..4a9c01e30 100644 --- a/dependencies/build.gradle +++ b/dependencies/build.gradle @@ -24,6 +24,8 @@ dependencies { api platform("org.springframework.data:spring-data-bom:2021.2.18") } api platform('com.fasterxml.jackson:jackson-bom:2.15.4') + api platform("io.micrometer:micrometer-bom:1.14.2") + api platform("io.micrometer:micrometer-tracing-bom:1.4.1") api platform("org.junit:junit-bom:5.8.2") constraints { api "com.querydsl:querydsl-core:$queryDslVersion" diff --git a/modules/ROOT/pages/observability.adoc b/modules/ROOT/pages/observability.adoc new file mode 100644 index 000000000..5ea973618 --- /dev/null +++ b/modules/ROOT/pages/observability.adoc @@ -0,0 +1,44 @@ +[[observability]] += Observability Support + +Spring LDAP integrates with Micromter. +Similar to LDAP's transaction support, observability happens in `DirContext` instances. + +== Minimal Configuration + +To minimally configure a Spring Boot application to observe LDAP operations, first configure Boot, then publish the following bean: + +[source,java] +==== +@Bean +static ContextSourceObservationPostProcessor observationPostProcessor(ObjectProvider provider) { + return new ContextSourceObservationPostProcessor(provider); +} +==== + +This will cause the `LdapContextSource` bean that Boot publishes to be wrapped in an `ObservationContextSource` which will time LDAP queries and emit corresponding traces. + +Or if you aren't using Boot or are otherwise publishing your own `ContextSource` instance, then you can do: + +[source,java] +==== +@Bean +ContextSource contextSource(ObservationRegistry registry) { + ContextSource observed = createContextSource(); + return new ObservationContextSource(observed, registry); +} +==== + +== Runtime Expectations + +Each observation is under the name `spring.ldap.dir.context.operations` with a contextual name of `perform _operation_`. + +The following is a truncated output of `DirContext#getAttributes` when instrumented with Micrometer: + +[source,bash] +==== +2025-01-06T19:02:00.343-07:00 INFO 2036360 --- [nio-8080-exec-1] [677c8af4f5f02980a1584d0ac0b9fba4-d38e906e55d92746] i.m.o.ObservationTextPublisher : START - name='spring.ldap.dir.context.operations', contextualName='null', error='null', lowCardinalityKeyValues=[base='dc=springframework,dc=org', operation='get.attributes', urls='[ldap://localhost:41283/]'], highCardinalityKeyValues=[attribute.ids='null', name='uid=user,ou=people'], ... duration(nanos)=243104.0 ... parentObservation={name=spring.security.authentications ... parentObservation={name=spring.security.filterchains(security filterchain before) ... parentObservation={name=http.server.requests(null) ... +2025-01-06T19:02:00.345-07:00 INFO 2036360 --- [nio-8080-exec-1] [677c8af4f5f02980a1584d0ac0b9fba4-daa1bbc981aade27] i.m.o.ObservationTextPublisher : OPEN - name='spring.ldap.dir.context.operations', ... +2025-01-06T19:02:00.363-07:00 INFO 2036360 --- [nio-8080-exec-1] [677c8af4f5f02980a1584d0ac0b9fba4-daa1bbc981aade27] i.m.o.ObservationTextPublisher : CLOSE - name='spring.ldap.dir.context.operations', ... +2025-01-06T19:02:00.364-07:00 INFO 2036360 --- [nio-8080-exec-1] [677c8af4f5f02980a1584d0ac0b9fba4-d38e906e55d92746] i.m.o.ObservationTextPublisher : STOP - name='spring.ldap.dir.context.operations', ... +====