LogReporter.java

package io.jstach.rainbowgum;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;

import org.jspecify.annotations.Nullable;

/**
 * Renders a diagnostic report of a bound {@link RainbowGum}: which sections are included
 * ({@link Section#VERSION}, {@link Section#COMPONENTS}, {@link Section#METRICS},
 * {@link Section#ALERTS}) depends on how the {@link Builder} that created this reporter
 * was configured.
 *
 * @apiNote The rendered output's format carries <strong>no compatibility guarantee
 * whatsoever</strong>. It is meant to be read by a human, or an agent, while setting
 * things up or debugging an incident, not parsed by anything expecting a stable schema,
 * and may change between any two releases including patch releases.
 */
public sealed interface LogReporter permits DefaultLogReporter {

	/**
	 * Writes the report for the given RainbowGum directly to the given appendable, rather
	 * than building the whole report in memory first, so a caller can stream straight
	 * into a {@link java.io.Writer}/response even while the system is in a degraded
	 * state.
	 * @param gum bound RainbowGum instance to report on.
	 * @param appendable destination.
	 * @throws IOException if the appendable throws.
	 */
	void report(RainbowGum gum, Appendable appendable) throws IOException;

	/**
	 * Writes the report to a {@link StringBuilder}, which never actually throws
	 * {@link IOException} in practice, so callers do not need to handle one.
	 * @param gum bound RainbowGum instance to report on.
	 * @param sb destination.
	 */
	default void report(RainbowGum gum, StringBuilder sb) {
		try {
			report(gum, (Appendable) sb);
		}
		catch (IOException e) {
			throw new UncheckedIOException(e);
		}
	}

	/**
	 * Renders the report as a String.
	 * @param gum bound RainbowGum instance to report on.
	 * @return report.
	 */
	default String report(RainbowGum gum) {
		var sb = new StringBuilder();
		report(gum, sb);
		return sb.toString();
	}

	/**
	 * Implemented by a component that wants to contribute its own description to a report
	 * instead of falling back to just its class name. Entirely optional: nothing in this
	 * codebase or a custom {@link LogOutput}/{@link LogEncoder} implementation is
	 * required to implement this.
	 *
	 * @apiNote a {@link LogOutput} implementing this does <strong>not</strong> get the
	 * same "no overlapping calls" guarantee {@link LogOutput}'s other methods are
	 * promised: a report can be requested from an unrelated thread (e.g. an operator
	 * pulling diagnostics) at any time, including while the appender is concurrently mid
	 * write/flush/reopen on its own thread. {@link #report(Appendable)} must therefore be
	 * safe to call concurrently with itself and with every other {@link LogOutput}
	 * method, unlike the rest of that interface's contract.
	 */
	interface Reportable {

		/**
		 * Returns a concise description of a component. A {@link Reportable} supplies its
		 * own description; ordinary named classes use their canonical name, while
		 * anonymous, synthetic, and local classes use the supplied fallback.
		 * @param component component to describe.
		 * @param fallback description for a component without a stable class name.
		 * @return component description.
		 */
		static String toString(Object component, String fallback) {
			if (component instanceof Reportable reportable) {
				var out = new StringBuilder();
				try {
					reportable.report(out);
				}
				catch (IOException e) {
					return fallback;
				}
				return out.toString();
			}
			var type = component.getClass();
			if (type.isAnonymousClass() || type.isLocalClass() || type.isSynthetic()) {
				return fallback;
			}
			var name = type.getCanonicalName();
			return name != null ? name : fallback;
		}

		/**
		 * Writes this component's own description directly to the given appendable.
		 * @param out destination.
		 * @throws IOException if the appendable throws.
		 */
		void report(Appendable out) throws IOException;

	}

	/**
	 * A section of the report that can be independently turned on or off.
	 */
	enum Section {

		/**
		 * Rainbow Gum's own version, printed first if included.
		 */
		VERSION,
		/**
		 * The wired component tree: active global properties, then each router's
		 * publisher, appenders, outputs, and encoders.
		 */
		COMPONENTS,
		/**
		 * Metrics from {@link LogConfig#metrics()}.
		 */
		METRICS,
		/**
		 * Recent entries from {@link LogConfig#alerts()}, see
		 * {@link Builder#maxAlerts(int)}.
		 */
		ALERTS,
		/**
		 * Logger names registered via
		 * {@link LogConfig.LoggerRegistry#registerLoggerName(LoggerAPI, String)}, each
		 * with its resolved level, see {@link Builder#loggerLevels(boolean)}.
		 */
		LOGGERS,
		/**
		 * The distinct set of {@link LoggerAPI}s registered via
		 * {@link LogConfig.LoggerRegistry#registerLoggerName(LoggerAPI, String)}, e.g.
		 * seeing both {@code SLF4J} and {@code JUL} means both facades are in active use
		 * (not just on the classpath).
		 */
		FACADES;

	}

	/**
	 * Creates a builder.
	 * @return builder.
	 */
	static Builder builder() {
		return new Builder();
	}

	/**
	 * Builds a {@link LogReporter}.
	 */
	final class Builder {

		private static final Set<Section> DEFAULT_SECTIONS = EnumSet.of(Section.VERSION, Section.COMPONENTS);

		private static final LogFormatter DEFAULT_ALERT_FORMATTER = LogFormatter.builder()
			.text("[")
			.timeStamp()
			.text("] ")
			.level()
			.text(" ")
			.loggerName()
			.text(" - ")
			.message()
			.build();

		private final EnumSet<Section> sections = EnumSet.noneOf(Section.class);

		private int maxAlerts = 5;

		private LogFormatter alertFormatter = DEFAULT_ALERT_FORMATTER;

		private boolean loggerLevels = true;

		private Builder() {
		}

		/**
		 * Replaces the set of sections to print. An empty set is treated the same as
		 * never calling this method at all: the default sections
		 * ({@link Section#VERSION}, {@link Section#COMPONENTS}) are used.
		 * @param sections sections to print.
		 * @return this.
		 */
		public Builder sections(Set<Section> sections) {
			this.sections.clear();
			this.sections.addAll(sections);
			return this;
		}

		/**
		 * Adds a single section to print, in addition to any already added.
		 * @param section section to print.
		 * @return this.
		 */
		public Builder section(Section section) {
			this.sections.add(section);
			return this;
		}

		/**
		 * Maximum number of most recent alerts to print when {@link Section#ALERTS} is
		 * included. Oldest-first entries beyond this count are omitted, not truncated mid
		 * list. Default is 5.
		 * @param maxAlerts maximum alerts to print, zero or greater.
		 * @return this.
		 */
		public Builder maxAlerts(int maxAlerts) {
			if (maxAlerts < 0) {
				throw new IllegalArgumentException("maxAlerts must be zero or greater: " + maxAlerts);
			}
			this.maxAlerts = maxAlerts;
			return this;
		}

		/**
		 * Formatter used to render each alert entry when {@link Section#ALERTS} is
		 * included. Alerts are just {@link LogEvent}s (see {@link LogAlerts#dump()}), so
		 * any {@link LogFormatter} works here, e.g. one that omits the timestamp for a
		 * deterministic report. Default prints
		 * <code>[timestamp] LEVEL loggerName - message</code>.
		 * @param alertFormatter formatter for a single alert.
		 * @return this.
		 */
		public Builder alertFormatter(LogFormatter alertFormatter) {
			this.alertFormatter = Objects.requireNonNull(alertFormatter);
			return this;
		}

		/**
		 * Whether to resolve and print each logger's level, via
		 * {@link LogConfig#levelResolver()}, next to its name when
		 * {@link Section#LOGGERS} is included. Default is <code>true</code>.
		 * @param loggerLevels false to print just the bare logger names.
		 * @return this.
		 */
		public Builder loggerLevels(boolean loggerLevels) {
			this.loggerLevels = loggerLevels;
			return this;
		}

		/**
		 * Builds the reporter.
		 * @return reporter.
		 */
		public LogReporter build() {
			Set<Section> resolved = sections.isEmpty() ? DEFAULT_SECTIONS : EnumSet.copyOf(sections);
			return new DefaultLogReporter(resolved, maxAlerts, alertFormatter, loggerLevels);
		}

	}

}

record DefaultLogReporter(Set<LogReporter.Section> sections, int maxAlerts, LogFormatter alertFormatter,
		boolean loggerLevels) implements LogReporter {

	/*
	 * Every one of these is already a named constant in LogProperties, printed as
	 * "key = value" rather than modeled as a new type, since none of these is a leaf on
	 * the component tree below: they are process wide context that colors how to read
	 * everything that follows.
	 */
	private static final List<String> GLOBAL_PROPERTY_KEYS = List.of(LogProperties.GLOBAL_CHANGE_PROPERTY,
			LogProperties.GLOBAL_QUEUE_LEVEL_PROPERTY, LogProperties.GLOBAL_QUEUE_ERROR_PROPERTY,
			LogProperties.GLOBAL_ANSI_DISABLE_PROPERTY, LogProperties.GLOBAL_APPENDER_REENTRANT_LOCK_PROPERTY,
			LogProperties.GLOBAL_THREADLOCAL_DISABLED_PROPERTY, LogProperties.GLOBAL_OPTIMIZE_PROPERTY);

	/*
	 * Every section is separated from the next by exactly one blank line, added here
	 * rather than left to each appendXxx method to manage its own leading/trailing
	 * newline: that previously meant whether a blank line actually showed up between two
	 * sections was an accident of which of the two happened to append one (VERSION and
	 * METRICS did, COMPONENTS/ALERTS/LOGGERS did not), so e.g. COMPONENTS immediately
	 * followed by METRICS printed with no separating blank line at all.
	 */
	@Override
	public void report(RainbowGum gum, Appendable out) throws IOException {
		boolean first = true;
		if (sections.contains(LogReporter.Section.VERSION)) {
			first = appendSeparator(out, first);
			appendVersion(out);
		}
		var config = gum.config();
		if (sections.contains(LogReporter.Section.COMPONENTS)) {
			first = appendSeparator(out, first);
			appendComponents(out, gum, config);
		}
		if (sections.contains(LogReporter.Section.METRICS)) {
			first = appendSeparator(out, first);
			appendMetrics(out, config.metrics());
		}
		if (sections.contains(LogReporter.Section.ALERTS)) {
			first = appendSeparator(out, first);
			appendAlerts(out, config.alerts());
		}
		if (sections.contains(LogReporter.Section.LOGGERS)) {
			first = appendSeparator(out, first);
			appendLoggers(out, config);
		}
		if (sections.contains(LogReporter.Section.FACADES)) {
			first = appendSeparator(out, first);
			appendFacades(out, config);
		}
	}

	private static boolean appendSeparator(Appendable out, boolean first) throws IOException {
		if (!first) {
			out.append("\n");
		}
		return false;
	}

	private void appendVersion(Appendable out) throws IOException {
		out.append("Rainbow Gum ").append(RainbowGumVersion.VERSION).append("\n");
	}

	private void appendComponents(Appendable out, RainbowGum gum, LogConfig config) throws IOException {
		appendGlobalProperties(out, config.properties());
		out.append("Debug mode: ").append(config.debugMode().toString()).append("\n");
		out.append("\n");
		for (var router : routersOf(gum.router())) {
			appendRouter(out, config, router);
		}
	}

	private void appendGlobalProperties(Appendable out, LogProperties properties) throws IOException {
		out.append("Properties: ");
		LogPropertiesReporting.describe(out, properties);
		out.append("\n");
		out.append("Global Properties:\n");
		for (String key : GLOBAL_PROPERTY_KEYS) {
			String value = properties.valueOrNull(key);
			out.append("  ").append(key).append(" = ").append(value == null ? "(unset)" : value).append("\n");
		}
	}

	private static List<LogRouter.Router> routersOf(LogRouter.RootRouter root) {
		return switch (root) {
			case SingleRootRouter s -> List.of(s.router());
			case CompositeLogRouter c -> List.of(c.routers());
			/*
			 * Neither of these carries a real, fully bound set of named routers: both are
			 * pre-bind/queueing states (GlobalLogRouter.INSTANCE is the "nothing bound
			 * yet" default, delegating to a QueueEventsRouter until a real RainbowGum
			 * binds). report(RainbowGum, ...) is only ever called with an already-bound
			 * instance, so these are unreachable in practice, not a real gap.
			 */
			case QueueEventsRouter q -> List.of();
			case GlobalLogRouter g -> List.of();
		};
	}

	private void appendRouter(Appendable out, LogConfig config, LogRouter.Router router) throws IOException {
		String name = router instanceof SimpleRouter sr ? sr.name() : router.getClass().getSimpleName();
		out.append("Router: ").append(name).append("\n");
		appendPublisher(out, config, name, router.publisher());
	}

	private void appendPublisher(Appendable out, LogConfig config, String routeName, LogPublisher publisher)
			throws IOException {
		out.append("  Publisher: ")
			.append(publisher.getClass().getSimpleName())
			.append(publisher.synchronous() ? " (synchronous)" : " (asynchronous)")
			.append("\n");
		/*
		 * LogRouter.Router.Builder.build() registers the Appenders it hands to the
		 * publisher factory under this same route name (see there), so this works for any
		 * publisher, current or future, that uses Appenders.asList()/asSingle() the
		 * ordinary way, with no cooperation required from the publisher itself.
		 * resolvedOrNull() is null only if the publisher never called either one, which
		 * means it does not use the appenders this ordinary way at all.
		 */
		var apps = config.serviceRegistry().findOrNull(LogAppender.Appenders.class, routeName);
		var resolved = apps == null ? null : apps.resolvedOrNull();
		if (resolved == null) {
			/*
			 * Reportable is the escape hatch for a publisher that does something unusual
			 * and does not go through Appenders normally at all, same as
			 * appendOutput/appendEncoder.
			 */
			if (publisher instanceof Reportable r) {
				out.append("    ");
				r.report(out);
				out.append("\n");
			}
			else {
				out.append("    (no appender information available for ")
					.append(publisher.getClass().getSimpleName())
					.append(")\n");
			}
			return;
		}
		for (var appender : resolved) {
			for (var direct : directAppendersOf(appender)) {
				appendAppender(out, direct);
			}
		}
	}

	private static List<DirectLogAppender> directAppendersOf(LogAppender appender) {
		return switch (appender) {
			case CompositeLogAppender c -> List.of(c.appenders());
			case DirectLogAppender d -> List.of(d);
			default -> List.of();
		};
	}

	private void appendAppender(Appendable out, DirectLogAppender appender) throws IOException {
		out.append("    Appender: ").append(appender.name()).append("\n");
		out.append("      Type: ").append(appender.getClass().getSimpleName()).append("\n");
		if (appender instanceof AbstractLogAppender a) {
			out.append("      Flags: ").append(a.flags.toString()).append("\n");
		}
		appendOutput(out, appender.output());
		appendEncoder(out, appender.encoder());
	}

	private void appendOutput(Appendable out, LogOutput output) throws IOException {
		out.append("      Output: ")
			.append(output.getClass().getSimpleName())
			.append(" (type=")
			.append(output.type().toString());
		URI uri = uriOrNull(output);
		if (uri != null) {
			out.append(", uri=").append(LogRedactor.redactUri(uri));
		}
		if (output instanceof Reportable r) {
			out.append(", ");
			r.report(out);
		}
		out.append(")\n");
	}

	private static @Nullable URI uriOrNull(LogOutput output) {
		try {
			return output.uri();
		}
		catch (UnsupportedOperationException e) {
			return null;
		}
	}

	private void appendEncoder(Appendable out, LogEncoder encoder) throws IOException {
		/*
		 * No accessor on LogEncoder itself for describing its own configuration beyond
		 * its concrete class: Object#toString() is not a safe fallback since most
		 * implementations never override it. A component that wants to contribute more
		 * can implement Reportable instead, entirely opt-in: see FormatterEncoder for the
		 * one built-in example.
		 */
		out.append("      Encoder: ").append(encoder.getClass().getSimpleName());
		if (encoder instanceof Reportable r) {
			out.append(" (");
			r.report(out);
			out.append(")");
		}
		out.append("\n");
	}

	private void appendMetrics(Appendable out, LogMetrics metrics) throws IOException {
		out.append("Metrics:\n");
		for (var metric : metrics.snapshot()) {
			out.append("  ")
				.append(metric.name())
				.append(" = ")
				.append(Long.toString(metric.value()))
				.append(" (")
				.append(metric.level().toString())
				.append(")\n");
		}
	}

	private void appendAlerts(Appendable out, LogAlerts alerts) throws IOException {
		var stats = alerts.stats();
		out.append("Alerts (total=")
			.append(Long.toString(stats.total()))
			.append(", capacity=")
			.append(Integer.toString(stats.capacity()))
			.append("):\n");
		var dump = alerts.dump();
		int show = Math.min(dump.size(), maxAlerts);
		StringBuilder line = new StringBuilder();
		for (var event : dump.subList(dump.size() - show, dump.size())) {
			line.setLength(0);
			alertFormatter.format(line, event);
			out.append("  ").append(line).append("\n");
		}
	}

	private void appendLoggers(Appendable out, LogConfig config) throws IOException {
		out.append("Loggers:\n");
		/*
		 * DefaultLoggerRegistry is the only implementation (LogConfig.LoggerRegistry
		 * deliberately does not expose the accumulated names itself, see its javadoc);
		 * downcasting here is the same pattern DefaultLogConfig's own constructor uses to
		 * reach DefaultLogAlerts#addInternalListener.
		 */
		var registry = (DefaultLoggerRegistry) config.loggerRegistry();
		for (String name : new TreeSet<>(registry.loggerNames())) {
			out.append("  ").append(name);
			if (loggerLevels) {
				out.append(" = ").append(config.levelResolver().resolveLevel(name).toString());
			}
			out.append("\n");
		}
	}

	private void appendFacades(Appendable out, LogConfig config) throws IOException {
		out.append("Facades:\n");
		var registry = (DefaultLoggerRegistry) config.loggerRegistry();
		var apis = new TreeSet<LoggerAPI>(Comparator.comparing(Object::toString));
		apis.addAll(registry.loggerAPIs());
		for (var api : apis) {
			out.append("  ").append(api.toString()).append("\n");
		}
	}

}