{"id":28164,"date":"2026-09-07T15:29:57","date_gmt":"2026-09-07T13:29:57","guid":{"rendered":"https:\/\/lets-scale.it\/?p=28164"},"modified":"2026-09-17T15:29:59","modified_gmt":"2026-09-17T13:29:59","slug":"quarkus-spring-boot-elasticsearch-grafana","status":"publish","type":"post","link":"https:\/\/lets-scale.it\/en\/quarkus-spring-boot-elasticsearch-grafana\/","title":{"rendered":"Securing Quarkus and Spring Boot with Elasticsearch and Grafana"},"content":{"rendered":"<p>The release is out, the Change Advisory Board has signed off, and the next morning the business unit reports that claims submissions are stuck. There are logs in Elasticsearch, but the Quarkus services write them differently than the Spring Boot services, and not a single line reveals the version. Anyone running Quarkus and Spring Boot side by side therefore needs shared conventions for logs, metrics and version information. Using an insurer as an example, we show how both frameworks deliver their data to Elasticsearch and how Grafana turns it into a verifiable criterion for every release.<\/p>\n<h2>Starting point: two Java frameworks, one Elasticsearch<\/h2>\n<p>The landscape we encounter at insurers has grown over time. Older business services for contract management and policy administration run on Spring Boot, newer services for claims submission or rate calculators run on Quarkus. Both run in containers on Kubernetes or OpenShift. Elasticsearch is set as the central log store and has been approved by information security, and Grafana serves as the interface for operations.<\/p>\n<p>Releases run in fixed windows. Every change goes through a Change Advisory Board, or CAB for short: a body that assesses the risk and rollback plan of a deployment before it is allowed into production. Today the CAB reviews documents, but no measurements from live operations.<\/p>\n<p>On top of this comes regulation. Since January 2025, the EU regulation DORA applies to insurers. Its Article 10 requires mechanisms that promptly detect anomalous activities and performance issues of ICT systems (<a href=\"https:\/\/eur-lex.europa.eu\/eli\/reg\/2022\/2554\/oj\">Regulation (EU) 2022\/2554<\/a>). Monitoring that only reacts once the business unit raises the alarm does not meet this standard.<\/p>\n<h2>Why Quarkus and Spring Boot diverge in operations<\/h2>\n<p>Both frameworks come with good tooling, but use it with different default settings. Spring Boot writes text logs in its own pattern, Quarkus writes a different one via JBoss Logging. Filebeat reads both as unstructured text, and Elasticsearch ends up with a <code>message<\/code> field in which timestamp, level and content are mixed together. Searching for all errors of a service in a specific version is not possible this way.<\/p>\n<p>The situation with metrics is similar. Both frameworks measure via Micrometer, the shared metrics library of the Java world, and expose the values in Prometheus text format. Quarkus does this under <code>\/q\/metrics<\/code>, Spring Boot via Actuator under <code>\/actuator\/prometheus<\/code>. Often no one collects the values, because a dedicated Prometheus would be another platform that has to be operated and approved.<\/p>\n<p>The third problem concerns security. By default, the endpoints for metrics and health checks are exposed on the same port as the business API. Anyone who can reach the API can therefore also reach the operational data. For a DevSecOps approach \u2014 that is, security as an integral part of development and operations \u2014 this is a finding.<\/p>\n<p>Finally, insurance logs contain personal data. Policy numbers, names, or, in the case of health and life insurance, health-related information slip into the logs via debug output. Once this data is in Elasticsearch, the same protection requirements apply to the index as to the policy administration system.<\/p>\n<h2>Bringing Quarkus and Spring Boot to the same conventions<\/h2>\n<p>The target picture is simple: both frameworks write logs in the same schema, expose metrics on a separate port and carry the version in every record. Elasticsearch stores logs and metrics, Grafana shows both side by side and checks the release. The configurations are written for Spring Boot 3.5, Quarkus 3.20, Elasticsearch, Filebeat and Metricbeat 8.18, and Grafana 12.1.<\/p>\n<pre><code class=\"language-mermaid\">flowchart LR\n  Q[Quarkus-Service] -- ECS-JSON auf stdout --&gt; F[Filebeat]\n  S[Spring-Boot-Service] -- ECS-JSON auf stdout --&gt; F\n  Q -- Port 9000 \/q\/metrics --&gt; M[Metricbeat]\n  S -- Port 8081 \/actuator\/prometheus --&gt; M\n  F --&gt; E[Elasticsearch]\n  M --&gt; E\n  E --&gt; G[Grafana]\n  G --&gt; R[Release-Gate im Rollout]<\/code><\/pre>\n<h2>Step 1: Configure Quarkus and Spring Boot the same way<\/h2>\n<p>In Quarkus, the management interface separates the operational endpoints from business traffic. This is a second HTTP server that exposes health, metrics and info endpoints on port 9000 (<a href=\"https:\/\/quarkus.io\/guides\/management-interface-reference\">Quarkus Management Interface<\/a>). The extensions <code>quarkus-micrometer-registry-prometheus<\/code>, <code>quarkus-smallrye-health<\/code> and <code>quarkus-logging-json<\/code> are required. Quarkus provides the ECS format directly (<a href=\"https:\/\/quarkus.io\/guides\/logging\">Quarkus Logging<\/a>).<\/p>\n<pre><code class=\"language-properties\"># application.properties (Quarkus)\nquarkus.application.name=schadenmeldung\nquarkus.management.enabled=true\nquarkus.log.console.json.enabled=true\nquarkus.log.console.json.log-format=ecs\nquarkus.log.console.json.mdc.flat-fields=true\n%dev.quarkus.log.console.json.enabled=false\n%test.quarkus.log.console.json.enabled=false<\/code><\/pre>\n<p>In Spring Boot, <code>management.server.port<\/code> handles the separation; the prerequisites are <code>spring-boot-starter-actuator<\/code> and <code>micrometer-registry-prometheus<\/code>. Structured logging in ECS format has been available since Spring Boot 3.4 (<a href=\"https:\/\/docs.spring.io\/spring-boot\/reference\/features\/logging.html\">Spring Boot Logging<\/a>). We set the version explicitly from the Maven build so that it does not depend on the manifest.<\/p>\n<pre><code class=\"language-yaml\"># application.yaml (Spring Boot)\nspring:\n  application:\n    name: vertragsverwaltung\nmanagement:\n  server:\n    port: 8081\n  endpoints:\n    web:\n      exposure:\n        include: health,prometheus\nlogging:\n  structured:\n    format:\n      console: ecs\n    ecs:\n      service:\n        version: &quot;@project.version@&quot;\n        environment: production<\/code><\/pre>\n<p>ECS stands for Elastic Common Schema, a fixed field schema in which <code>log.level<\/code>, <code>service.name<\/code> and <code>service.version<\/code> have the same names in both frameworks. The network policy in the cluster only allows ports 9000 and 8081 for Metricbeat.<\/p>\n<h2>Step 2: Business metrics with Micrometer<\/h2>\n<p>HTTP status codes show technical errors, but not business ones. A claims submission that fails the plausibility check still returns status 200. The development teams count such events themselves. The code is identical in both frameworks; only the bean annotation differs: <code>@ApplicationScoped<\/code> in Quarkus, <code>@Component<\/code> in Spring Boot.<\/p>\n<pre><code class=\"language-java\">import io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.MeterRegistry;\nimport jakarta.enterprise.context.ApplicationScoped;\n\n@ApplicationScoped\npublic class SchadenmeldungMetriken {\n\n    private final Counter abgelehnt;\n\n    public SchadenmeldungMetriken(MeterRegistry registry) {\n        this.abgelehnt = Counter.builder(&quot;schadenmeldung.abgelehnt&quot;)\n                .description(&quot;Schadenmeldungen, die die Plausibilit\u00e4tspr\u00fcfung nicht bestehen&quot;)\n                .tag(&quot;sparte&quot;, &quot;kfz&quot;)\n                .register(registry);\n    }\n\n    public void abgelehnt() {\n        abgelehnt.increment();\n    }\n}<\/code><\/pre>\n<p>In Prometheus format the counter is named <code>schadenmeldung_abgelehnt_total<\/code>. Tags such as <code>sparte<\/code> should stay limited to a few fixed values. Using a policy number as a tag would blow up the number of time series and carry personal data into the metric store.<\/p>\n<h2>Step 3: Logs to Elasticsearch with Filebeat<\/h2>\n<p>Filebeat reads the container logs, parses the JSON lines and enriches them with Kubernetes metadata (<a href=\"https:\/\/www.elastic.co\/docs\/reference\/beats\/filebeat\/filebeat-input-filestream\">Filestream Input<\/a>).<\/p>\n<pre><code class=\"language-yaml\"># filebeat.yml\nfilebeat.inputs:\n  - type: filestream\n    id: java-services\n    paths:\n      - \/var\/log\/containers\/*.log\n    parsers:\n      - container:\n          stream: all\n          format: auto\n      - ndjson:\n          target: &quot;&quot;\n          overwrite_keys: true\n          expand_keys: true\n          add_error_key: true\n\nprocessors:\n  - add_kubernetes_metadata:\n      host: ${NODE_NAME}\n      matchers:\n        - logs_path:\n            logs_path: &quot;\/var\/log\/containers\/&quot;\n  - drop_fields:\n      fields: [&quot;&lt;FELD_MIT_PERSONENBEZUG&gt;&quot;]\n      ignore_missing: true\n\noutput.elasticsearch:\n  hosts: [&quot;https:\/\/&lt;ELASTICSEARCH_HOST&gt;:9200&quot;]\n  api_key: &quot;${ES_API_KEY}&quot;\n  ssl.certificate_authorities: [&quot;\/etc\/filebeat\/certs\/ca.crt&quot;]<\/code><\/pre>\n<p>The <code>drop_fields<\/code> processor is the last line of defense, not the actual measure. Personal data should not end up in the log in the first place. The MDC therefore holds only a technical process ID. The MDC (Mapped Diagnostic Context) is the logger&#8217;s context store, whose fields are carried by every log line of a request.<\/p>\n<h2>Step 4: Metrics to Elasticsearch with Metricbeat<\/h2>\n<p>Instead of a dedicated Prometheus, Metricbeat scrapes the endpoints. The Prometheus module reads the text format, and autodiscover recognizes the pods via a <code>framework<\/code> label (<a href=\"https:\/\/www.elastic.co\/docs\/reference\/beats\/metricbeat\/metricbeat-metricset-prometheus-collector\">Prometheus Collector<\/a>). With <code>use_types<\/code> and <code>rate_counters<\/code>, Metricbeat calculates for each counter the increase since the last scrape \u2014 with an interval of 60 seconds, that is the increase per minute.<\/p>\n<pre><code class=\"language-yaml\"># metricbeat.yml\nmetricbeat.autodiscover:\n  providers:\n    - type: kubernetes\n      node: ${NODE_NAME}\n      templates:\n        - condition:\n            equals:\n              kubernetes.labels.framework: &quot;quarkus&quot;\n          config:\n            - module: prometheus\n              metricsets: [&quot;collector&quot;]\n              period: 60s\n              hosts: [&quot;${data.host}:9000&quot;]\n              metrics_path: \/q\/metrics\n              use_types: true\n              rate_counters: true\n        - condition:\n            equals:\n              kubernetes.labels.framework: &quot;spring-boot&quot;\n          config:\n            - module: prometheus\n              metricsets: [&quot;collector&quot;]\n              period: 60s\n              hosts: [&quot;${data.host}:8081&quot;]\n              metrics_path: \/actuator\/prometheus\n              use_types: true\n              rate_counters: true\n\noutput.elasticsearch:\n  hosts: [&quot;https:\/\/&lt;ELASTICSEARCH_HOST&gt;:9200&quot;]\n  api_key: &quot;${ES_API_KEY}&quot;\n  ssl.certificate_authorities: [&quot;\/etc\/metricbeat\/certs\/ca.crt&quot;]<\/code><\/pre>\n<p>In Elasticsearch, the HTTP counter is then in the field <code>prometheus.http_server_requests_seconds_count.rate<\/code>, and the status code in <code>prometheus.labels.status<\/code>. The version comes from the pod label and ends up in <code>kubernetes.labels.app_kubernetes_io\/version<\/code>.<\/p>\n<h2>Step 5: The release gate in Grafana<\/h2>\n<p>Grafana reads with its own API key that only has read rights on the two data streams. You create the key in the Kibana Dev Tools with <code>POST \/_security\/api_key<\/code> and this content.<\/p>\n<pre><code class=\"language-json\">{\n  &quot;name&quot;: &quot;grafana-lesen&quot;,\n  &quot;role_descriptors&quot;: {\n    &quot;grafana_lesen&quot;: {\n      &quot;indices&quot;: [\n        {\n          &quot;names&quot;: [&quot;filebeat-*&quot;, &quot;metricbeat-*&quot;],\n          &quot;privileges&quot;: [&quot;read&quot;, &quot;view_index_metadata&quot;]\n        }\n      ]\n    }\n  }\n}<\/code><\/pre>\n<p>The data sources are set up via provisioning so that they look the same in every environment and the key does not end up entered by hand in the browser.<\/p>\n<pre><code class=\"language-yaml\"># grafana\/provisioning\/datasources\/elasticsearch.yaml\napiVersion: 1\ndatasources:\n  - name: Elasticsearch Logs\n    type: elasticsearch\n    access: proxy\n    url: https:\/\/&lt;ELASTICSEARCH_HOST&gt;:9200\n    jsonData:\n      index: &quot;filebeat-*&quot;\n      timeField: &quot;@timestamp&quot;\n      logMessageField: message\n      logLevelField: log.level\n      httpHeaderName1: Authorization\n    secureJsonData:\n      httpHeaderValue1: &quot;ApiKey &lt;API_KEY&gt;&quot;\n  - name: Elasticsearch Metriken\n    type: elasticsearch\n    access: proxy\n    url: https:\/\/&lt;ELASTICSEARCH_HOST&gt;:9200\n    jsonData:\n      index: &quot;metricbeat-*&quot;\n      timeField: &quot;@timestamp&quot;\n      httpHeaderName1: Authorization\n    secureJsonData:\n      httpHeaderValue1: &quot;ApiKey &lt;API_KEY&gt;&quot;<\/code><\/pre>\n<p>The alert rule for the gate consists of two queries and a calculation. The <code>Fehler<\/code> query sums the server errors per version and minute, the <code>Anfragen<\/code> query does the same without a status filter, and a Math expression <code>$Fehler \/ $Anfragen<\/code> yields the error rate. This is what the first query looks like in Grafana&#8217;s query model.<\/p>\n<pre><code class=\"language-json\">{\n  &quot;refId&quot;: &quot;Fehler&quot;,\n  &quot;query&quot;: &quot;kubernetes.container.name:\\&quot;&lt;CONTAINER&gt;\\&quot; AND prometheus.labels.status:5*&quot;,\n  &quot;metrics&quot;: [\n    { &quot;id&quot;: &quot;1&quot;, &quot;type&quot;: &quot;sum&quot;, &quot;field&quot;: &quot;prometheus.http_server_requests_seconds_count.rate&quot; }\n  ],\n  &quot;bucketAggs&quot;: [\n    { &quot;id&quot;: &quot;2&quot;, &quot;type&quot;: &quot;terms&quot;, &quot;field&quot;: &quot;kubernetes.labels.app_kubernetes_io\/version&quot;, &quot;settings&quot;: { &quot;size&quot;: &quot;5&quot;, &quot;order&quot;: &quot;desc&quot;, &quot;orderBy&quot;: &quot;_count&quot;, &quot;min_doc_count&quot;: &quot;1&quot; } },\n    { &quot;id&quot;: &quot;3&quot;, &quot;type&quot;: &quot;date_histogram&quot;, &quot;field&quot;: &quot;@timestamp&quot;, &quot;settings&quot;: { &quot;interval&quot;: &quot;1m&quot; } }\n  ],\n  &quot;timeField&quot;: &quot;@timestamp&quot;\n}<\/code><\/pre>\n<p>Alongside the rate, the dashboard has a log panel with the query <code>log.level:ERROR AND service.name:&quot;&lt;SERVICE&gt;&quot;<\/code>, grouped by <code>service.version<\/code>. This gives the CAB a criterion it can check: the release counts as approved if the new version stays below the threshold throughout the agreed observation window.<\/p>\n<h2>What changes in operations<\/h2>\n<p>We deliberately avoid quoting before-and-after figures here, because they depend heavily on the landscape and release cadence. A faulty release becomes apparent while only part of the pods carries the new version, and not only the next morning. Troubleshooting starts from a version and a log line with a fixed schema instead of from a full-text search.<\/p>\n<p>For operations, there is no longer any difference between Quarkus and Spring Boot. Both deliver the same fields and the same metrics, and both separate their operational port from business traffic. For DORA documentation, the detection capability can be demonstrated through concrete rules and dashboards.<\/p>\n<p>For the development teams, the effort per service goes down. The conventions live in a shared template, for example as a parent POM for Spring Boot or as a company-owned Quarkus extension. A new service thus comes with an operational port, ECS logs and version information from the start.<\/p>\n<h2>Where the approach does not hold<\/h2>\n<p>Elasticsearch is not a specialized metric store. Anyone who needs latency quantiles from histograms or complex queries across many time series will get further with PromQL in Prometheus or Grafana Mimir. Metrics in Elasticsearch also take up more storage per measured value, which is why we keep the scrape interval at 60 seconds and keep the number of tags small. In the starting point described here, it weighs more heavily that no second platform has to be approved.<\/p>\n<p>The gate requires that the version is maintained consistently in two places: in the pod label <code>app.kubernetes.io\/version<\/code> and in the build from which <code>service.version<\/code> originates. If the label says <code>latest<\/code>, the query separates nothing. The deployment pipeline should therefore set both values from the same build number.<\/p>\n<p>The gate also needs traffic. A service that only receives a few requests at night does not deliver a reliable rate. A single error among three requests produces a high error rate, even though nothing is broken. For such services, synthetic requests during the rollout or a gate on absolute error counts help.<\/p>\n<p>The setup only detects business errors to the extent that the teams count them with Micrometer. Anyone who wants to trace flows across multiple services adds distributed tracing with OpenTelemetry; Elastic&#8217;s APM Server accepts OpenTelemetry data directly.<\/p>\n<h2>Conclusion: shared conventions make releases verifiable<\/h2>\n<p>Quarkus and Spring Boot differ less in operations than the default settings suggest. With ECS logs, separate management ports and version information in every record, Elasticsearch and Grafana become the shared foundation for release approval.<\/p>\n<p>Lets Scale IT AG sets up such stacks together with development and operations teams. In doing so, we have also supported projects at insurers in Hannover.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Observe Quarkus and Spring Boot consistently: logs and metrics in Elasticsearch, per-version release approval in Grafana, using an insurer as an example.<\/p>\n","protected":false},"author":6,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[27],"tags":[],"class_list":["post-28164","post","type-post","status-publish","format-standard","hentry","category-it-monitoring-en"],"_links":{"self":[{"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/posts\/28164","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/users\/6"}],"replies":[{"embeddable":true,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/comments?post=28164"}],"version-history":[{"count":1,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/posts\/28164\/revisions"}],"predecessor-version":[{"id":28165,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/posts\/28164\/revisions\/28165"}],"wp:attachment":[{"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/media?parent=28164"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/categories?post=28164"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/tags?post=28164"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}