TaxonomyObservationConfiguration.java

package com.taxonomy.observability;

import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.aop.framework.Advised;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.util.ClassUtils;

import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;

/**
 * Adds low-cardinality Micrometer observations to Taxonomy-owned service boundaries.
 *
 * <p>The OpenTelemetry Java agent remains responsible for automatic technical spans
 * and selected method spans. These observations intentionally provide the existing
 * Micrometer/Prometheus path with stable domain timings and outcomes. No invocation
 * argument, return value, user, workspace, repository, query, prompt, response or
 * document content is inspected or attached to an observation.</p>
 */
@Configuration(proxyBeanMethods = false)
public class TaxonomyObservationConfiguration {

    @Bean
    static BeanPostProcessor taxonomyObservationBeanPostProcessor(
            ObjectProvider<ObservationRegistry> registryProvider) {
        // BeanPostProcessors are created before many ordinary infrastructure beans.
        // Resolve the registry only when an observed operation is invoked so an early
        // NOOP fallback cannot become permanent for the lifetime of the application.
        Supplier<ObservationRegistry> registrySupplier = () -> registryProvider.getIfAvailable(
                () -> ObservationRegistry.NOOP);
        return new TaxonomyObservationBeanPostProcessor(registrySupplier);
    }

    record TargetDescriptor(String observationName, String component,
                            Set<String> methods) {
        TargetDescriptor {
            methods = Set.copyOf(methods);
        }
    }

    static final class TaxonomyObservationBeanPostProcessor
            implements BeanPostProcessor, Ordered {

        private static final Map<String, TargetDescriptor> TARGETS = Map.ofEntries(
                Map.entry("com.taxonomy.workspace.service.WorkspaceContextResolver",
                        new TargetDescriptor("taxonomy.workspace.resolve", "workspace",
                                Set.of("resolveCurrentContext", "resolveForUser",
                                        "resolveCurrentRepositoryContext",
                                        "resolveRepositoryContextForUser"))),
                Map.entry("com.taxonomy.dsl.storage.DslGitRepositoryFactory",
                        new TargetDescriptor("taxonomy.repository.route", "repository",
                                Set.of("resolveRepository", "getSystemRepository",
                                        "getWorkspaceRepository"))),
                Map.entry("com.taxonomy.versioning.service.DslOperationsFacade",
                        new TargetDescriptor("taxonomy.repository.operation", "repository",
                                Set.of("materialize", "materializeIncremental", "commitDsl",
                                        "getDslHistory", "diffBetween", "textDiff",
                                        "listBranches", "createBranch", "cherryPick", "merge",
                                        "revert", "undoLast", "restore", "getDslAtHead",
                                        "getDslAtCommit", "getHeadCommit", "indexBranch",
                                        "searchHistory"))),
                Map.entry("com.taxonomy.catalog.service.SearchService",
                        new TargetDescriptor("taxonomy.search", "search", Set.of("search"))),
                Map.entry("com.taxonomy.analysis.usecase.AnalyzeRequirementUseCase",
                        new TargetDescriptor("taxonomy.analysis", "analysis", Set.of("analyze"))),
                Map.entry("com.taxonomy.analysis.usecase.AnalyzeNodeChildrenUseCase",
                        new TargetDescriptor("taxonomy.analysis", "analysis", Set.of("analyze"))),
                Map.entry("com.taxonomy.analysis.service.LlmService",
                        new TargetDescriptor("taxonomy.llm", "llm",
                                Set.of("analyzeWithBudget", "analyzeSingleBatchDetailed",
                                        "generateLeafJustification", "callLlmRaw"))),
                Map.entry("com.taxonomy.catalog.service.importer.FrameworkImportService",
                        new TargetDescriptor("taxonomy.import", "import",
                                Set.of("preview", "importFile"))),
                Map.entry("com.taxonomy.export.service.ExportFacade",
                        new TargetDescriptor("taxonomy.export", "export",
                                Set.of("exportAsVisio", "exportAsArchiMate", "exportAsMermaid",
                                        "exportAsStructurizrDsl", "buildDiagram",
                                        "importFromJson")))
        );

        private final Supplier<ObservationRegistry> registrySupplier;

        TaxonomyObservationBeanPostProcessor(
                Supplier<ObservationRegistry> registrySupplier) {
            this.registrySupplier = registrySupplier;
        }

        @Override
        public Object postProcessAfterInitialization(Object bean, String beanName)
                throws BeansException {
            Class<?> targetClass = ClassUtils.getUserClass(AopUtils.getTargetClass(bean));
            TargetDescriptor descriptor = TARGETS.get(targetClass.getName());
            if (descriptor == null) {
                return bean;
            }

            MethodInterceptor interceptor =
                    new TaxonomyObservationInterceptor(registrySupplier, descriptor);
            if (bean instanceof Advised advised) {
                advised.addAdvice(interceptor);
                return bean;
            }

            ProxyFactory proxyFactory = new ProxyFactory(bean);
            proxyFactory.setProxyTargetClass(true);
            proxyFactory.addAdvice(interceptor);
            return proxyFactory.getProxy(bean.getClass().getClassLoader());
        }

        @Override
        public int getOrder() {
            return Ordered.LOWEST_PRECEDENCE;
        }
    }

    static final class TaxonomyObservationInterceptor implements MethodInterceptor {

        private static final Logger log = LoggerFactory.getLogger(
                TaxonomyObservationInterceptor.class);

        private final Supplier<ObservationRegistry> registrySupplier;
        private final TargetDescriptor descriptor;

        TaxonomyObservationInterceptor(ObservationRegistry observationRegistry,
                                       TargetDescriptor descriptor) {
            this(() -> observationRegistry, descriptor);
        }

        TaxonomyObservationInterceptor(Supplier<ObservationRegistry> registrySupplier,
                                       TargetDescriptor descriptor) {
            this.registrySupplier = registrySupplier;
            this.descriptor = descriptor;
        }

        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
            String operation = invocation.getMethod().getName();
            if (!descriptor.methods().contains(operation)) {
                return invocation.proceed();
            }

            ObservationRegistry observationRegistry = registrySupplier.get();
            if (observationRegistry == null) {
                observationRegistry = ObservationRegistry.NOOP;
            }
            Observation observation = Observation.createNotStarted(
                            descriptor.observationName(), observationRegistry)
                    .lowCardinalityKeyValue("taxonomy.component", descriptor.component())
                    .lowCardinalityKeyValue("taxonomy.operation", operation)
                    .start();
            try (Observation.Scope ignored = observation.openScope()) {
                Object result = invocation.proceed();
                observation.lowCardinalityKeyValue("outcome", "success");
                log.debug("Observed taxonomy operation component={} operation={} outcome=success",
                        descriptor.component(), operation);
                return result;
            } catch (Throwable failure) {
                // The exception is deliberately not attached to the Observation or log because
                // its message may contain imported, DSL or LLM content. Agent method spans retain
                // exception type and error status independently.
                observation.lowCardinalityKeyValue("outcome", "error");
                log.debug("Observed taxonomy operation component={} operation={} outcome=error",
                        descriptor.component(), operation);
                throw failure;
            } finally {
                observation.stop();
            }
        }
    }
}