001package io.prometheus.metrics.config;
002
003import java.io.IOException;
004import java.io.InputStream;
005import java.nio.file.Files;
006import java.nio.file.Paths;
007import java.util.HashMap;
008import java.util.Map;
009import java.util.Properties;
010import java.util.Set;
011
012/**
013 * The Properties Loader is early stages.
014 *
015 * <p>It would be great to implement a subset of <a
016 * href="https://docs.spring.io/spring-boot/docs/3.1.x/reference/html/features.html#features.external-config">Spring
017 * Boot's Externalized Configuration</a>, like support for YAML, Properties, and env vars, or
018 * support for Spring's naming conventions for properties.
019 */
020public class PrometheusPropertiesLoader {
021
022  /** See {@link PrometheusProperties#get()}. */
023  public static PrometheusProperties load() throws PrometheusPropertiesException {
024    return load(new Properties());
025  }
026
027  public static PrometheusProperties load(Map<Object, Object> externalProperties)
028      throws PrometheusPropertiesException {
029    PropertySource propertySource = loadProperties(externalProperties);
030    PrometheusProperties.MetricPropertiesMap metricsConfigs = loadMetricsConfigs(propertySource);
031    MetricsProperties defaultMetricsProperties =
032        MetricsProperties.load("io.prometheus.metrics", propertySource);
033    ExemplarsProperties exemplarConfig = ExemplarsProperties.load(propertySource);
034    ExporterProperties exporterProperties = ExporterProperties.load(propertySource);
035    ExporterFilterProperties exporterFilterProperties =
036        ExporterFilterProperties.load(propertySource);
037    ExporterHttpServerProperties exporterHttpServerProperties =
038        ExporterHttpServerProperties.load(propertySource);
039    ExporterPushgatewayProperties exporterPushgatewayProperties =
040        ExporterPushgatewayProperties.load(propertySource);
041    ExporterOpenTelemetryProperties exporterOpenTelemetryProperties =
042        ExporterOpenTelemetryProperties.load(propertySource);
043    OpenMetrics2Properties openMetrics2Properties = OpenMetrics2Properties.load(propertySource);
044    validateAllPropertiesProcessed(propertySource);
045    return new PrometheusProperties(
046        defaultMetricsProperties,
047        metricsConfigs,
048        exemplarConfig,
049        exporterProperties,
050        exporterFilterProperties,
051        exporterHttpServerProperties,
052        exporterPushgatewayProperties,
053        exporterOpenTelemetryProperties,
054        openMetrics2Properties);
055  }
056
057  // This will remove entries from propertySource when they are processed.
058  static PrometheusProperties.MetricPropertiesMap loadMetricsConfigs(
059      PropertySource propertySource) {
060    PrometheusProperties.MetricPropertiesMap result =
061        new PrometheusProperties.MetricPropertiesMap();
062    // Note that the metric name in the properties file must be as exposed in the Prometheus
063    // exposition formats,
064    // i.e. all dots replaced with underscores.
065
066    // Get a snapshot of all keys for pattern matching. Entries will be removed
067    // when MetricsProperties.load(...) is called.
068    Set<String> propertyNames = propertySource.getAllKeys();
069    for (String propertyName : propertyNames) {
070      String metricName = null;
071
072      if (propertyName.startsWith("io.prometheus.metrics.")) {
073        // Dot-separated format (from regular properties, system properties, or files)
074        String remainder = propertyName.substring("io.prometheus.metrics.".length());
075        // Try to match against known property suffixes
076        for (String suffix : MetricsProperties.PROPERTY_SUFFIXES) {
077          if (remainder.endsWith("." + suffix)) {
078            // Metric name in dot format, convert dots to underscores for exposition format
079            metricName =
080                remainder.substring(0, remainder.length() - suffix.length() - 1).replace(".", "_");
081            break;
082          }
083        }
084      } else if (propertyName.startsWith("io_prometheus_metrics_")) {
085        // Underscore-separated format (from environment variables)
086        String remainder = propertyName.substring("io_prometheus_metrics_".length());
087        // Try to match against known property suffixes
088        for (String suffix : MetricsProperties.PROPERTY_SUFFIXES) {
089          if (remainder.endsWith("_" + suffix)) {
090            metricName = remainder.substring(0, remainder.length() - suffix.length() - 1);
091            break;
092          }
093        }
094      }
095
096      if (metricName != null && result.get(metricName) == null) {
097        result.put(
098            metricName,
099            MetricsProperties.load("io.prometheus.metrics." + metricName, propertySource));
100      }
101    }
102    return result;
103  }
104
105  // If there are properties left starting with io.prometheus it's likely a typo,
106  // because we didn't use that property.
107  // Throw a config error to let the user know that this property doesn't exist.
108  private static void validateAllPropertiesProcessed(PropertySource propertySource) {
109    for (String key : propertySource.getRemainingKeys()) {
110      if (key.startsWith("io.prometheus") || key.startsWith("io_prometheus")) {
111        throw new PrometheusPropertiesException(key + ": Unknown property");
112      }
113    }
114  }
115
116  private static PropertySource loadProperties(Map<Object, Object> externalProperties) {
117    // Regular properties (lowest priority): classpath, file, system properties
118    Map<Object, Object> regularProperties = new HashMap<>();
119    // Normalize all properties at load time to handle camelCase in files for backward compatibility
120    normalizeAndPutAll(regularProperties, loadPropertiesFromClasspath());
121    normalizeAndPutAll(
122        regularProperties,
123        loadPropertiesFromFile()); // overriding the entries from the classpath file
124    // overriding the entries from the properties file
125    // copy System properties to avoid ConcurrentModificationException
126    // normalize camelCase system properties to snake_case for backward compatibility
127    System.getProperties().stringPropertyNames().stream()
128        .filter(key -> key.startsWith("io.prometheus"))
129        .forEach(key -> regularProperties.put(normalizePropertyKey(key), System.getProperty(key)));
130
131    // Environment variables (second priority): just lowercase, keep underscores
132    Map<Object, Object> envVarProperties = loadPropertiesFromEnvironment();
133
134    // External properties (highest priority): normalize camelCase for backward compatibility
135    Map<Object, Object> normalizedExternalProperties = new HashMap<>();
136    externalProperties.forEach(
137        (key, value) ->
138            normalizedExternalProperties.put(normalizePropertyKey(key.toString()), value));
139
140    return new PropertySource(normalizedExternalProperties, envVarProperties, regularProperties);
141  }
142
143  private static void normalizeAndPutAll(Map<Object, Object> target, Map<Object, Object> source) {
144    source.forEach((key, value) -> target.put(normalizePropertyKey(key.toString()), value));
145  }
146
147  private static Properties loadPropertiesFromClasspath() {
148    Properties properties = new Properties();
149    try (InputStream stream =
150        Thread.currentThread()
151            .getContextClassLoader()
152            .getResourceAsStream("prometheus.properties")) {
153      properties.load(stream);
154    } catch (Exception ignored) {
155      // No properties file found on the classpath.
156    }
157    return properties;
158  }
159
160  private static Properties loadPropertiesFromFile() throws PrometheusPropertiesException {
161    Properties properties = new Properties();
162    String path = System.getProperty("prometheus.config");
163    if (System.getenv("PROMETHEUS_CONFIG") != null) {
164      path = System.getenv("PROMETHEUS_CONFIG");
165    }
166    if (path != null) {
167      try (InputStream stream = Files.newInputStream(Paths.get(path))) {
168        properties.load(stream);
169      } catch (IOException e) {
170        throw new PrometheusPropertiesException(
171            "Failed to read Prometheus properties from " + path + ": " + e.getMessage(), e);
172      }
173    }
174    return properties;
175  }
176
177  /**
178   * Load properties from environment variables.
179   *
180   * <p>Environment variables are converted to lowercase but keep underscores as-is. For example,
181   * the environment variable IO_PROMETHEUS_METRICS_EXEMPLARS_ENABLED becomes
182   * io_prometheus_metrics_exemplars_enabled.
183   *
184   * <p>The transformation to dot notation happens at access time in PropertySource.
185   *
186   * <p>Only environment variables starting with IO_PROMETHEUS are considered.
187   *
188   * @return properties loaded from environment variables (with lowercase keys and underscores)
189   */
190  private static Map<Object, Object> loadPropertiesFromEnvironment() {
191    Map<Object, Object> properties = new HashMap<>();
192    System.getenv()
193        .forEach(
194            (key, value) -> {
195              if (key.startsWith("IO_PROMETHEUS")) {
196                String normalizedKey = key.toLowerCase(java.util.Locale.ROOT);
197                properties.put(normalizedKey, value);
198              }
199            });
200    return properties;
201  }
202
203  /**
204   * Normalize a property key for consistent lookup.
205   *
206   * <p>Converts camelCase property keys to snake_case. This allows both snake_case (preferred) and
207   * camelCase (deprecated) property names to be used.
208   *
209   * <p>For example: exemplarsEnabled → exemplars_enabled exemplars_enabled → exemplars_enabled
210   * (unchanged)
211   *
212   * @param key the property key
213   * @return the normalized property key
214   */
215  static String normalizePropertyKey(String key) {
216    // Insert underscores before uppercase letters to convert camelCase to snake_case
217    String withUnderscores = key.replaceAll("([a-z])([A-Z])", "$1_$2");
218    return withUnderscores.toLowerCase(java.util.Locale.ROOT);
219  }
220}