<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: David</title>
    <description>The latest articles on DEV Community by David (@davidgrath).</description>
    <link>https://dev.to/davidgrath</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3765015%2F590d1ba8-3115-4eff-b1d5-af86e75e27d6.png</url>
      <title>DEV Community: David</title>
      <link>https://dev.to/davidgrath</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/davidgrath"/>
    <language>en</language>
    <item>
      <title>Expense Tracker: Learning Performance Testing and Continuous Profiling</title>
      <dc:creator>David</dc:creator>
      <pubDate>Thu, 09 Apr 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/davidgrath/expense-tracker-learning-performance-testing-and-continuous-profiling-3lm</link>
      <guid>https://dev.to/davidgrath/expense-tracker-learning-performance-testing-and-continuous-profiling-3lm</guid>
      <description>&lt;p&gt;(Originally posted on &lt;a href="https://medium.com/@DavidGrath0/expense-tracker-learning-performance-testing-and-continuous-profiling-9f39578940a3" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;, Photo by Matheus Bertelli: &lt;a href="https://www.pexels.com/photo/close-up-of-cooling-system-inside-gaming-pc-34552800/" rel="noopener noreferrer"&gt;https://www.pexels.com/photo/close-up-of-cooling-system-inside-gaming-pc-34552800/&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;In this article, I learned how to precisely identify CPU bottlenecks in my code using everything I’ve learned so far, including a new tool: Pyroscope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;As a brief review, Expense Tracker is my personal solution to recording my expenses, where I took the opportunity to learn about unit testing, continuous integration, and observability. It exists as a &lt;a href="https://github.com/DavidGrath/Expense-Tracker" rel="noopener noreferrer"&gt;standalone&lt;/a&gt; Android app, but this series is about the &lt;a href="https://github.com/DavidGrath/Expense-Tracker-Backend/" rel="noopener noreferrer"&gt;client-server&lt;/a&gt; version with Spring Boot. After I was done with the basic features of the app, I decided to find out how it would perform in the real world, and so I came across performance testing. The two types of performance tests I’m concerned with are Load and Stress tests. There are other types like soak and chaos tests, but I chose to focus on the basics. Load tests are to anticipate the resources needed for standard load conditions, while stress tests are meant to find out how much pain a system has to endure before it caves in entirely, and if it does so gracefully.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://www.browserstack.com/guide/load-testing-vs-stress-testing?source=post_page-----9f39578940a3---------------------------------------" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fbrowserstack.wpenginepowered.com%2Fwp-content%2Fuploads%2F2025%2F01%2FLoad-Testing-vs-Stress-Testing_-The-Main-Differences.png" height="320" class="m-0" width="506"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://www.browserstack.com/guide/load-testing-vs-stress-testing?source=post_page-----9f39578940a3---------------------------------------" rel="noopener noreferrer" class="c-link"&gt;
            Load Testing vs Stress Testing: The Main Differences | BrowserStack
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Understand the key differences between load testing and stress testing and why both are crucial for optimizing your application's performance.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fbrowserstack.wpenginepowered.com%2Fwp-content%2Fthemes%2Fbrowserstack%2Fimg%2Ffavicons%2Ffavicon.ico" width="48" height="48"&gt;
          browserstack.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Bottleneck&lt;/strong&gt;: Identified a critical N+1 query issue causing 80s+ latencies and 4,000+ spans in a single trace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Fix&lt;/strong&gt;: Reduced latency by 97% using database &lt;code&gt;JOIN&lt;/code&gt;s and in-memory Hash Map caching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thread Pool Tuning&lt;/strong&gt;: Optimized JWT generation by aligning Tomcat thread pools with v-core counts to minimize context-switching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Reality Check&lt;/strong&gt;: Concluded that BCrypt slowness is a non-negotiable security feature, choosing to rate-limit the app rather than compromise hashing strength.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Final Verdict&lt;/strong&gt;: Stabilized the system at 10 RPS with a 100-thread cap for a 2 v-core staging environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Preparation
&lt;/h2&gt;

&lt;p&gt;My initial objective was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What are the limits of my system?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To find that out, I made sure to observe the Grafana dashboards I built when I was learning about &lt;a href="https://dev.to/davidgrath/learning-fullstack-observability-metrics-3n44"&gt;metrics&lt;/a&gt;, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JVM metrics:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F29u122fb5y0t4n2hd43z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F29u122fb5y0t4n2hd43z.png" alt="JVM Server metrics dashboard" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MySQL Metrics:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F529b3rboruxutkcyefv9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F529b3rboruxutkcyefv9.png" alt="MySQL basic metrics" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request, Error, Duration (RED) dashboard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F422v4t10dr6ent0tdq16.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F422v4t10dr6ent0tdq16.png" alt="RED Dashboard" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I also followed this article as a guideline and decided to come up with a basic test script based on a typical user flow:&lt;/p&gt;

&lt;h3&gt;
  
  
  Test Script
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Log in

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;POST /auth/login&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Enter the draft screen

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;POST /transactions/draft&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /transactions/draft&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /categories&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Change the account from the default

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GET /accounts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PUT /transactions/draft&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Attach the relevant documents

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;POST /transactions/draft/documents&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Modify the items

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;PUT /transactions/draft/items/{uuid}&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Save the draft and view the main page

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;POST /transactions&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /transactions&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;View statistics according to certain criteria

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /statistics/...&lt;/code&gt; (5 different endpoints)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;View the filtered transactions themselves

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;POST /statistics/transactions&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;My tool of choice is &lt;a href="https://jmeter.apache.org/usermanual/get-started.html" rel="noopener noreferrer"&gt;Apache JMeter&lt;/a&gt;, and for all my tests, I used the &lt;a href="https://jmeter.apache.org/usermanual/component_reference.html#Constant_Throughput_Timer" rel="noopener noreferrer"&gt;Constant Throughput Timer&lt;/a&gt;, which essentially dictates my Requests per Second, or RPS, so long as the server can keep up. I was iteratively building my JMeter script as I slowly discovered my system limits each run. Some key points include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;My MySQL never showed any real signs of being anywhere near breaking — the memory, row locks, and slow queries were consistently okay&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw6kzjk2ftwiaoe79ly3o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw6kzjk2ftwiaoe79ly3o.png" alt="MySQL is okay" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I never encountered any memory issues for my JVM. The graphs all pretty much looked like this:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr5rsinlqo9qchp89qi8b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr5rsinlqo9qchp89qi8b.png" alt="JVM Memory is okay" width="800" height="241"&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Earlier on in my tests, I also noticed that my CPU kept peaking at certain points, but I didn’t think much of it. I later learned why that was important.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;VM CPU:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsmoysokhfu66mkqo3ca9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsmoysokhfu66mkqo3ca9.png" alt="VM CPU" width="309" height="246"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Host CPU:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqbu6icfwunpmv9o7t83q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqbu6icfwunpmv9o7t83q.png" alt="Host CPU" width="420" height="206"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I noticed various things and changed a number of other things over time, but I’ll leave them out from here for brevity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Developing Acceptance Criteria
&lt;/h2&gt;

&lt;p&gt;During my tests and research, and after being comfortable with gauges and counters, I discovered the usefulness of the third metric type in Prometheus: &lt;a href="https://prometheus.io/docs/practices/histograms/" rel="noopener noreferrer"&gt;histograms&lt;/a&gt;. They help to answer the question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What latency did 90% of my users experience?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgoveowavuzhi1cco4kid.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgoveowavuzhi1cco4kid.png" alt="Histogram Quantile sample" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The graph above is capped at 10 here because those are the settings prescribed by the &lt;a href="https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpserverrequestduration" rel="noopener noreferrer"&gt;OpenTelemetry semantic conventions&lt;/a&gt;. Histograms work with buckets, and so precise detail can be hidden — it’s possible that a majority of requests take significantly longer than 10 seconds, or alternatively, it’s likely that a large portion of request durations are towards the tail end of a bucket boundary. Errors of estimation are actually expected because of this, and one can choose their own bucket boundaries to fit their use case. I stuck with the defaults because 10 seconds sounds long enough to me for a bad user experience.&lt;/p&gt;

&lt;p&gt;I initially focused on averages, but I later realised why histograms are worth considering. Histogram quantiles are more helpful in my case because of the possibility of outliers skewing the results.&lt;/p&gt;

&lt;p&gt;With this new information, I decided to rework my dashboards and make use of Grafana’s thresholds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Response time p95 latency:

&lt;ul&gt;
&lt;li&gt;0 to 3 seconds: OKAY/green;&lt;/li&gt;
&lt;li&gt;3 to 7 seconds: FAIR/yellow;&lt;/li&gt;
&lt;li&gt;7 to 10 seconds: BAD/red&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;Memory and CPU: 90%&lt;/li&gt;

&lt;li&gt;Storage: 70%&lt;/li&gt;

&lt;li&gt;Server error rate (HTTP 4xx): 10%&lt;/li&gt;

&lt;li&gt;Client error rate (HTTP 5xx): 30%&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;These numbers come from what I’ve decided to be an acceptable user experience. With the thresholds established, my JVM dashboard looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv61hg93bnr3wb1lxq635.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv61hg93bnr3wb1lxq635.png" alt="JVM dashboard with thresholds" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And my RED dashboard looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffjkryhukump6t4erb4ul.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffjkryhukump6t4erb4ul.png" alt="RED dashboard with thresholds" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At that point, my objective changed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Optimize the bottlenecks&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There were three offenders:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Upload Document&lt;/li&gt;
&lt;li&gt;Get transactions&lt;/li&gt;
&lt;li&gt;Login&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The document endpoint was mostly fixed by changing my hardware from an HDD to an SSD, but the others were a bit more involved&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimizations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  GET /transactions
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Problem
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;I was making unnecessary round-trip queries to the database, severely increasing the latency&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Solutions
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Use a JOIN query to fetch all the needed data at once&lt;/li&gt;
&lt;li&gt;For nested entities, also use hash maps to cache them and make “in-memory” joins.&lt;/li&gt;
&lt;li&gt;Improvement: my worst-case trace went from 1 minute 21 seconds down to 2.2 seconds (97% improvement)&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Details
&lt;/h4&gt;

&lt;p&gt;My test script essentially acted like a mass insertion script, so I wanted to see how the app would handle the roughly 777 transactions I had inserted by that point, but after logging in to the test user account and opening the screen, it crashed. After looking at Tempo, I found out why:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjobwi7jty3g3moxoy03i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjobwi7jty3g3moxoy03i.png" alt="Grafana Tempo investigation" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My endpoint took 1 minute and 20 seconds, but the app crashed due to a 10-second timeout, which is OkHttp’s default. It took so much time because I made an excessive number of round-trips to the database to fetch nested entities. That’s why this trace has almost 4,000 spans. On Markus Winand’s &lt;a href="https://use-the-index-luke.com/" rel="noopener noreferrer"&gt;website&lt;/a&gt; for database indexing, this problem is called the &lt;a href="https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping" rel="noopener noreferrer"&gt;N+1 query problem&lt;/a&gt;, where 1 is the initial query to fetch the data and &lt;em&gt;N&lt;/em&gt; is the number of rows fetched. The solution is to use a &lt;a href="https://use-the-index-luke.com/sql/join/nested-loops-join-n1-problem" rel="noopener noreferrer"&gt;join query&lt;/a&gt;. Once I made the &lt;a href="https://github.com/DavidGrath/Expense-Tracker-Backend/commit/69d5cad8b28b229bad383b29b93ac4b4c1fca5c7" rel="noopener noreferrer"&gt;change&lt;/a&gt;, the time drastically improved — the same endpoint went down in duration by 97% to 2.2 seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  POST /auth/login
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Problems
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;JWT generation was stressing the CPU&lt;/li&gt;
&lt;li&gt;Password hashing was also stressing the CPU&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Solutions
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Limit JWT generation to a CPU-sized thread pool&lt;/li&gt;
&lt;li&gt;No more software optimizations to be made. Get better hardware&lt;/li&gt;
&lt;li&gt;Token generation was improved by 64% from 1 minute 8 seconds to 24 seconds in my worst case, but password hashing still took the bulk of the time&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Details
&lt;/h4&gt;

&lt;p&gt;When I checked my traces — which are usually more accurate than histograms due to their different use cases — I saw that the time would get as bad as 37 seconds, so I had a good amount of trouble with this one. I thought that maybe private key and JWT generation were computationally expensive and just took that much time, and I initially didn’t know how to verify that. After adding timer logs and searching with Loki, I saw something interesting:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fahd3xx3z1796cwi7cox1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fahd3xx3z1796cwi7cox1.png" alt="Grafana Loki Investigation" width="539" height="724"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There’s a point where it suddenly goes from taking over 13 seconds to less than 1 second to generate that JWT. Quite confusing. I didn’t know how to diagnose the problem when the output was so divided.&lt;/p&gt;

&lt;p&gt;I eventually discovered profiling as a potential tool to help, and I chose &lt;a href="https://github.com/grafana/pyroscope" rel="noopener noreferrer"&gt;Grafana Pyroscope&lt;/a&gt; as my tool. I initially tried &lt;a href="https://visualvm.github.io/" rel="noopener noreferrer"&gt;VisualVM&lt;/a&gt;, but setting each one up required roughly the same amount of effort, so Pyroscope won out since it’s part of the Grafana ecosystem I’m already invested in.&lt;/p&gt;

&lt;p&gt;With Pyroscope set up, I searched for my JWT method, &lt;code&gt;createUserToken&lt;/code&gt;, and found that it had taken over 1 minute of CPU time out of the 23 minutes that the newly profiled test ran for. After looking into it and asking Gemini, I discovered that &lt;strong&gt;context-switching&lt;/strong&gt; was my main issue: CPU-bound tasks shouldn’t be performed across a lot of threads, as the overhead is high. They should only have as many threads as there are cores. Spring’s default thread count for Tomcat is &lt;a href="https://docs.spring.io/spring-boot/appendix/application-properties/index.html#application-properties.server.server.tomcat.threads.max" rel="noopener noreferrer"&gt;200&lt;/a&gt;. This pool size isn’t a problem for I/O threads, which in this case is for server requests, but by letting each worker thread call the JWT function, I was slowing everything down.&lt;/p&gt;

&lt;p&gt;I further confirmed this suspicion by returning to my CPU graphs&lt;/p&gt;

&lt;p&gt;VM:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4sizqbcqvl8yzc8jyw6t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4sizqbcqvl8yzc8jyw6t.png" alt="For the VM" width="800" height="273"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;JVM:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5o3xg3v6cxlzwm6l5dhu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5o3xg3v6cxlzwm6l5dhu.png" alt="For the JVM" width="800" height="274"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Host (the VM had 4 out of 8 cores):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9bcz3tyct91qo9wxhhnm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9bcz3tyct91qo9wxhhnm.png" alt="Host Machine" width="800" height="268"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once I looked at the timings and compared them with my RPS for the login endpoint, I saw that they were closely aligned, which proved to me that the endpoint’s volume accounted for the increased CPU load.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi6b93r9ytkm0letpn6ku.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi6b93r9ytkm0letpn6ku.png" alt="RPS for login" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The improvement was made by delegating the task to a thread pool sized with &lt;code&gt;Runtime.availableProcessors()&lt;/code&gt; (&lt;a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Runtime.html#availableProcessors()" rel="noopener noreferrer"&gt;link&lt;/a&gt;) and verified by testing with JUnit. After deploying my &lt;a href="https://github.com/DavidGrath/Expense-Tracker-Backend/commit/da4ebb8b78f129c8831784bf03a977ed9971ca3b" rel="noopener noreferrer"&gt;change&lt;/a&gt;, I saw that the optimization worked. Here are the before and after screenshots:&lt;br&gt;
Before:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6n4ci2g1ra0t1yuuovdz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6n4ci2g1ra0t1yuuovdz.png" alt="First profiled run" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdv23bw2uctyhgdj4gldi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdv23bw2uctyhgdj4gldi.png" alt="Second profiled run" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An improvement from 1 minute 8 seconds to 24 seconds. However, the endpoint still had a bad p95, so after more digging and sorting the table by &lt;a href="https://grafana.com/docs/pyroscope/latest/view-and-analyze-profile-data/self-vs-total/" rel="noopener noreferrer"&gt;Self&lt;/a&gt; instead of Total, and that’s where I found the worst offender:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjlbuk41b2rhn7ruituw6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjlbuk41b2rhn7ruituw6.png" alt="First run sorted by “Self”" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw98bvwiu6qzqeeetxiw0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw98bvwiu6qzqeeetxiw0.png" alt="Second run sorted by “Self”" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Password hashing with BCrypt&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What I tried:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creating a custom subclass of the Password Encoder in order to delegate it to a thread pool to achieve the same speed-up effect as JWT generation. After testing it with 5 consecutive JUnit runs, my best time &lt;a href="https://github.com/DavidGrath/Expense-Tracker-Backend/commit/4b30f5e2d564e55bb0b4195885e43ce5107637ad" rel="noopener noreferrer"&gt;was roughly 26 seconds&lt;/a&gt; whereas the target was 10.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/DavidGrath/Expense-Tracker-Backend/commit/5424b00343d18349ea132120fdfd6a9c9f003cd8" rel="noopener noreferrer"&gt;Rate limiting&lt;/a&gt; with the endpoint with &lt;a href="https://github.com/bucket4j/bucket4j" rel="noopener noreferrer"&gt;Bucket4J&lt;/a&gt;. I started trading latency for throughput and &lt;a href="https://github.com/DavidGrath/Expense-Tracker-Backend/commit/233546d63d35bea5328a8994a27f75d8ea35b426" rel="noopener noreferrer"&gt;couldn’t really find&lt;/a&gt; a good middle ground&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After searching around for other ways to optimize my code and not finding anything, I decided to stop for 3 main reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The slowness of password hashing is a security feature. I shouldn’t work against that&lt;/li&gt;
&lt;li&gt;My hardware has reached its limit&lt;/li&gt;
&lt;li&gt;A massive simultaneous login isn’t a regular user pattern&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Verdict
&lt;/h2&gt;

&lt;p&gt;I had tried various other conditions, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Increasing the RPS of the load test&lt;/li&gt;
&lt;li&gt;Increasing the Tomcat thread count&lt;/li&gt;
&lt;li&gt;Varying the user count in my JMeter script&lt;/li&gt;
&lt;li&gt;Using my staging VM to determine my final resources&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The VM I chose has 2 v-cores and 2 gigabytes of RAM, and factoring that in, my final decisions include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rate limit the app to 10 requests per second&lt;/li&gt;
&lt;li&gt;The Tomcat thread count should be capped at 100&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The project was a fun learning experience overall, and now I have a good idea of what it’s like to architect an app from end to end with performance, automation, and code stability in mind. It was especially nice learning the hard limits and what I could and could not improve.&lt;/p&gt;

&lt;p&gt;Thank you for your time&lt;/p&gt;

</description>
      <category>cpu</category>
      <category>performance</category>
      <category>monitoring</category>
      <category>springboot</category>
    </item>
    <item>
      <title>Learning Full-stack Observability: Tracing</title>
      <dc:creator>David</dc:creator>
      <pubDate>Wed, 08 Apr 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/davidgrath/learning-full-stack-observability-tracing-4oin</link>
      <guid>https://dev.to/davidgrath/learning-full-stack-observability-tracing-4oin</guid>
      <description>&lt;p&gt;(Originally published on &lt;a href="https://medium.com/@DavidGrath0/learning-fullstack-observability-tracing-37a13fb5977f" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;, Image by &lt;a href="https://pixabay.com/users/geralt-9301/?utm_source=link-attribution&amp;amp;utm_medium=referral&amp;amp;utm_campaign=image&amp;amp;utm_content=6552137" rel="noopener noreferrer"&gt;Gerd Altmann&lt;/a&gt; from &lt;a href="https://pixabay.com//?utm_source=link-attribution&amp;amp;utm_medium=referral&amp;amp;utm_campaign=image&amp;amp;utm_content=6552137" rel="noopener noreferrer"&gt;Pixabay&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Distributed Tracing serves the purpose of connecting network calls across various services within an organization. In my case, that means I can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;From Android: see the time taken for the request to get sent from the device&lt;/li&gt;
&lt;li&gt;From Spring Boot: see the potential error logs a certain user may have&lt;/li&gt;
&lt;li&gt;From the database: see how much time is spent to determine the efficiency of my queries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Traces are made of spans, and spans have attributes, and a span may have a parent span. A span without a parent is known as the root span. More about that in the &lt;a href="https://opentelemetry.io/docs/concepts/signals/traces/" rel="noopener noreferrer"&gt;OpenTelemetry documentation&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpcl3fgad29p1dukrao3z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpcl3fgad29p1dukrao3z.png" alt="Sample trace" width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In this example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The root span is named &lt;code&gt;GET&lt;/code&gt;, starting at the Android app.&lt;/li&gt;
&lt;li&gt;It’s composed of 5 spans, 3 of which are database queries, and the longest-running one is the root.&lt;/li&gt;
&lt;li&gt;The next span after the app starts at the backend&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The trace took 1.91 seconds in total.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;As mentioned before, my choice of tools for OpenTelemetry will be the ones from the &lt;a href="https://grafana.com/docs/opentelemetry/" rel="noopener noreferrer"&gt;Grafana&lt;/a&gt; stack, and so I’ll be using &lt;a href="https://grafana.com/docs/tempo/latest/" rel="noopener noreferrer"&gt;Tempo&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trace Propagation
&lt;/h2&gt;

&lt;p&gt;Briefly, I’ll go over how traces work. The &lt;a href="https://www.w3.org/TR/trace-context/#traceparent-header" rel="noopener noreferrer"&gt;W3C Trace Context&lt;/a&gt; and &lt;a href="https://opentelemetry.io/docs/concepts/signals/traces/#trace-exporters" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; &lt;a href="https://opentelemetry.io/docs/concepts/context-propagation/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; explain in much better detail.&lt;/p&gt;

&lt;p&gt;Spans are generated within each service before being sent to the span collector. Spans are able to be created with ancestral information using trace headers.&lt;/p&gt;

&lt;p&gt;A trace header is passed from the first component down the service trail. If a service has tracing enabled and doesn’t detect a compatible trace header, it will create one. It comes in multiple formats, but W3C is the main header format.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frwudjh61rv1n90azede0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frwudjh61rv1n90azede0.png" alt="Trace header in Android logcat" width="800" height="124"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Since the Android app is instrumented, the trace starts from there; otherwise, it would have started and ended at the backend as a single-service trace&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftmy20aii1ivb1cm5q310.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftmy20aii1ivb1cm5q310.png" alt="Trace Assembly Illustration" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the resulting trace can be viewed in Tempo&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffk9qub0ogh7755xrcrxr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffk9qub0ogh7755xrcrxr.png" alt="The same trace in Tempo" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Trace Correlation
&lt;/h2&gt;

&lt;p&gt;This allows one to filter logs by HTTP request instead of having to narrow down the potential time of the request. &lt;a href="https://grafana.com/docs/grafana/latest/datasources/tempo/traces-in-grafana/trace-correlations/" rel="noopener noreferrer"&gt;Correlation&lt;/a&gt; connects traces to the other 2* types of OpenTelemetry — logs and metrics — and allows one to jump back and forth between them.&lt;/p&gt;

&lt;p&gt;(* &lt;a href="https://opentelemetry.io/docs/specs/otel/profiles/" rel="noopener noreferrer"&gt;profiles&lt;/a&gt; are an upcoming 4th signal in OpenTelemetry. As of now, &lt;a href="https://grafana.com/docs/pyroscope/v1.18.x/" rel="noopener noreferrer"&gt;Grafana Pyroscope&lt;/a&gt; can be used with traces, but that will be discussed in the performance test)&lt;/p&gt;

&lt;h3&gt;
  
  
  Log Correlation with Loki
&lt;/h3&gt;

&lt;p&gt;The key attributes here are &lt;code&gt;trace_id&lt;/code&gt; and &lt;code&gt;span_id&lt;/code&gt;. These have to be set as structured metadata of the log entry. In Spring, these can be extracted using the request headers, such as with &lt;code&gt;RequestContextHolder&lt;/code&gt;(&lt;a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/context/request/RequestContextHolder.html" rel="noopener noreferrer"&gt;link&lt;/a&gt;). The request attributes then have to be converted into log attributes that can be parsed using a Promtail &lt;a href="https://grafana.com/docs/loki/latest/send-data/promtail/stages/" rel="noopener noreferrer"&gt;pipeline&lt;/a&gt;, as I explained in my logging article. With an auto-instrumented app such as one using the &lt;a href="https://opentelemetry.io/docs/zero-code/java/agent/getting-started/" rel="noopener noreferrer"&gt;Java Agent&lt;/a&gt;, these steps can be skipped as the attributes are extracted automatically.&lt;/p&gt;

&lt;h4&gt;
  
  
  Loki to Tempo
&lt;/h4&gt;

&lt;p&gt;After getting the trace attributes, I configured Loki to use &lt;a href="https://grafana.com/docs/grafana/next/datasources/loki/configure-loki-data-source/#derived-fields" rel="noopener noreferrer"&gt;Derived Fields&lt;/a&gt; to link to Tempo.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8f2e1d9tsdjjxwciyrx5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8f2e1d9tsdjjxwciyrx5.png" alt="Derived Fields settings" width="800" height="185"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And this is an example of what will appear when properly configured&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5iy7esar08caevdb7r4j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5iy7esar08caevdb7r4j.png" alt="Log with Trace links" width="800" height="755"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note the “tempo” buttons:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F32i2javrkafazcpocz8c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F32i2javrkafazcpocz8c.png" alt="Loki to Tempo link buttons" width="800" height="86"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Tempo to Loki
&lt;/h4&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://grafana.com/docs/grafana/latest/datasources/tempo/traces-in-grafana/#link-traces-and-logs" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;grafana.com&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;
&lt;br&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://grafana.com/docs/grafana/latest/datasources/tempo/configure-tempo-data-source/#trace-to-logs" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;grafana.com&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Example link:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs9lnwdkhw341k0wvb3as.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs9lnwdkhw341k0wvb3as.png" alt="Tempo Span to Loki link" width="800" height="107"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When configured properly, this “LOG” button, or a chain link icon, will appear on the traces.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metric Correlation with Prometheus
&lt;/h3&gt;

&lt;p&gt;For Prometheus, it’s slightly more complicated. All labels are indexed, so a high cardinality field like &lt;code&gt;trace_id&lt;/code&gt; is out of the question. There’s no such thing as “metadata labels” as there is with Loki, either. Instead, &lt;a href="https://grafana.com/docs/grafana/latest/fundamentals/exemplars/" rel="noopener noreferrer"&gt;exemplars&lt;/a&gt; are needed; the feature is currently available, but still &lt;a href="https://prometheus.io/docs/prometheus/latest/feature_flags/" rel="noopener noreferrer"&gt;experimental&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F531tl697lof6knen2n3b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F531tl697lof6knen2n3b.png" alt="Exemplar link example" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For some reason, I’ve only gotten it to work with &lt;code&gt;histogram_quantile&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There’s also &lt;a href="https://grafana.com/docs/grafana/latest/datasources/tempo/configure-tempo-data-source/#trace-to-metrics" rel="noopener noreferrer"&gt;Traces to Metrics&lt;/a&gt;, but I won’t talk about it here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom Trace Attributes
&lt;/h2&gt;

&lt;p&gt;My main use case for this is to allow traces to filtered by a user's device, to act as a very basic form of session replay.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx1nn0zld2rerqz7h63ac.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx1nn0zld2rerqz7h63ac.png" alt="Trace with device ID" width="800" height="714"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Instrumentation
&lt;/h3&gt;

&lt;p&gt;Because of the fundamental differences between Android’s JVM and the standard JVM, auto-instrumentation is &lt;a href="https://opentelemetry.io/blog/2025/android-road-to-stable/#auto-instrumentation" rel="noopener noreferrer"&gt;generally&lt;/a&gt; not available, so I’ve gone the route of using manual instrumentation.&lt;/p&gt;

&lt;p&gt;Retrofit is my library of choice for making HTTP calls, and so I chose to use the &lt;a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/v2.16.0/instrumentation/okhttp/okhttp-3.0/library/README.md" rel="noopener noreferrer"&gt;OkHttp instrumentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This is a summarized version of my Dagger Provides method for the Retrofit object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Named&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"authorized"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;retrofitAuthorized&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buildConstants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BuildConstants&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;openTelemetry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Retrofit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;okHttpClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OkHttpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addNetworkInterceptor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;span&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isRecording&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dataProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="nc"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CustomTraceAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UserId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attributeName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;deviceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dataProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deviceId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deviceId&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="nc"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CustomTraceAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DeviceId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attributeName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;deviceId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;proceed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;callFactory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;OkHttpTelemetry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;openTelemetry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;newCallFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;okHttpClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;baseUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buildConstants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exTrackBaseUrl&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;retrofit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Retrofit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;callFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callFactory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;retrofit&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I’m using &lt;code&gt;Span.current()&lt;/code&gt; here because the instrumentation already starts a span through a custom interceptor.&lt;/p&gt;

&lt;p&gt;And the definition for &lt;code&gt;DeviceDataProvider&lt;/code&gt; is simply this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;DeviceDataProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;deviceId&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where I store the device ID in a &lt;code&gt;SharedPreferences&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;And the result is that I can search Tempo with a query like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{resource.service.name="expense-tracker-android"} &amp;amp;&amp;amp; {span.extrack.deviceId = "8ea68a2d-ffba-42a8-970c-02a397210fb0"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo9q43opdmauvkm8a8ncs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo9q43opdmauvkm8a8ncs.png" alt="Traces filtered by device ID" width="800" height="722"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I also added these attributes to my Loki logs because I could afford to have metadata without indexes, but as discussed in my Prometheus article, I didn’t add these to my metrics because of high cardinality&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;With tracing in place, I now have a tool which works as a form of "super-logger" to help me narrow down API calls faster.&lt;br&gt;
Now that I’ve concluded the “OpenTelemetry” portion of my ExTrack series, I’ll be discussing how I was able to do performance tests on my server and how all 3 signals provided great value to me. That will be all for now.&lt;/p&gt;

&lt;p&gt;Thank you for your time.&lt;/p&gt;

</description>
      <category>distributedsystems</category>
      <category>learning</category>
      <category>grafana</category>
    </item>
    <item>
      <title>Learning Full-stack Observability: Logging</title>
      <dc:creator>David</dc:creator>
      <pubDate>Tue, 07 Apr 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/davidgrath/learning-full-stack-observability-logging-25jg</link>
      <guid>https://dev.to/davidgrath/learning-full-stack-observability-logging-25jg</guid>
      <description>&lt;p&gt;(Originally published on &lt;a href="https://medium.com/@DavidGrath0/learning-fullstack-observability-part-2-logging-d5cfc1e86322" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;, Image by &lt;a href="https://pixabay.com/users/boskampi-3788146/?utm_source=link-attribution&amp;amp;utm_medium=referral&amp;amp;utm_campaign=image&amp;amp;utm_content=1836330" rel="noopener noreferrer"&gt;Boskampi&lt;/a&gt; from &lt;a href="https://pixabay.com//?utm_source=link-attribution&amp;amp;utm_medium=referral&amp;amp;utm_campaign=image&amp;amp;utm_content=1836330" rel="noopener noreferrer"&gt;Pixabay&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;When it comes to logging, there are multiple concerns, some of which I'll highlight for this post:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Retention: to prevent server disk space from becoming scarce&lt;/li&gt;
&lt;li&gt;Search: the core use case of logs&lt;/li&gt;
&lt;li&gt;Formatting: aids in readability and search&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Across different tools and platforms such as programming language and Operating System, these aspects vary significantly. One of the main points of &lt;a href="https://opentelemetry.io/docs/concepts/signals/logs/" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; is to have a central place to view all those logs, whether they come from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A file on the local machine, like with my MySQL server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgg14gqs1p59f86vhtsfk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgg14gqs1p59f86vhtsfk.png" alt="MySQL Error logs" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A file on a remote machine, like with my NGINX server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi7bkmb96jw7raat1ee2v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi7bkmb96jw7raat1ee2v.png" alt="NGINX Access logs" width="800" height="279"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A mobile device, like with ExTrack:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj3h1yncdoo0z5w9ep9xh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj3h1yncdoo0z5w9ep9xh.png" alt="Android Studio Logcat" width="800" height="183"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As mentioned in my earlier articles, my tools of choice for OpenTelemetry come from the &lt;a href="https://grafana.com/docs/opentelemetry/" rel="noopener noreferrer"&gt;Grafana&lt;/a&gt; stack, so I’ll be using &lt;a href="//As%20mentioned%20in%20my%20earlier%20articles,%20my%20tools%20of%20choice%20for%20OpenTelemetry%20come%20from%20the%20Grafana%20stack,%20so%20I%E2%80%99ll%20be%20using%20Loki."&gt;Loki&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loki
&lt;/h2&gt;

&lt;p&gt;Loki’s main description is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Like Prometheus, but for logs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So that’s why I made this article to be after the Metrics article.&lt;/p&gt;

&lt;p&gt;Loki mainly relies on labels in order to narrow down logs while searching, similar to Prometheus.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7s5mnhr0rtktgzu87lw9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7s5mnhr0rtktgzu87lw9.png" alt="service_name and level, 2 of the most common labels in my case" width="443" height="469"&gt;&lt;/a&gt;&lt;br&gt;
(&lt;code&gt;service_name&lt;/code&gt; and &lt;code&gt;level&lt;/code&gt;, 2 of the most common labels in my case)&lt;/p&gt;

&lt;p&gt;It also supports &lt;a href="https://grafana.com/docs/loki/latest/get-started/labels/structured-metadata/" rel="noopener noreferrer"&gt;structured metadata&lt;/a&gt;, which is similar to labels, but they’re not indexed, and so they can have high &lt;a href="https://grafana.com/docs/loki/latest/get-started/labels/cardinality/" rel="noopener noreferrer"&gt;cardinality&lt;/a&gt;, such as an IP address or a user ID. The main idea when working with Loki is to filter by a few key indexed labels, and then narrow down with the various search operators.&lt;/p&gt;

&lt;p&gt;The 2 main ways I push to Loki are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scraping the raw log files with &lt;a href="https://grafana.com/docs/loki/latest/send-data/promtail/" rel="noopener noreferrer"&gt;Grafana Promtail&lt;/a&gt;*, which then pushes them to Loki&lt;/li&gt;
&lt;li&gt;Using the &lt;a href="https://opentelemetry.io/docs/zero-code/java/agent/getting-started/" rel="noopener noreferrer"&gt;OpenTelemetry Java Agent&lt;/a&gt; in combination with Grafana &lt;a href="https://grafana.com/docs/alloy/latest/introduction/" rel="noopener noreferrer"&gt;Alloy&lt;/a&gt; using &lt;a href="https://grafana.com/docs/alloy/latest/reference/components/otelcol/otelcol.receiver.otlp/" rel="noopener noreferrer"&gt;OTLP&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(*Promtail is at &lt;a href="https://grafana.com/docs/alloy/latest/set-up/migrate/from-promtail/" rel="noopener noreferrer"&gt;end-of-life&lt;/a&gt;, Alloy is the recommended alternative)&lt;/p&gt;
&lt;h2&gt;
  
  
  Promtail
&lt;/h2&gt;

&lt;p&gt;At a basic level, Promtail uses a &lt;a href="https://grafana.com/docs/loki/latest/send-data/promtail/stages/" rel="noopener noreferrer"&gt;pipeline&lt;/a&gt; to process log lines into the final output to be sent to Loki. Pipeline consists of a wide variety of stages, which include, most notably in my case, the &lt;a href="https://grafana.com/docs/loki/latest/send-data/promtail/stages/json/" rel="noopener noreferrer"&gt;json&lt;/a&gt; stage, since I can use JSON for both the &lt;a href="https://dev.mysql.com/doc/refman/8.0/en/error-log-format.html#error-log-format-output-format-for-log-sink-json" rel="noopener noreferrer"&gt;MySQL error log&lt;/a&gt; and &lt;a href="https://nginx.org/en/docs/http/ngx_http_log_module.html" rel="noopener noreferrer"&gt;NGINX access logs&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  MySQL Promtail Config
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql_error_logs&lt;/span&gt;
  &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;service_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql_error_logs&lt;/span&gt;
      &lt;span class="na"&gt;__path__&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;C:/ProgramData/MySQL/MySQL&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Server&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;8.0/Data/LAPTOP-7H9JJDHB.err.00.json"&lt;/span&gt;
  &lt;span class="na"&gt;pipeline_stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;expressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;time&lt;/span&gt;
        &lt;span class="na"&gt;msg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;msg&lt;/span&gt;
        &lt;span class="na"&gt;prio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prio&lt;/span&gt;
        &lt;span class="na"&gt;level_derived&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prio&lt;/span&gt;
        &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;label&lt;/span&gt;
        &lt;span class="na"&gt;err_code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;err_code&lt;/span&gt;
        &lt;span class="na"&gt;err_symbol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;err_symbol&lt;/span&gt;
        &lt;span class="na"&gt;SQL_state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SQL_state&lt;/span&gt;
        &lt;span class="na"&gt;subsystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;subsystem&lt;/span&gt;
  &lt;span class="c1"&gt;# https://dev.mysql.com/doc/refman/8.4/en/error-log-event-fields.html&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;level_derived&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;if&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;eq&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.Value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}System{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;else&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;if&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;eq&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.Value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}Error{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;else&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;if&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;eq&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.Value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"2"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}Warning{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;else&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}Note{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;end&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;time&lt;/span&gt;
      &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RFC3339&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;level_derived&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;structured_metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;prio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;err_code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;err_code&lt;/span&gt;
      &lt;span class="na"&gt;err_symbol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;err_symbol&lt;/span&gt;
      &lt;span class="na"&gt;SQL_state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SQL_state&lt;/span&gt;
      &lt;span class="na"&gt;subsystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;subsystem&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;msg&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;I extract all the key-value pairs using the &lt;code&gt;json&lt;/code&gt; stage&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;timestamp&lt;/code&gt; stage is there to ensure Promtail uses the time from the log statement instead of the time the file was last scraped&lt;/li&gt;
&lt;li&gt;I chose to turn the &lt;code&gt;level&lt;/code&gt; attribute into a label because log levels are usually the most important label, and also because the cardinality is quite low — 4.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;
  
  
  Example
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuqe1msosljpnfigyfbei.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuqe1msosljpnfigyfbei.png" alt="Raw MySQL log entry" width="800" height="168"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Equivalent logs in Loki:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgwkpkailbq7upd1bduga.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgwkpkailbq7upd1bduga.png" alt="Loki MySQL logs" width="800" height="249"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  NGINX Logs
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx_access_logs&lt;/span&gt;
  &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;service_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx_access_logs&lt;/span&gt;
      &lt;span class="na"&gt;__path__&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/log/nginx/access-json.log"&lt;/span&gt;
  &lt;span class="na"&gt;pipeline_stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;expressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;timestamp&lt;/span&gt;
        &lt;span class="na"&gt;remote_addr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;remote_addr&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;message&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;status&lt;/span&gt;
        &lt;span class="na"&gt;request_method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;request_method&lt;/span&gt;
        &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hostname&lt;/span&gt;
        &lt;span class="na"&gt;trace_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;trace_id&lt;/span&gt;
        &lt;span class="na"&gt;span_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;span_id&lt;/span&gt;
        &lt;span class="na"&gt;server_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server_name&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;timestamp&lt;/span&gt;
      &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RFC3339&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;request_method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;structured_metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;remote_addr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;trace_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;span_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;server_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;message&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Here, once again, I chose just 2 labels with a combined maximum cardinality of 4,500: 9 for &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods" rel="noopener noreferrer"&gt;HTTP methods&lt;/a&gt; times 500 for the range of status codes. Although practically it will be much smaller since not all of them are used.&lt;/p&gt;

&lt;p&gt;And here is the custom NGINX log format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;log_format json escape=json '{"remote_addr":"$remote_addr","timestamp":"$time_iso8601","message":"$request","status":"$status","request_method":"$request_method","hostname":"$hostname","trace_id": "$otel_trace_id","span_id":"$otel_span_id", "user_agent": "$http_user_agent","server_name":"$server_name"}';

access_log  /var/log/nginx/access-json.log  json;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Java Agent
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Backend
&lt;/h3&gt;

&lt;p&gt;For the backend, the Java Agent automatically exports the Logback logs using OTLP and sends them to Grafana Alloy, where some &lt;a href="https://opentelemetry.io/docs/specs/semconv/resource/" rel="noopener noreferrer"&gt;resource attributes&lt;/a&gt; get indexed to labels by &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods" rel="noopener noreferrer"&gt;default&lt;/a&gt;. The relevant ones for me include &lt;code&gt;deployment.environment.name&lt;/code&gt;(&lt;a href="https://opentelemetry.io/docs/specs/semconv/registry/attributes/deployment/" rel="noopener noreferrer"&gt;link&lt;/a&gt;) to differentiate &lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, and &lt;code&gt;production&lt;/code&gt;; and &lt;code&gt;service.name&lt;/code&gt;(&lt;a href="https://opentelemetry.io/docs/specs/semconv/registry/attributes/service/" rel="noopener noreferrer"&gt;link&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;My configuration using environment variables for &lt;a href="https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; and &lt;a href="https://opentelemetry.io/docs/zero-code/java/agent/getting-started/" rel="noopener noreferrer"&gt;Java&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;APP_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;app-version&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_SERVICE_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;expense_tracker_backend
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_JAVA_AGENT_LOCATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/opentelemetry
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;JAVA_TOOL_OPTIONS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-javaagent:&lt;/span&gt;&lt;span class="nv"&gt;$OTEL_JAVA_AGENT_LOCATION&lt;/span&gt;&lt;span class="s2"&gt;/opentelemetry-javaagent.jar"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SPRING_PROFILES_ACTIVE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://telemetry.davidgrath.com:4318
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_RESOURCE_ATTRIBUTES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;deployment.environment.name&lt;span class="o"&gt;=&lt;/span&gt;dev,deployment.environment&lt;span class="o"&gt;=&lt;/span&gt;dev,service.version&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$APP_VERSION&lt;/span&gt;
java &lt;span class="nt"&gt;-jar&lt;/span&gt; &lt;span class="s2"&gt;"/opt/expense_tracker/expense-tracker-backend.jar"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The domain name is my local DNS entry that points to my Grafana Alloy instance:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpvi21z1taqbtixanoqqb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpvi21z1taqbtixanoqqb.png" alt="Alloy Service Graph" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which is &lt;a href="https://grafana.com/docs/alloy/latest/reference/components/otelcol/otelcol.receiver.otlp/" rel="noopener noreferrer"&gt;configured&lt;/a&gt; like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;otelcol.exporter.otlphttp "loki_exporter" {&lt;/span&gt;
    &lt;span class="s"&gt;client {&lt;/span&gt;
        &lt;span class="s"&gt;endpoint = "http://localhost:3100/otlp"&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;

&lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="s"&gt;otelcol.receiver.otlp "otlp_receiver" {&lt;/span&gt;
    &lt;span class="s"&gt;grpc {&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;

    &lt;span class="s"&gt;http {&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;
    &lt;span class="s"&gt;output {&lt;/span&gt;
        &lt;span class="s"&gt;logs = [otelcol.exporter.otlphttp.loki_exporter.input]&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Example
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foufch8pkw0pf1qtcugpz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foufch8pkw0pf1qtcugpz.png" alt="Logging code" width="800" height="109"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Resulting Loki entry&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2p0zdqruok8edecicn4c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2p0zdqruok8edecicn4c.png" alt="Java app Loki logs" width="800" height="493"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Android
&lt;/h2&gt;

&lt;p&gt;For the Android version, the JVM is fundamentally different, and so auto-instrumentation isn’t a thing. Manual instrumentation is needed and can be achieved with the &lt;a href="https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/v2.16.0/instrumentation/logback/logback-appender-1.0/library" rel="noopener noreferrer"&gt;Logback library&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I’m using Dependency Injection with Dagger, so here’s how the &lt;code&gt;OpenTelemetry&lt;/code&gt; object is configured in my Dagger &lt;code&gt;Provides&lt;/code&gt; method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Provides&lt;/span&gt;
&lt;span class="nd"&gt;@Singleton&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;openTelemetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buildConstants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BuildConstants&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;OpenTelemetry&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;logsUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"${buildConstants.telemetryHttpUrl()}/v1/logs"&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ServiceAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SERVICE_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"expense-tracker-android"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"android.os.api_level"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SDK_INT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;//Gotten from semconv docs&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"deployment.environment.name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;buildConstants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;environmentName&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;sdk&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenTelemetrySdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setPropagators&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;ContextPropagators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nc"&gt;TextMapPropagator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;composite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="nc"&gt;W3CTraceContextPropagator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                    &lt;span class="nc"&gt;W3CBaggagePropagator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLoggerProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;SdkLoggerProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;addResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addLogRecordProcessor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BatchLogRecordProcessor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OtlpHttpLogRecordExporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;setEndpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logsUrl&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;buildAndRegisterGlobal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nc"&gt;OpenTelemetryAppender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;install&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sdk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sdk&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forgetting to append &lt;code&gt;/v1/logs&lt;/code&gt; gave me a bit of trouble before I found out.&lt;br&gt;
With the help of &lt;a href="https://logback.qos.ch/manual/mdc.html" rel="noopener noreferrer"&gt;Mapped Diagnostics Context&lt;/a&gt;, I’m able to attach a randomly generated &lt;code&gt;UUID&lt;/code&gt; to be able to filter my logs by &lt;code&gt;deviceId&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz3swv6njctc0h11rh7xk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz3swv6njctc0h11rh7xk.png" alt="Logs filtered by device" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Logging crashes
&lt;/h3&gt;

&lt;p&gt;There are better tools for the job, including &lt;a href="https://github.com/ACRA/acra" rel="noopener noreferrer"&gt;ACRA&lt;/a&gt; and &lt;a href="https://firebase.google.com/docs/crashlytics" rel="noopener noreferrer"&gt;Firebase Crashlytics&lt;/a&gt;. There’s even one within &lt;a href="https://github.com/open-telemetry/opentelemetry-android/tree/main/instrumentation/crash" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; itself, but I wanted something basic that I could see directly in Loki.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh1rfi0v88m0myu0q6zaz.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh1rfi0v88m0myu0q6zaz.gif" alt="Crash recording on Android" width="720" height="1600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjafaaxcadkvsq2twrj8d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjafaaxcadkvsq2twrj8d.png" alt="Crash logs in Android Studio" width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By making use of the OpenTelemetry SDK and &lt;a href="https://stackoverflow.com/questions/8943288/how-to-implement-uncaughtexception-android" rel="noopener noreferrer"&gt;UncaughtExceptionHandler&lt;/a&gt;, I’m able to upload the log before the app shuts down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;defaultUncaughtExceptionHandler&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDefaultUncaughtExceptionHandler&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;handler&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UncaughtExceptionHandler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;uncaughtException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Throwable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;processor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appComponent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logRecordProcessor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nc"&gt;LOGGER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Uncaught exception"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forceFlush&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MILLISECONDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;logRecordExporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MILLISECONDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;//VERY IMPORTANT! Return the flow back to the system&lt;/span&gt;
        &lt;span class="n"&gt;defaultUncaughtExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;uncaughtException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And I can see what happened in Loki:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8rff0cv436d4e22wj78f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8rff0cv436d4e22wj78f.png" alt="Android crash from Loki" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This method feels a little hacky since I’m accessing the SDK directly, but I’ll work with it since I got my logs&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration summary
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb5wkufmx6unbus9f2h92.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb5wkufmx6unbus9f2h92.png" alt="Summary diagram" width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Now that my configuration is complete, I have a central place where I can search my logs across all my servers and services, while still letting SSH be a viable alternative. &lt;/p&gt;

&lt;p&gt;In the next article, I’ll make use of distributed tracing to enable me to track the flow of execution from the app to the backend to the database using Tempo.&lt;/p&gt;

&lt;p&gt;If you need any feedback, feel free to discuss in the comments&lt;/p&gt;

&lt;p&gt;Thank you for your time.&lt;/p&gt;

</description>
      <category>learning</category>
    </item>
    <item>
      <title>Learning Fullstack Observability: Metrics</title>
      <dc:creator>David</dc:creator>
      <pubDate>Mon, 06 Apr 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/davidgrath/learning-fullstack-observability-metrics-3n44</link>
      <guid>https://dev.to/davidgrath/learning-fullstack-observability-metrics-3n44</guid>
      <description>&lt;p&gt;(Originally published on &lt;a href="https://medium.com/@DavidGrath0/implementing-full-stack-observability-a-deep-dive-into-prometheus-metrics-and-opentelemetry-900e86fde650" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;According to &lt;a href="https://www.redhat.com/en/topics/devops/what-is-observability" rel="noopener noreferrer"&gt;RedHat&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Observability refers to the ability to monitor, measure, and understand the state of a system or application by examining its outputs, logs, and performance metrics.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://opentelemetry.io/" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; is a vendor-neutral framework for observability, and is made of 3* main &lt;a href="https://opentelemetry.io/docs/concepts/signals/" rel="noopener noreferrer"&gt;pillars&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Metrics&lt;/li&gt;
&lt;li&gt;Traces&lt;/li&gt;
&lt;li&gt;Logs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(* Profiles are an upcoming 4th pillar, but still in development)&lt;/p&gt;

&lt;p&gt;In the “Expense Tracker” series, this article will be the first of 3 where I apply OpenTelemetry to my app, primarily with the Grafana stack:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/WSW1urIXsfA"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;In order to find out which REST API requests are the slowest, what services are hogging the most memory, and how saturated my web server's thread pool is, I need to track the numbers behind them, and the tool fit for that is the first in this observability series: Prometheus.&lt;/p&gt;

&lt;p&gt;For Expense Tracker, the exporters I’m interested in include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/prometheus/node_exporter" rel="noopener noreferrer"&gt;Node exporter&lt;/a&gt;: for my Linux VMs&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/prometheus-community/windows_exporter/" rel="noopener noreferrer"&gt;Windows exporter&lt;/a&gt;: for my host machine&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/prometheus/mysqld_exporter" rel="noopener noreferrer"&gt;MySQL exporter&lt;/a&gt;: for my database&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/nginx/nginx-prometheus-exporter" rel="noopener noreferrer"&gt;NGinX exporter&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://opentelemetry.io/docs/zero-code/java/agent/getting-started/" rel="noopener noreferrer"&gt;OpenTelemetry Java Agent&lt;/a&gt;: for the backend. I could have also used &lt;a href="https://docs.spring.io/spring-boot/reference/actuator/metrics.html" rel="noopener noreferrer"&gt;Spring Actuator&lt;/a&gt; or the &lt;a href="https://github.com/prometheus/jmx_exporter" rel="noopener noreferrer"&gt;JMX exporter&lt;/a&gt;, but the agent is simpler for this project since it emits all 3 types of telemetry signals.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’ll be going over the types of metrics used in Prometheus and how I apply them to create my Grafana dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prometheus Metric Types
&lt;/h2&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://prometheus.io/docs/concepts/metric_types/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fprometheus.io%2Ftwitter-image.png%3Fb370f6418ef38b42" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://prometheus.io/docs/concepts/metric_types/" rel="noopener noreferrer" class="c-link"&gt;
            Metric types | Prometheus
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Prometheus project documentation for Metric types
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fprometheus.io%2Ficon.svg%3F7aa022e51797bcef"&gt;
          prometheus.io
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;h3&gt;
  
  
  Gauges
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;How much disk space is available on the system?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The answer is a number that can go up or down as the server runs. This is one of the main metric types in Prometheus, and for the Windows exporter, that metric looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="n"&gt;windows_logical_disk_free_bytes&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;volume&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"C:"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="mf"&gt;2.6363297792&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first part is the actual metric. The second part in the curly braces is a label, and the number after is the actual value of the metric. The Windows exporter exposes this, and a lot of other metrics at &lt;code&gt;http://localhost:9182/metrics&lt;/code&gt;, and Prometheus is made to scrape them at a defined interval, and with enough scrapes, we get a pretty graph:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F43bqpjaqa2m1eczasenl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F43bqpjaqa2m1eczasenl.png" alt="Prometheus native query page"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Counters
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;How much load is my storage drive experiencing?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The answer lies with a counter. For Windows, the counter looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzetwi5qge1lsltjs7q7k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzetwi5qge1lsltjs7q7k.png" alt="Example of a counter"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Counters only ever go up, unlike gauges, but the key to using them is in finding out how quickly they go up. That’s achieved with the &lt;a href="https://prometheus.io/docs/prometheus/latest/querying/functions/#rate" rel="noopener noreferrer"&gt;rate&lt;/a&gt; function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="nb"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;windows_logical_disk_write_bytes_total&lt;/span&gt;&lt;span class="p"&gt;{}[&lt;/span&gt;&lt;span class="mi"&gt;15s&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft06fvejly20f7a11jkar.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft06fvejly20f7a11jkar.png" alt="Rate applied to counter"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The time window in square braces needs to be at least bigger than the scrape interval. In my case, I chose a scrape interval of 2.5 seconds, so a rate function with a window of 1 second would return no data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Histograms
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://prometheus.io/docs/practices/histograms/" rel="noopener noreferrer"&gt;Histograms&lt;/a&gt; are more complex, but in general, they’re for aggregation, and the question they helped me answer while I was running my load tests is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What’s the minimum latency that 95% of requests to GET /transactions are getting?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And simply put, the answer is gotten with a &lt;a href="https://prometheus.io/docs/prometheus/latest/querying/functions/#histogram_quantile" rel="noopener noreferrer"&gt;histogram quantile&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="nb"&gt;histogram_quantile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
  &lt;span class="nf"&gt;sum&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;le&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="na"&gt;http_route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; 
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http_server_request_duration_seconds_bucket&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;http_route&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/transactions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="na"&gt;http_request_method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;}[&lt;/span&gt;&lt;span class="mi"&gt;15m&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgi09c1tjrk0ysezlb0n5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgi09c1tjrk0ysezlb0n5.png" alt="Histogram quantile graph"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The 0.95 is the actual percentile, &lt;code&gt;sum&lt;/code&gt; is to aggregate by various labels like request method and request route, and &lt;code&gt;rate&lt;/code&gt; is mainly used to adjust the time window.&lt;/p&gt;

&lt;p&gt;The graph caps off at 10 because of the default bucket boundaries of le specified by the OpenTelemetry semantic conventions for &lt;a href="https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpserverrequestduration" rel="noopener noreferrer"&gt;HTTP metrics&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[ 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10 ]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;le&lt;/code&gt; is a special label for “less or equals”, which is used to form a cumulative sum with the relevant metric.&lt;/p&gt;

&lt;p&gt;This means that if most of the requests were significantly above 10 seconds, I’d have no way of knowing that from Prometheus.&lt;/p&gt;

&lt;p&gt;My options include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Relying on Tempo with a query to filter out the minimum duration traces
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="n"&gt;traceDuration&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;resource.service.name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"expense_tracker_backend"&lt;/span&gt;&lt;span class="ss"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flnmvur2ig1czmqzomka8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flnmvur2ig1czmqzomka8.png" alt="Tempo time filter"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use Tempo &lt;a href="https://grafana.com/docs/tempo/v2.6.x/configuration/#metrics-generator" rel="noopener noreferrer"&gt;Metrics Generator&lt;/a&gt;, although by default the bucket only goes up to 16.384 seconds, which was much less than what I was experiencing at the extreme in my load tests&lt;/li&gt;
&lt;li&gt;Use Tempo in combination with &lt;a href="https://grafana.com/docs/grafana/latest/fundamentals/exemplars/" rel="noopener noreferrer"&gt;exemplars&lt;/a&gt; to find samples of the worst offenders&lt;/li&gt;
&lt;li&gt;Customizing my bucket boundaries to include larger values.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I talk about how my performance test reached such high latencies in a later article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grafana Dashboards
&lt;/h2&gt;

&lt;p&gt;Using what I’ve learned in combination with the various collectors lets me build some fun dashboards:&lt;/p&gt;

&lt;h3&gt;
  
  
  MySQL
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcipdsihjkh8d8cudv3g7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcipdsihjkh8d8cudv3g7.png" alt="MySQL Basics Dashboard"&gt;&lt;/a&gt;&lt;br&gt;
Variables reference linked &lt;a href="https://dev.mysql.com/doc/refman/8.0/en/server-status-variables.html" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;br&gt;
Some of the panels include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;InnoDB memory usage to know if the server is running low&lt;/li&gt;
&lt;li&gt;Bytes sent and received per second&lt;/li&gt;
&lt;li&gt;Count of slow queries to know if any potentially inefficient queries are running&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Node Exporter
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9il3raim10o96xhb7rmj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9il3raim10o96xhb7rmj.png" alt="Node Exporter Dashboard"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here I monitor the hardware similar to the way it's shown in Linux &lt;a href="https://www.baeldung.com/linux/top-command" rel="noopener noreferrer"&gt;top&lt;/a&gt; or Windows Task Manager&lt;/p&gt;
&lt;h3&gt;
  
  
  RED Dashboard
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4dc61mhhzoy8brbcwftn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4dc61mhhzoy8brbcwftn.png" alt="Rate, Error, Duration dashboard"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Where &lt;a href="https://grafana.com/blog/the-red-method-how-to-instrument-your-services/" rel="noopener noreferrer"&gt;RED&lt;/a&gt; stands for&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rate: number of requests per second, here I show the overall and also break it down by endpoint&lt;/li&gt;
&lt;li&gt;Errors: the error rate. I chose to keep one panel for server errors and another for client errors&lt;/li&gt;
&lt;li&gt;Duration: here I use p95 as discussed earlier, with a time window of 1 hour&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;I’ll briefly cover the actual setup and configuration I used&lt;/p&gt;
&lt;h3&gt;
  
  
  Metric scraping
&lt;/h3&gt;

&lt;p&gt;The most basic configuration to get started with Prometheus is the &lt;code&gt;scrape_configs&lt;/code&gt; block with the &lt;code&gt;static_configs&lt;/code&gt; parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;windows_exporter&lt;/span&gt;
   &lt;span class="na"&gt;scrape_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2500ms&lt;/span&gt;
   &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;localhost:9182'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;host_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LAPTOP-7H9JJDHB"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The config shown is for Windows, but it’s similar to my configuration for the other services&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenTelemetry
&lt;/h2&gt;

&lt;p&gt;Enabled at the command line with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--web&lt;/span&gt;.enable-otlp-receiver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;More information &lt;a href="https://prometheus.io/docs/guides/opentelemetry/" rel="noopener noreferrer"&gt;here&lt;/a&gt;. It uses a push-based model, which differs from Prometheus main pull-based model. This is mainly for use with Alloy, which is Grafana’s distribution of the OpenTelemetry collector. My &lt;a href="https://grafana.com/docs/alloy/latest/reference/components/otelcol/" rel="noopener noreferrer"&gt;configuration&lt;/a&gt; looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;otelcol.exporter.otlphttp "prometheus_exporter_otlp" {&lt;/span&gt;
    &lt;span class="s"&gt;client {&lt;/span&gt;
        &lt;span class="s"&gt;endpoint = "http://localhost:9090/api/v1/otlp"&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;

&lt;span class="s"&gt;otelcol.receiver.otlp "otlp_receiver" {&lt;/span&gt;
    &lt;span class="s"&gt;grpc {&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;

    &lt;span class="s"&gt;http {&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;
    &lt;span class="s"&gt;output {&lt;/span&gt;
        &lt;span class="s"&gt;metrics = [otelcol.exporter.otlphttp.prometheus_exporter_otlp.input]&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And my resulting Alloy graph is this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fewmzwl2wchpbrdth0wia.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fewmzwl2wchpbrdth0wia.png" alt="Alloy OpenTelemetry graph"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6cv78he458fsajbpvu79.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6cv78he458fsajbpvu79.jpg" alt="Alert icon"&gt;&lt;/a&gt;&lt;br&gt;
Image by &lt;a href="https://www.freepik.com/free-vector/notification-bell-red_234716987.htm" rel="noopener noreferrer"&gt;juicy_fish&lt;/a&gt; on Freepik&lt;/p&gt;

&lt;h3&gt;
  
  
  Alerting
&lt;/h3&gt;

&lt;p&gt;CPU, Disk usage, and RAM usage are all very volatile metrics compared to storage usage, so I grouped them separately and set different time intervals for alerts to be sent, and then set the notifier to be the email one.&lt;/p&gt;

&lt;p&gt;The basic setup looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffuiyn1rq2syv1wgwvk6i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffuiyn1rq2syv1wgwvk6i.png" alt="Prometheus Alerting Example"&gt;&lt;/a&gt;&lt;br&gt;
And a sample alert looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F246289dgg1k0db61gjdo.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F246289dgg1k0db61gjdo.jpg" alt="Prometheus Email Example"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;So that’s pretty much it. This is the first of the 3 Observability articles, and the next one will be about how I gather all the logs from my various services into a central place using Loki.&lt;/p&gt;

&lt;p&gt;Please feel free to discuss your thoughts and suggestions in the comment section&lt;/p&gt;

&lt;p&gt;Thank you for your time&lt;/p&gt;

</description>
      <category>java</category>
      <category>mysql</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Expense Tracker: Learning CI/CD</title>
      <dc:creator>David</dc:creator>
      <pubDate>Mon, 09 Mar 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/davidgrath/expense-tracker-learning-cicd-2ah1</link>
      <guid>https://dev.to/davidgrath/expense-tracker-learning-cicd-2ah1</guid>
      <description>&lt;p&gt;(Originally published on &lt;a href="https://medium.com/@DavidGrath0/expense-tracker-learning-ci-cd-63fe1ec5e6ec" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;At some point, I had no idea what CI and CD were about, so I decided to learn about them. I got to learn enough to be able to do a simple Build-Test-Deploy cycle, so now I know enough to define them in my own words:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous Integration&lt;/strong&gt;: JUnit tests are run on every commit to the &lt;code&gt;dev&lt;/code&gt; branch&lt;br&gt;
&lt;strong&gt;Continuous Delivery&lt;/strong&gt;: the JAR is also built and sent straight to the test server when the tests pass, with potential smoke tests such as a “User can log in” test being run.&lt;br&gt;
&lt;strong&gt;Continuous Deployment&lt;/strong&gt;: the build is also sent to production after the smoke tests pass&lt;br&gt;
&lt;a href="https://www.atlassian.com/continuous-delivery/principles/continuous-integration-vs-delivery-vs-deployment" rel="noopener noreferrer"&gt;Sten from Atlassian&lt;/a&gt; does a great job of explaining the differences between the 3 terms&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4h1j899n9a4isjnepw9x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4h1j899n9a4isjnepw9x.png" alt="Atlassian CI CD article image"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My tool of choice is Jenkins, mainly because I prefer a self-hosted approach, and I’ll talk about the Jenkinsfile I wrote for Expense Tracker.&lt;/p&gt;
&lt;h2&gt;
  
  
  Architecture overview
&lt;/h2&gt;

&lt;p&gt;I have 2 main VM’s, both of which are running Ubuntu&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Controller&lt;/strong&gt;
This is where Jenkins itself runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test Server&lt;/strong&gt;
This is where the “dev” profile of the app will run. It’s also where I’ll be running my tests with the “ci” profile.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Jenkinsfile Walk-through
&lt;/h2&gt;

&lt;p&gt;So at a basic level, the tasks mainly involved:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Resetting the database&lt;/li&gt;
&lt;li&gt;Generating the relevant code&lt;/li&gt;
&lt;li&gt;Testing&lt;/li&gt;
&lt;li&gt;Building&lt;/li&gt;
&lt;li&gt;Deploying&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;
  
  
  MySQL Credentials
&lt;/h3&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://www.jenkins.io/doc/book/using/using-credentials/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.jenkins.io%2Fimages%2Flogo-title-opengraph.png" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://www.jenkins.io/doc/book/using/using-credentials/" rel="noopener noreferrer" class="c-link"&gt;
            
Using credentials

          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Jenkins – an open source automation server which enables developers around the world to reliably build, test, and deploy their software
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.jenkins.io%2Ffavicon.ico"&gt;
          jenkins.io
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;br&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;environment&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;MYSQL_CREDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;credentials&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mysql_credentials'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The credentials used here consist of a username and password combination stored within Jenkins. Other credential types I use in the file include Secrets Text for Ansible and a Secrets file for the Spring configuration. I did this to make sure &lt;strong&gt;I never have to commit my credentials directly to source control&lt;/strong&gt;. &lt;a href="https://github.com/hashicorp/vault" rel="noopener noreferrer"&gt;HashiVault&lt;/a&gt; is also an option, but I decided it wasn't needed for my use case.&lt;/p&gt;
&lt;h2&gt;
  
  
  Use agent “Test Server”
&lt;/h2&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://www.jenkins.io/doc/book/using/using-agents/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.jenkins.io%2Fimages%2Flogo-title-opengraph.png" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://www.jenkins.io/doc/book/using/using-agents/" rel="noopener noreferrer" class="c-link"&gt;
            
Using Jenkins agents

          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Jenkins – an open source automation server which enables developers around the world to reliably build, test, and deploy their software
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.jenkins.io%2Ffavicon.ico"&gt;
          jenkins.io
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;





&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://www.jenkins.io/doc/book/managing/nodes/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.jenkins.io%2Fimages%2Flogo-title-opengraph.png" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://www.jenkins.io/doc/book/managing/nodes/" rel="noopener noreferrer" class="c-link"&gt;
            
Managing Nodes

          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Jenkins – an open source automation server which enables developers around the world to reliably build, test, and deploy their software
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.jenkins.io%2Ffavicon.ico"&gt;
          jenkins.io
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;br&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s1"&gt;'Test Server'&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Here, I used &lt;code&gt;ssh-keygen&lt;/code&gt; to generate a private key and copy it over as a secret into Jenkins.&lt;/p&gt;
&lt;h3&gt;
  
  
  Flyway
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Flyway'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'chmod +x ./gradlew'&lt;/span&gt;
    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'./gradlew setUpCIDb'&lt;/span&gt;
    &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'database/flyway'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'flyway migrate -environment=ci  -user=$MYSQL_CREDS_USR -password=$MYSQL_CREDS_PSW'&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Here, I drop and recreate the database before running migrations on it. There’s an exclusive database for this task, and it is suffixed with “_ci”. Flyway was downloaded and symlinked on this machine into &lt;code&gt;/usr/bin&lt;/code&gt; for this to work, and I created an &lt;a href="https://documentation.red-gate.com/fd/environments-namespace-277578909.html" rel="noopener noreferrer"&gt;environment&lt;/a&gt; just for this&lt;/p&gt;
&lt;h3&gt;
  
  
  jOOQ
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'JooQ'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'./gradlew jooqCodeGenCi'&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;jOOQ enables me to work with a schema-first approach for the database by generating classes based on the tables and views I have, so this block is necessary for all the needed classes to be present.&lt;/p&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://www.jooq.org/doc/3.19/manual/code-generation/codegen-system-properties/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.jooq.org%2Fimg%2Fjooq-logo-white-750x750-padded.png" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://www.jooq.org/doc/3.19/manual/code-generation/codegen-system-properties/" rel="noopener noreferrer" class="c-link"&gt;
            System properties governing code generation
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            System properties governing code generation
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.jooq.org%2Fapple-touch-icon-57x57.png"&gt;
          jooq.org
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;





&lt;p&gt;The main issue I had here was that even though jOOQ supports using command-line properties, it didn’t work until I used the driver, url, username, and password all on the command line. Mixing command-line authentication with configuration file properties didn’t work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'jooqCodeGenCi'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dependsOn&lt;/span&gt; &lt;span class="n"&gt;jooqCodeGenCopy&lt;/span&gt;

    &lt;span class="n"&gt;workingDir&lt;/span&gt; &lt;span class="s2"&gt;"${rootDir}/database/jooq"&lt;/span&gt;
    &lt;span class="n"&gt;commandLine&lt;/span&gt; &lt;span class="s2"&gt;"java"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"-Djooq.codegen.propertyOverride=true"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; 
      &lt;span class="s2"&gt;"-Djooq.codegen.jdbc.driver=com.mysql.jdbc.Driver"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; 
      &lt;span class="s2"&gt;"-Djooq.codegen.jdbc.url=jdbc:mysql://172.26.48.26:3306/expense_tracker_database_ci"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; 
      &lt;span class="s2"&gt;"-Djooq.codegen.jdbc.username=${System.getenv("&lt;/span&gt;&lt;span class="n"&gt;MYSQL_CREDS_USR&lt;/span&gt;&lt;span class="s2"&gt;")}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; 
      &lt;span class="s2"&gt;"-Djooq.codegen.jdbc.password=${System.getenv("&lt;/span&gt;&lt;span class="n"&gt;MYSQL_CREDS_PSW&lt;/span&gt;&lt;span class="s2"&gt;")}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; 
      &lt;span class="s2"&gt;"-Djooq.codegen.propertyOverride=true"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; 
      &lt;span class="s2"&gt;"-cp"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"org.jooq.codegen.GenerationTool"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; 
      &lt;span class="s2"&gt;"configuration-ci.xml"&lt;/span&gt;

&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  OpenAPI
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Open API'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'./gradlew openApiGenerate'&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Similar to jOOQ, I use an API-first or spec-first approach in my code, meaning that all of the entities and paths that my controllers rely on need to be generated first.&lt;/p&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://openapi-generator.tech/docs/generators/spring/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fopenapi-generator.tech%2Fimg%2Fdocusaurus.png" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://openapi-generator.tech/docs/generators/spring/" rel="noopener noreferrer" class="c-link"&gt;
            Documentation for the spring Generator | OpenAPI Generator
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            METADATA
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fopenapi-generator.tech%2Fimg%2Ffavicon.png"&gt;
          openapi-generator.tech
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;





&lt;p&gt;&lt;a href="https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator-gradle-plugin/README.adoc" rel="noopener noreferrer"&gt;https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator-gradle-plugin/README.adoc&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Build
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Build'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'./gradlew assemble'&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Compiles the classes&lt;/p&gt;
&lt;h3&gt;
  
  
  Test
&lt;/h3&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://www.jenkins.io/doc/pipeline/tour/environment/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.jenkins.io%2Fimages%2Flogo-title-opengraph.png" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://www.jenkins.io/doc/pipeline/tour/environment/" rel="noopener noreferrer" class="c-link"&gt;
            
Using environment variables

          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Jenkins – an open source automation server which enables developers around the world to reliably build, test, and deploy their software
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.jenkins.io%2Ffavicon.ico"&gt;
          jenkins.io
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;





&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://www.jenkins.io/doc/book/pipeline/syntax/#environment" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.jenkins.io%2Fimages%2Flogo-title-opengraph.png" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://www.jenkins.io/doc/book/pipeline/syntax/#environment" rel="noopener noreferrer" class="c-link"&gt;
            
Pipeline Syntax

          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Jenkins – an open source automation server which enables developers around the world to reliably build, test, and deploy their software
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.jenkins.io%2Ffavicon.ico"&gt;
          jenkins.io
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;br&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;environment&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;SPRING_PROFILES_ACTIVE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ci'&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;I start by setting the active Spring Profile to “ci”, one that I’ve made exclusively for this pipeline.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'rm -f app/src/main/resources/secrets-ci.properties'&lt;/span&gt;
&lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'chmod o+w -R app'&lt;/span&gt;
&lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'expense-tracker-backend-secrets-ci'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;variable:&lt;/span&gt; &lt;span class="s1"&gt;'expenseTrackerApplicationSecrets'&lt;/span&gt;&lt;span class="o"&gt;)])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'cp $expenseTrackerApplicationSecrets app/src/main/resources/secrets-ci.properties'&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'./gradlew test'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;I then remove any potentially existing secrets file before copying over a new one from Jenkins credentials. What I store there includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The JDBC password&lt;/li&gt;
&lt;li&gt;The JWT private key&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;-f&lt;/code&gt; flag is to ensure an exit code of 0 if the file does not exist, because &lt;strong&gt;with a non-zero exit code, the pipeline will fail&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And then I test. How exactly I test is discussed in the &lt;a href="https://dev.to/davidgrath/how-i-finally-wrapped-my-head-around-unit-testing-1l3n"&gt;previous article&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Flyway Dev
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Flyway Dev'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'database/flyway'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'flyway migrate -environment=dev  -user=$MYSQL_CREDS_USR -password=$MYSQL_CREDS_PSW'&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Exactly like my first Flyway migration, but this time for my “dev” environment. There’s no &lt;code&gt;DROP&lt;/code&gt;-ing this time, though&lt;/p&gt;

&lt;p&gt;Since there were no issues with the CI migration, I’m confident there will be no issues with this migration.&lt;/p&gt;
&lt;h3&gt;
  
  
  Ansible
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Ansible setup'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s1"&gt;'built-in'&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;environment&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ANSIBLE_BECOME_PASS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;credentials&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ansible-become-password'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ansible'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'ansible-playbook -i inventory playbooks/setup_service.yaml  --private-key ~/.ssh/jenkins_agent_key -e "ansible_become_password=\'$ANSIBLE_BECOME_PASS\'"'&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;I won’t go into the full Ansible playbook here, but it essentially configures my app to run with the &lt;a href="https://opentelemetry.io/docs/zero-code/java/agent/getting-started/" rel="noopener noreferrer"&gt;OpenTelemetry Java Agent&lt;/a&gt; and &lt;a href="https://grafana.com/docs/pyroscope/latest/configure-client/language-sdks/java/" rel="noopener noreferrer"&gt;Pyroscope&lt;/a&gt; using SystemD.&lt;/p&gt;

&lt;p&gt;I needed to run this off the Jenkins host machine, which is &lt;strong&gt;Controller&lt;/strong&gt; in this case — the built-in node, and my main issue was finding the name to use from the docs. I found it in a &lt;a href="https://community.jenkins.io/t/jenkins-2-319-1-master-controller-built-in-node-built-in/939" rel="noopener noreferrer"&gt;forum&lt;/a&gt; instead.&lt;/p&gt;
&lt;h3&gt;
  
  
  Deploy
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Deploy'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'./gradlew bootJar'&lt;/span&gt;
    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'cp "./app/build/libs/expense-tracker-backend.jar" /opt/expense_tracker/expense-tracker-backend.jar'&lt;/span&gt;
    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'cp app-version /opt/expense_tracker/app-version'&lt;/span&gt;
    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'chmod 777 /opt/expense_tracker/expense-tracker-backend.jar'&lt;/span&gt;
    &lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'expense-tracker-backend-secrets-dev'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;variable:&lt;/span&gt; &lt;span class="s1"&gt;'expenseTrackerApplicationSecrets'&lt;/span&gt;&lt;span class="o"&gt;)])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo rm -f /opt/expense_tracker/secrets-dev.properties'&lt;/span&gt;
      &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'cp $expenseTrackerApplicationSecrets /opt/expense_tracker/secrets-dev.properties'&lt;/span&gt;
      &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'chmod 700 /opt/expense_tracker/secrets-dev.properties'&lt;/span&gt;
      &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo chown root:root /opt/expense_tracker/secrets-dev.properties'&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo systemctl enable expense_tracker_backend'&lt;/span&gt;
    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo systemctl daemon-reload'&lt;/span&gt;
    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo systemctl restart expense_tracker_backend'&lt;/span&gt;
    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo systemctl status expense_tracker_backend'&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The only really interesting parts in this block are here:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo systemctl enable expense_tracker_backend'&lt;/span&gt;
&lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo systemctl daemon-reload'&lt;/span&gt;
&lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo systemctl restart expense_tracker_backend'&lt;/span&gt;
&lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo systemctl status expense_tracker_backend'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Usually, &lt;code&gt;sudo&lt;/code&gt; can’t be run without a password prompt, and this is a problem for an automated script. However, I can &lt;a href="https://stackoverflow.com/questions/11880070/how-to-run-a-script-as-root-in-jenkins" rel="noopener noreferrer"&gt;get it to work&lt;/a&gt; for specific commands using the &lt;a href="https://unix.stackexchange.com/questions/606452/allowing-user-to-run-systemctl-systemd-services-without-password" rel="noopener noreferrer"&gt;sudoers&lt;/a&gt; file. The directive needs to be the &lt;a href="https://askubuntu.com/a/100112" rel="noopener noreferrer"&gt;last in the file&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jenkins ALL=(ALL) NOPASSWD: /usr/bin/systemctl, /usr/bin/chown, /usr/bin/rm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;I made sure to only grant access to the commands I needed and nothing more.&lt;/p&gt;

&lt;p&gt;I had cases where the service would restart but still crash due to a mistake I made, like some improper configuration. The &lt;code&gt;status&lt;/code&gt; command is there to fail and make sure I know something went wrong.&lt;/p&gt;
&lt;h3&gt;
  
  
  Post-Script Execution
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;junit&lt;/span&gt; &lt;span class="s1"&gt;'app/build/test-results/**/*.xml'&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://www.jenkins.io/doc/pipeline/tour/tests-and-artifacts/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.jenkins.io%2Fimages%2Flogo-title-opengraph.png" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://www.jenkins.io/doc/pipeline/tour/tests-and-artifacts/" rel="noopener noreferrer" class="c-link"&gt;
            
Recording tests and artifacts

          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Jenkins – an open source automation server which enables developers around the world to reliably build, test, and deploy their software
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.jenkins.io%2Ffavicon.ico"&gt;
          jenkins.io
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;





&lt;p&gt;Here I export the results of the test run from the agent so they can be viewed from within Jenkins.&lt;/p&gt;

&lt;p&gt;And here’s the full Jenkinsfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;environment&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;MYSQL_CREDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;credentials&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mysql_credentials'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s1"&gt;'Test Server'&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Branch dev&lt;/span&gt;
    &lt;span class="n"&gt;stages&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Flyway'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'chmod +x ./gradlew'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'./gradlew setUpCIDb'&lt;/span&gt;
                &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'database/flyway'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'flyway migrate -environment=ci  -user=$MYSQL_CREDS_USR -password=$MYSQL_CREDS_PSW'&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'JooQ'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'./gradlew jooqCodeGenCi'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Open API'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'./gradlew openApiGenerate'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Build'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'./gradlew assemble'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Test'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;environment&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;SPRING_PROFILES_ACTIVE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ci'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'rm -f app/src/main/resources/secrets-ci.properties'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'chmod o+w -R app'&lt;/span&gt;
            &lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'expense-tracker-backend-secrets-ci'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;variable:&lt;/span&gt; &lt;span class="s1"&gt;'expenseTrackerApplicationSecrets'&lt;/span&gt;&lt;span class="o"&gt;)])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'cp $expenseTrackerApplicationSecrets app/src/main/resources/secrets-ci.properties'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'./gradlew test'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Flyway Dev'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'database/flyway'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'flyway migrate -environment=dev  -user=$MYSQL_CREDS_USR -password=$MYSQL_CREDS_PSW'&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Ansible setup'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s1"&gt;'built-in'&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;environment&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;ANSIBLE_BECOME_PASS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;credentials&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ansible-become-password'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ansible'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'ansible-playbook -i inventory playbooks/setup_service.yaml  --private-key ~/.ssh/jenkins_agent_key -e "ansible_become_password=\'$ANSIBLE_BECOME_PASS\'"'&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Deploy'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'./gradlew bootJar'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'cp "./app/build/libs/expense-tracker-backend.jar" /opt/expense_tracker/expense-tracker-backend.jar'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'cp app-version /opt/expense_tracker/app-version'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'chmod 777 /opt/expense_tracker/expense-tracker-backend.jar'&lt;/span&gt;
                &lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'expense-tracker-backend-secrets-dev'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;variable:&lt;/span&gt; &lt;span class="s1"&gt;'expenseTrackerApplicationSecrets'&lt;/span&gt;&lt;span class="o"&gt;)])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo rm -f /opt/expense_tracker/secrets-dev.properties'&lt;/span&gt;
                    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'cp $expenseTrackerApplicationSecrets /opt/expense_tracker/secrets-dev.properties'&lt;/span&gt;
                    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'chmod 700 /opt/expense_tracker/secrets-dev.properties'&lt;/span&gt;
                    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo chown root:root /opt/expense_tracker/secrets-dev.properties'&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo systemctl enable expense_tracker_backend'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo systemctl daemon-reload'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo systemctl restart expense_tracker_backend'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'sudo systemctl status expense_tracker_backend'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;junit&lt;/span&gt; &lt;span class="s1"&gt;'app/build/test-results/**/*.xml'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a sample success run&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw6ubvq9so4uxwutunpcg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw6ubvq9so4uxwutunpcg.png" alt="Satisfactory button-click output"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;There are several areas for improvement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;My app version is manually updated right now, and my own forgetfulness means that I’d rather automate incrementing it.&lt;/li&gt;
&lt;li&gt;All of this was done using the &lt;a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Git-Daemon" rel="noopener noreferrer"&gt;Git daemon&lt;/a&gt;. I’d rather set up &lt;a href="https://about.gitea.com/products/gitea/" rel="noopener noreferrer"&gt;Gitea&lt;/a&gt; so I can use webhooks instead of manually clicking build whenever I commit to dev&lt;/li&gt;
&lt;li&gt;I could also use an artifact management tool like Artifactory or Sona Nexus to keep the past few JAR files in case I need them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But I’m also enjoying some worthwhile benefits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The build verification phase happens automatically&lt;/li&gt;
&lt;li&gt;I don’t have to &lt;code&gt;ssh&lt;/code&gt; and &lt;code&gt;scp&lt;/code&gt; the JAR file manually&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now that the application backend is up and running, next I’ll be talking about how I use OpenTelemetry to ensure I have full visibility into both ends of the app at runtime, starting with Prometheus.&lt;/p&gt;

&lt;p&gt;Thank you for your time&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>jenkins</category>
      <category>springboot</category>
      <category>linux</category>
    </item>
    <item>
      <title>How I finally wrapped my head around unit testing</title>
      <dc:creator>David</dc:creator>
      <pubDate>Sat, 28 Feb 2026 19:37:50 +0000</pubDate>
      <link>https://dev.to/davidgrath/how-i-finally-wrapped-my-head-around-unit-testing-1l3n</link>
      <guid>https://dev.to/davidgrath/how-i-finally-wrapped-my-head-around-unit-testing-1l3n</guid>
      <description>&lt;p&gt;(Originally posted on &lt;a href="https://medium.com/@DavidGrath0/how-i-learned-unit-testing-d85f1368cd59" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;)&lt;br&gt;
This is the second article in my Expense Tracker series. In my &lt;a href="https://dev.to/davidgrath/building-a-personal-expense-tracker-with-opentelemetry-and-cicd-5865"&gt;introductory article&lt;/a&gt;, I covered the overall architecture and the topics this series will cover. Here, I discuss how I automated my tests.&lt;/p&gt;
&lt;h2&gt;
  
  
  It all Started with Postman
&lt;/h2&gt;

&lt;p&gt;Expense Tracker isn’t actually my first project where I started learning about testing. I have another learning project written in Spring Boot, aptly named “Shopping App”. The pattern I started with was something like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Write code for the controller&lt;/li&gt;
&lt;li&gt;Run the project with &lt;code&gt;gradlew bootRun&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Open Postman&lt;/li&gt;
&lt;li&gt;Confirm the endpoint is working&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Early on, this cycle already felt unnecessarily repetitive, and I knew there had to be a better way. I started by looking into &lt;a href="https://learning.postman.com/docs/tests-and-scripts/write-scripts/intro-to-scripts/" rel="noopener noreferrer"&gt;Postman scripting&lt;/a&gt;, but then I thought, “Isn’t there a way I could do this natively in Java?”, and that’s when I realized what Unit Testing was about.&lt;/p&gt;
&lt;h3&gt;
  
  
  Writing a Basic Authentication Test
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff95arbtmbb48hq7n8wt4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff95arbtmbb48hq7n8wt4.png" alt="Postman Sign Up Test"&gt;&lt;/a&gt;&lt;br&gt;
In this instance, I'm checking to see if sign up works at the basic level. What happens in the case of a username being taken or the password being insecure isn't my concern just yet, but here's the same flow translated to testing code using the Spring testing framework:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;successfulSignUpFlow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;signUpRequest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SignUpRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"username"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"password"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;US&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLanguageTag&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;objectWriter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ObjectMapper&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;signUpJson&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;objectWriter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeValueAsString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signUpRequest&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;signUpResult&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mockMvc&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;MockMvcRequestBuilders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/auth/signUp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;APPLICATION_JSON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signUpJson&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;andExpect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MockMvcResultMatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;isOk&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;andReturn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signUpResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getResponse&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getContentAsString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"eyJ"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;//pretty much constant for every JSON Base64&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;And the test result looks like this in IntelliJ:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fji0u7yxis9gw804486tn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fji0u7yxis9gw804486tn.png" alt="JUnit Sign Up Test Result"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Testing my code like this meant a few key things for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I no longer had to use Postman to verify the correctness of my new code&lt;/li&gt;
&lt;li&gt;I now have an automated way to verify any previous tests I would have done manually&lt;/li&gt;
&lt;li&gt;I can specify all the relevant outcomes, such as HTTP 400 for an empty text field, or HTTP 404 for entities that don’t belong to the user, and be sure that they occur&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’ve read from a variety of sources to help improve my understanding of what testing should be about, and a key one has been Martin Fowler, especially his post “&lt;a href="https://martinfowler.com/bliki/GivenWhenThen.html" rel="noopener noreferrer"&gt;Given When Then&lt;/a&gt;”, which is about Behaviour Driven Development, or BDD, a term &lt;a href="https://dannorth.net/blog/introducing-bdd/" rel="noopener noreferrer"&gt;coined by Dan North&lt;/a&gt;. I’ll use an arithmetic example as I go along.&lt;/p&gt;

&lt;p&gt;Before, when I thought about testing, all I could think up was trivial things like “Verify 1+1 = 2”, and it felt so trivial and pointless, and I wondered why people would be able to create tests like these. I did look at other examples, but I couldn’t understand what was going on back then. &lt;strong&gt;Now, with my current understanding, I see that it’s mainly about capturing expected behaviours as well as edge cases&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Let’s assume that this simple addition function is in Java, and we’re dealing with integers. Using &lt;code&gt;int&lt;/code&gt; already eliminates the need for a few edge cases, but some other questions pop up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Are a and b both positive?&lt;/li&gt;
&lt;li&gt;What if a+b exceeds the 32-bit or 64-bit limit?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few other questions might pop up if varied input is allowed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What about scientific notation?&lt;/li&gt;
&lt;li&gt;What about different bases like hexadecimal or binary?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This example is rather trivial, and the actual constraints for an app like this may be very different, but it does help me to illustrate some key things I’ve learned.&lt;/p&gt;

&lt;p&gt;Tests serve as a form of documentation of the multiple possible ways to use a system. Based on the example above, some behaviors would look like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Given a or b negative when add then return invalid input&lt;/li&gt;
&lt;li&gt;Given sum overflows when add return maximum positive number&lt;/li&gt;
&lt;li&gt;Given sum overflows when add return invalid input&lt;/li&gt;
&lt;li&gt;Given a and b in scientific when add then output is scientific&lt;/li&gt;
&lt;li&gt;Given a and b in when add hex then return hex&lt;/li&gt;
&lt;li&gt;Given a and b in different formats when add then return invalid input&lt;/li&gt;
&lt;li&gt;Given a and b in different formats when add then output in format of a&lt;/li&gt;
&lt;li&gt;Given a and b in different bases when add then output in higher base&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some of these are contradictory alternative responses to the same scenario, but it illustrates the point that there’s now no ambiguity in how I want things to work out.&lt;br&gt;
I'm aware that there's another very popular format for test cases called "Arrange, Act, Assert", but I've chosen to stick with "Given When Then" since it also works.&lt;br&gt;
Next, I’ll cover the various layers of my 3 code bases and how I applied testing to each of them.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Database Layer
&lt;/h2&gt;

&lt;p&gt;Known as the “Data Access Object” or “DAO” layer in the Android app and the “Repository” layer in the Spring project.&lt;br&gt;
The tests I wrote here were mainly just to verify if my queries ran correctly whenever I went beyond simple column &lt;code&gt;SELECT&lt;/code&gt;s such as with &lt;code&gt;COUNT DISTINCT()&lt;/code&gt;, subqueries, or my personal favorite: &lt;code&gt;WINDOW&lt;/code&gt; functions&lt;/p&gt;
&lt;h3&gt;
  
  
  Cumulative Sum
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;getTransactionCumSumByDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;profileId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;debitOrCredit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DateAmountSummary&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;window&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DSL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DATED_AT&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rangeBetweenUnboundedPreceding&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;andCurrentRow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;selectStep&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dslContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DATED_AT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;`as`&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"aggregateDate"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;DSL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AMOUNT&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;over&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;`as`&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sum"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// Rest of selectStep&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;alias&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selectStep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;selectFrom&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dslContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"aggregateDate"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;DSL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sum"&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;seekStep&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;  &lt;span class="n"&gt;selectFrom&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"aggregateDate"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"aggregateDate"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;


    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seekStep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;into&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DateAmountSummary&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;I was combining a window function with an aggregation via a subquery, and this was not very straightforward to me, so I wrote a test to help confirm that the number of rows and the final output was as expected:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;cumSumByDateTest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;folder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="c1"&gt;//Temp folder for images&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;firstTransactionDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2025-01-01"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;secondTransactionDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2025-01-02"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;thirdTransactionDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2025-01-04"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fourthTransactionDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2025-01-04"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fifthTransactionDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2025-01-05"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;accountId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getDefaultAccountId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;profileRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accountRepository&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;user&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByUuid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TestConstants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DEFAULT_USER_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;profile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;profileRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByStringId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DEFAULT_PROFILE_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dataBuilder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DataBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DEFAULT_PROFILE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;testUtilComponent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dataBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransaction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Description"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"miscellaneous"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;110.00&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firstTransactionDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;dataBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransaction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Description"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"miscellaneous"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;300.00&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secondTransactionDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;dataBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransaction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Description"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"miscellaneous"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;450.00&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thirdTransactionDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;dataBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransaction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Description"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"miscellaneous"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;750.00&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fourthTransactionDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;dataBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransaction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Description"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"miscellaneous"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1_000.00&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fifthTransactionDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;sums&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transactionRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTransactionCumSumByDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;firstTransactionDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;  &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;firstSum&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aggregateDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compareTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firstTransactionDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;secondSum&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aggregateDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compareTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secondTransactionDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;thirdSum&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aggregateDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compareTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thirdTransactionDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;
    &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assertEqualsBD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;110.00&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;firstSum&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assertEqualsBD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;410.00&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;secondSum&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assertEqualsBD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1610.00&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;thirdSum&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  The Business Logic Layer
&lt;/h2&gt;

&lt;p&gt;I chose to name them as the “Repository” layer in the Android app and the “Service” layer in the Spring project (confusing, I know)&lt;/p&gt;

&lt;p&gt;Tests here were mainly to confirm if any transformations I applied to the raw data were valid, for example, when I get the transaction cumulative sum by date in this chart:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0533cdp4m19509gvgths.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0533cdp4m19509gvgths.png" alt="Ignore the horrible overlapping numbers"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Since not every day within the date interval is guaranteed to have an entry, I'd like to interpolate those sub-intervals with the most recent amount so that the chart has a more natural-looking spread:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;intervalCumSumTest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;folder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="c1"&gt;//Temp folder for images&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dataBuilder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DataBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DEFAULT_PROFILE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;testUtilComponent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;user&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByUuid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TestConstants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DEFAULT_USER_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;profile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;profileRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByStringId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DEFAULT_PROFILE_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;profileId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;firstTransactionDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2025-01-02"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;secondTransactionDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2025-01-02"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;thirdTransactionDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2025-01-07"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fourthTransactionDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2025-01-09"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fifthTransactionDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2025-07-11"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;



    &lt;span class="n"&gt;dataBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransaction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Description"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"miscellaneous"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;110.00&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firstTransactionDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;dataBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransaction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Description"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"miscellaneous"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;300.00&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secondTransactionDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


    &lt;span class="n"&gt;dataBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransaction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Description"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"miscellaneous"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;450.00&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thirdTransactionDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;dataBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransaction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Description"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"miscellaneous"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;750.00&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fourthTransactionDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;dataBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransaction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Description"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"miscellaneous"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1000.00&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fifthTransactionDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;// Here, transactionService is responsible for the interpolation&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;transactionSumByDates&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transactionService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCumSumByDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profileId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2025-01-02"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;sum1&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transactionSumByDates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aggregateDate&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;firstTransactionDate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;missingSum1&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transactionSumByDates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aggregateDate&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2025-01-03"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;missingSum2&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transactionSumByDates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aggregateDate&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2025-01-08"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;missingSum3&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transactionSumByDates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aggregateDate&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"2025-01-10"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;
    &lt;span class="nf"&gt;assertEqualsBD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;410&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;sum1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assertEqualsBD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;410&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;missingSum1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assertEqualsBD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;860&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;missingSum2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assertEqualsBD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1610&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;missingSum3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Accompanying charts:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi6tprko7lhykd2avxf4b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi6tprko7lhykd2avxf4b.png" alt="Individual transactions"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg6hwf9hlmq5tdbx9t2ir.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg6hwf9hlmq5tdbx9t2ir.png" alt="Cumulative sum"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The UI Layer
&lt;/h2&gt;

&lt;p&gt;(Unique to Android)&lt;br&gt;
My tests here were a mix of input validation, such as password constraints, and proper conditional behavior, such as the correct dialog popping up for invalid PDFs, but the test I want to highlight is about character count.&lt;/p&gt;

&lt;p&gt;I went on a bit of a tangent (including but not limited to the &lt;a href="https://tonsky.me/blog/unicode/" rel="noopener noreferrer"&gt;Tonsky.me blog&lt;/a&gt;, &lt;a href="https://docs.x.com/fundamentals/counting-characters" rel="noopener noreferrer"&gt;X’s developer docs&lt;/a&gt;, &lt;a href="https://stackoverflow.com/questions/27331819/whats-the-difference-between-a-character-a-code-point-a-glyph-and-a-grapheme/27331885#27331885" rel="noopener noreferrer"&gt;StackOverflow&lt;/a&gt;), and decided that "Code Point" will be how I define what a "Character" is, and use it to determine my character limit, so my test here is to confirm that I’m counting and truncating graphemes correctly using &lt;a href="https://unicode-org.github.io/icu/userguide/icu4j/" rel="noopener noreferrer"&gt;ICU4J&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;whenUserPastesTextAndTotalLengthOverCharacterLimitThenTextTruncatedByGrapheme&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApplicationProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getApplicationContext&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ExpenseTracker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;//Assume max length is 500&lt;/span&gt;
    &lt;span class="c1"&gt;// `text` is one code point short of that limit&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CharArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MAX_NOTE_CODEPOINT_LENGTH&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sc"&gt;'a'&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;manRunning&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"\uD83C\uDFC3\u200D\u2642\uFE0F"&lt;/span&gt; &lt;span class="c1"&gt;//4 codepoints - 1 surrogate pair + 3 regular&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;manRunningCount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
    &lt;span class="c1"&gt;// Copy to clipboard&lt;/span&gt;
    &lt;span class="n"&gt;addDetailedTransactionActivityScenario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onActivity&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runOnUiThread&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;clipBoardManager&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSystemService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CLIPBOARD_SERVICE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;ClipboardManager&lt;/span&gt;
            &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;clipData&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ClipData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPlainText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"simple text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;manRunning&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;clipBoardManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setPrimaryClip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clipData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Ctrl+V&lt;/span&gt;
    &lt;span class="nf"&gt;onView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;withId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;edit_text_add_detailed_transaction_note&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;ViewActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pressKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;EspressoKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;withCtrlPressed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;withKeyCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;KEYCODE_V&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="nf"&gt;onView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;withId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;edit_text_add_detailed_transaction_note&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;noViewFoundException&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;v&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;EditText&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;codePointCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// A simpler text limiter would have kept the first part of the surrogate pair, D83C, which has no sensible meaning&lt;/span&gt;
        &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MAX_NOTE_CODEPOINT_LENGTH&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;onView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;withId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text_view_add_detailed_transaction_note_length_indicator&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;withText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${Constants.MAX_NOTE_CODEPOINT_LENGTH - 1}/${Constants.MAX_NOTE_CODEPOINT_LENGTH}"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

    &lt;span class="c1"&gt;//Similar test, but confirming with just emojis&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;menRunning&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;manRunning&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floorDiv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MAX_NOTE_CODEPOINT_LENGTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manRunningCount&lt;/span&gt;&lt;span class="p"&gt;)+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;//4 x 126 = 504&lt;/span&gt;
    &lt;span class="n"&gt;addDetailedTransactionActivityScenario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onActivity&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runOnUiThread&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;clipBoardManager&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
                &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSystemService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CLIPBOARD_SERVICE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;ClipboardManager&lt;/span&gt;
            &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;clipData&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ClipData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPlainText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"simple text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;menRunning&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;clipBoardManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setPrimaryClip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clipData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;onView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;withId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;edit_text_add_detailed_transaction_note&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;clearText&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;ViewActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pressKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;EspressoKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;withCtrlPressed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;withKeyCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;KEYCODE_V&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="nf"&gt;onView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;withId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;edit_text_add_detailed_transaction_note&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;noViewFoundException&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;v&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;EditText&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;codePointCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MAX_NOTE_CODEPOINT_LENGTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;onView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;withId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text_view_add_detailed_transaction_note_length_indicator&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;withText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${Constants.MAX_NOTE_CODEPOINT_LENGTH}/${Constants.MAX_NOTE_CODEPOINT_LENGTH}"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="nf"&gt;onView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;withId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;edit_text_add_detailed_transaction_note&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;clearText&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;And here’s the test being run by Espresso at 0.5 speed:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2qb21exygemjhpvlbhrb.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2qb21exygemjhpvlbhrb.gif" alt="The text has been cut off correctly"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Controller Layer
&lt;/h2&gt;

&lt;p&gt;(Unique to Spring)&lt;/p&gt;

&lt;p&gt;Very similar to the UI layer, it’s also mostly input validation, but instead of dialogs and error indicators, I check to see if I’m returning the correct status and custom error code:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;givenCategoryIdExistsAndCategoryIdBelongsToAnotherUserWhenCreateTransactionThenNotFound&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;user&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByUuid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TestConstants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DEFAULT_USER_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;profile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;profileRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByStringId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DEFAULT_PROFILE_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"user2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"password"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;US&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;profile2&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;profileRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByStringId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DEFAULT_PROFILE_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;accounts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;accountRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAllByProfileId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;misc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;categoryRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByProfileIdAndStringId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"miscellaneous"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;basicTransactionRequest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BasicTransactionCreateRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Description"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;misc&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Etc/UTC"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;jws&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBearerToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"username"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jwtSigningKey&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;JWT_ALG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;objectWriter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ObjectWriter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ObjectMapper&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;objectWriter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeValueAsString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;basicTransactionRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mockMvc&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;MockMvcRequestBuilders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/transactions/basic"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Bearer ${jws}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Profile-Id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stringId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;APPLICATION_JSON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;andExpectAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="c1"&gt;//Error code should be 404&lt;/span&gt;
            &lt;span class="nc"&gt;MockMvcResultMatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;isNotFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;MockMvcResultMatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jsonPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"$.$CUSTOM_PROBLEM_DETAILS_ATTRIBUTE_ERROR_CODE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
                &lt;span class="c1"&gt;//Make sure correct descriptive error code is returned&lt;/span&gt;
                &lt;span class="nc"&gt;Matchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equalTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ENTITY_NOT_FOUND&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Now that I’ve covered the various layers, I’ll talk about 3 more issues that came up while I was learning:&lt;/p&gt;
&lt;h2&gt;
  
  
  Decision Table
&lt;/h2&gt;

&lt;p&gt;I usually come up with my tests in my head, basically. I start with one possibility, like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Given Number field is empty…&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then I’d expand to another one like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Given Number field is non-positive…&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And it’s worked fine for most of my tests, but for the edit feature of Expense Tracker, this unfortunately wasn’t enough.&lt;/p&gt;

&lt;p&gt;For a brief overview, in the edit feature:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;dbId&lt;/code&gt; property determines if an item is “new” or “existing”&lt;/li&gt;
&lt;li&gt;Similarly, images also have a &lt;code&gt;dbId&lt;/code&gt; property as well as &lt;code&gt;dbIsLinked&lt;/code&gt; to indicate whether there was already an entry in the database that linked the image to the item
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;AddTransactionItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dbId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CategoryUi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AddEditTransactionFile&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;deletedDbImages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AddEditTransactionFile&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;AddEditTransactionFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dbId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;sizeBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dbIsLinked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdqmmwqlipa58ejuluyzk.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdqmmwqlipa58ejuluyzk.jpg" alt="Sample edit screen"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In this example screen, the first item is from the database, and the orange has a non-null &lt;code&gt;dbId&lt;/code&gt; and &lt;code&gt;dbIsLinked&lt;/code&gt; is set to true, while the second item is new and so has a null &lt;code&gt;dbId&lt;/code&gt; .&lt;/p&gt;

&lt;p&gt;My main issue was knowing what to do when saving the changes of that edit. I needed to know how to deal with images that satisfied my main constraints:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If the same image is added to more than one item, then simply link them instead of adding duplicates&lt;/li&gt;
&lt;li&gt;If the image has been removed from an item, and that was the last item it was linked to, remove it from the user’s device&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Coming up with test cases for this felt complicated, simply put. I looked at Karnaugh maps and Finite State machines because I felt they would help out, but in the end, I settled for something that loosely resembles a truth table, which I later found to be called a &lt;a href="https://en.wikipedia.org/wiki/Decision_table" rel="noopener noreferrer"&gt;Decision table&lt;/a&gt;.&lt;br&gt;
I itemized my variables like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the item new or in the database?&lt;/li&gt;
&lt;li&gt;Has the item been marked as deleted?&lt;/li&gt;
&lt;li&gt;Is the image new or in the database?&lt;/li&gt;
&lt;li&gt;Has the image been marked deleted?&lt;/li&gt;
&lt;li&gt;Does the image have a database link to the item?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With a clear idea of what they were, I could now go through all the possibilities. Since there are 5 variables in total, there are 2⁵=32 possibilities*&lt;br&gt;
(* It’s possible for the variable to have more than 2 states, but I haven’t encountered a case like that yet)&lt;br&gt;
For the sake of brevity, I’ll show the first 8 rows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7fcro3ijtr061qzy921c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7fcro3ijtr061qzy921c.png" alt="Partial decision table"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rows 3 and 5 are marked “Impossible” because of the condition:
not( ImageInDb ) and ImageLinked&lt;/li&gt;
&lt;li&gt;Similarly, 7 and 9 are marked that way because of
not( ItemInDb ) and ImageLinked&lt;/li&gt;
&lt;li&gt;Rows 4 and 8 don’t need any action because I don’t care about images that won’t be linked to an item&lt;/li&gt;
&lt;li&gt;Row 1 means that I have to persist the image to the database first before I create a link to the item, and row 6 means I skip the first part&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The edit feature so far has been the only time where I’ve really needed the table, but I’m glad I got to use it.&lt;/p&gt;

&lt;p&gt;Overall, this method gave me some notable benefits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A way to mark impossible states and relevant states. In my case, I only needed to use 8 of the 32 rows, but I got to know which ones were which&lt;/li&gt;
&lt;li&gt;Those 8 rows can each become a unit test&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Seeding Data
&lt;/h2&gt;

&lt;p&gt;As the number of my tests grew, I found myself repeatedly having to put custom entries in the database through the repository methods. This ended up taking up a good portion of my test code in some cases.&lt;/p&gt;

&lt;p&gt;I wanted something that would simplify this process so that I could focus on testing outcomes instead of creating data. The idea mainly came from the  &lt;a href="https://github.com/OpenAPITools/openapi-generator" rel="noopener noreferrer"&gt;OpenAPI generator&lt;/a&gt; code base, where I did a bit of &lt;a href="https://github.com/OpenAPITools/openapi-generator/pull/21278" rel="noopener noreferrer"&gt;work&lt;/a&gt;. They use various utilities such as &lt;code&gt;CodegenConfigurator&lt;/code&gt; to make it so that configuration doesn’t take as long. After some searching, one of my main helps was this article from Lukas Eder of jOOQ:&lt;br&gt;


&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://blog.jooq.org/the-java-fluent-api-designer-crash-course/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fblog.jooq.org%2Fwp-content%2Fuploads%2F2012%2F01%2Fsimple-grammar.png" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://blog.jooq.org/the-java-fluent-api-designer-crash-course/" rel="noopener noreferrer" class="c-link"&gt;
            The Java Fluent API Designer Crash Course – Java, SQL and jOOQ.
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Ever since Martin Fowler’s talks about fluent interfaces, people have started chaining methods all over the place, creating fluent API’s (or DSLs) for every possible use case. In princi…
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fi0.wp.com%2Fblog.jooq.org%2Fwp-content%2Fuploads%2F2021%2F08%2Fjooq-logo-white-750x750-padded.png%3Ffit%3D32%252C32%26ssl%3D1"&gt;
          blog.jooq.org
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;





&lt;p&gt;And I ended up coming up with my own basic DSL to help me out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Commit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/**
     * Returns the list of transaction IDs
     */&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;TransactionBuilder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Commit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;withItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;TransactionBuilder&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;withItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;TransactionBuilder&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;withDefaultItemPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;defaultPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;TransactionBuilder&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;withNewAccount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currencyCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accountName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;TransactionBuilder&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;debitOrCredit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;debitOrCredit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;TransactionBuilder&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;withDetailedItem&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;TransactionItemBuilder&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;repeatIntoDates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;):&lt;/span&gt; &lt;span class="nc"&gt;TransactionBuilder&lt;/span&gt;

    &lt;span class="cm"&gt;/**
     * Inclusive, Inclusive
     */&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;repeatIntoDateRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;TransactionBuilder&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;atDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;TransactionBuilder&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;TransactionItemBuilder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Commit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;mainDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;TransactionItemBuilder&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;otherDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;referenceNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?):&lt;/span&gt; &lt;span class="nc"&gt;TransactionItemBuilder&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;withImages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;vararg&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TestData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;TransactionItemBuilder&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;TransactionBuilder&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Relevant commit &lt;a href="https://github.com/DavidGrath/Expense-Tracker/commit/8e59a81872a07ad7e60e40425d0631d5904e722e#diff-ede273dcdf07727fe619d774ced946df49ea8ba3245ad7e0824b5896708724c1" rel="noopener noreferrer"&gt;link&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And in a test to verify that I’m fetching the statistics for accounts correctly, here’s an example where I make data seeding slightly more readable:&lt;br&gt;


&lt;/p&gt;
&lt;div class="ltag_gist-liquid-tag"&gt;
  
&lt;/div&gt;





&lt;h2&gt;
  
  
  Setup and Teardown
&lt;/h2&gt;

&lt;p&gt;For setup, I have utility methods to insert basic data for a default profile.&lt;/p&gt;

&lt;p&gt;For teardown, I chose to use an approach where I essentially delete the whole database when a test is finished. In Android, it’s simple with &lt;a href="https://developer.android.com/reference/androidx/room/RoomDatabase#clearAllTables()" rel="noopener noreferrer"&gt;Room&lt;/a&gt;’s &lt;code&gt;clearAllTables&lt;/code&gt; , but in the backend, there’s no direct utility method I found, so instead I did this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generated documentation with &lt;a href="https://schemaspy.org/" rel="noopener noreferrer"&gt;SchemaSpy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Looked at the &lt;code&gt;deletionOrder.txt&lt;/code&gt; file&lt;/li&gt;
&lt;li&gt;Wrote &lt;code&gt;DELETE&lt;/code&gt; methods that corresponded to each table and ran them in the same order&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://testcontainers.com/" rel="noopener noreferrer"&gt;Testcontainers&lt;/a&gt; are probably a better way to handle this, and Spring’s Transactional annotation as well, but I’m sticking with the current method since it’s not too much trouble&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Overall, I’ve gained some key things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A framework for identifying edge cases — even the rare ones — ensuring they are handled by design rather than by accident.&lt;/li&gt;
&lt;li&gt;Increased confidence that when I make any changes to a complicated feature like transaction editing, I likely haven’t broken existing behavior&lt;/li&gt;
&lt;li&gt;A shorter feedback loop&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are also other kinds of tests, like end-to-end, acceptance, and integration tests, but I think those topics are different enough to not have to discuss here, especially since the meaning of some can change significantly per environment. I do talk about performance testing, though, but that’s for later.&lt;/p&gt;

&lt;p&gt;Now that I’ve learned how to automate my tests, next I’ll talk about how I’ve learned to automate my deployments using Continuous Integration with Jenkins.&lt;/p&gt;

&lt;p&gt;Please share your experiences with how testing and test culture is (or is not) implemented in your own code bases in the comments.&lt;/p&gt;

&lt;p&gt;Thank you for your time.&lt;/p&gt;

</description>
      <category>tdd</category>
      <category>testing</category>
      <category>java</category>
      <category>kotlin</category>
    </item>
    <item>
      <title>Building a Personal Expense Tracker with OpenTelemetry and CI/CD</title>
      <dc:creator>David</dc:creator>
      <pubDate>Wed, 25 Feb 2026 23:11:52 +0000</pubDate>
      <link>https://dev.to/davidgrath/building-a-personal-expense-tracker-with-opentelemetry-and-cicd-5865</link>
      <guid>https://dev.to/davidgrath/building-a-personal-expense-tracker-with-opentelemetry-and-cicd-5865</guid>
      <description>&lt;p&gt;(Originally posted on &lt;a href="https://medium.com/@DavidGrath0/building-a-personal-expense-tracker-with-opentelemetry-and-ci-cd-803f2dc9e622" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;)&lt;br&gt;
I decided to make a personal app to track my expenses. Spreadsheets served me well, but there are some core features that I couldn’t get out of that &lt;br&gt;
method:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The ability to attach receipts to my records&lt;/li&gt;
&lt;li&gt;The ability to add images of the items I purchase&lt;/li&gt;
&lt;li&gt;Viewing and filtering the statistics across any time period without needing new PivotTables&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are excellent apps that already exist for this, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/jameskokoska/Cashew" rel="noopener noreferrer"&gt;Cashew&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://play.google.com/store/apps/details?id=com.seshadri.padmaja.expense&amp;amp;pcampaignid=web_share" rel="noopener noreferrer"&gt;Day-to-Day&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://play.google.com/store/apps/details?id=com.easyexpense&amp;amp;pcampaignid=web_share" rel="noopener noreferrer"&gt;Easy Expense&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Though none of them fit exactly what I wanted. I could have kept searching, but beyond the need for a custom solution, I also saw the opportunity to challenge myself to learn certain topics, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unit Testing&lt;/strong&gt;: with the Android and Spring frameworks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continuous Integration and Continuous Delivery&lt;/strong&gt;: with Jenkins&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability&lt;/strong&gt;: with OpenTelemetry and the Grafana stack&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance Testing&lt;/strong&gt;: with JMeter&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure&lt;/strong&gt;: including Ansible, NGINX, and Linux concepts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And so I decided to go ahead and build the app for myself&lt;/p&gt;

&lt;h2&gt;
  
  
  App Structure
&lt;/h2&gt;

&lt;p&gt;There are 2 versions of the app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Expense Tracker: offline, the first version of the app, and essentially my template for the next version. The source is available &lt;a href="https://github.com/DavidGrath/Expense-Tracker" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;ExTrack: the full client-server version, split into the Java backend and the native Android version&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Entities
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Transaction: made up of one or more items, bound to an account, and has a currency code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fan1net59pt92vcqrt5cu.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fan1net59pt92vcqrt5cu.jpg" alt="Transaction with a single item" width="720" height="1600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Transaction items have a description, an amount, a category for classification, and images&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjlved67afi8zbj3ls6vg.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjlved67afi8zbj3ls6vg.jpg" alt="Transaction draft with item details" width="720" height="1600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Other entities include Category, Account, Document, and Image.&lt;br&gt;
The basic use cases include adding transactions and viewing statistics:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fme2b18k9otvlbq30s6k5.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fme2b18k9otvlbq30s6k5.jpg" alt="Add basic transaction" width="720" height="1600"&gt;&lt;/a&gt;&lt;br&gt;
Statistics screen:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbmd2vjjsmealoow3t5ll.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbmd2vjjsmealoow3t5ll.jpg" alt="Statistics screen" width="720" height="1600"&gt;&lt;/a&gt;&lt;br&gt;
This screen is loosely based on &lt;a href="https://play.google.com/store/apps/details?id=com.razeeman.util.simpletimetracker&amp;amp;pcampaignid=web_share" rel="noopener noreferrer"&gt;Simple Time Tracker&lt;/a&gt;'s stats screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;Most of this was done on my local network.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tech Stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend&lt;/strong&gt;: Java 17, Spring Boot, jOOQ, OpenAPI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability&lt;/strong&gt;: Grafana Loki 3.2, Grafana 11, Grafana Tempo 2.6, Prometheus 3.0, Grafana Pyroscope 1.18&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure&lt;/strong&gt;: Jenkins, NGINX 1.28, MySQL 8.0, various Prometheus exporters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operating Systems&lt;/strong&gt;: Ubuntu 22, Windows 11, Windows 10&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh6wxvzt7jlcelp31b3ii.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh6wxvzt7jlcelp31b3ii.png" alt="Overview diagram" width="800" height="682"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  How it fits together
&lt;/h3&gt;

&lt;p&gt;Prometheus reads metrics from a wide variety of exporters that I use. Promtail scrapes log files and sends them to Loki. Tempo is for distributed tracing. Using OpenTelemetry, all 3 signals are sent through NGINX to Alloy, and I visualize everything in Grafana. Pyroscope is a special case that I'll discuss in a separate article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The next few articles will go into detail on how I learned each topic with the app, with the first 2 covering how I automated my testing and deployment processes.&lt;/p&gt;

&lt;p&gt;Let me know your thoughts about this new series in the comments. I'll be cross-posting the remaining articles from Medium gradually.&lt;/p&gt;

&lt;p&gt;Thank you for your time&lt;/p&gt;

</description>
      <category>tdd</category>
      <category>java</category>
      <category>android</category>
      <category>sre</category>
    </item>
  </channel>
</rss>
