Observability in Action Part 2: Enhancing Your Codebase with OpenTelemetry
A Step-by-Step Guide to Building a Kotlin Service for Cat Facts API
TL;DR: Enhancing your codebase with OpenTelemetry involves building a service that returns cat facts, saves them to a database, and sends them back to the caller. This is demonstrated using a Spring-based service, loading facts into a request context, storing and reading facts, Dockerizing the project, and setting up docker-compose for easy deployment.
Welcome to my series on Observability in Action. In this series, I explore various aspects of enhancing your codebase with modern observability techniques. If you're new to the series, I highly encourage you to check out our previous articles to get a comprehensive understanding of the topics covered:
Series Outline
Part 2 - Build the Service ⬅ You are here
Part 4 - Integrate the OpenTelemetry Collector
All code examples for this series are available on GitHub:
- All code examples related to this service are available here on the branch
business-logic-implementation
Building the Service
The service includes the following features:
Each request returns a specified number of cat facts (between 1-10) using the
cat-fact-client
library, If not specified, the default is 5 factsEach fact is saved to the database unless it's already stored.
The retrieved facts are then sent back to the caller.
You might wonder, "When would I ever need this?!" This is primarily a demonstrative use case. Imagine a scenario where instead of accessing a facts API, you're calling a user management service to ensure a user's context is saved in the request.
Various methods can deliver this functionality. Here, we will utilize Spring's @Scope
for a bean that's instantiated for every new service request.
Adding Dependencies
To integrate our library, append the following dependency to the project:
repositories {
maven {
url = uri("https://maven.pkg.github.com/ForkingGeniuses/cat-fact-client")
credentials {
username = project.findProperty("gpr.user")?.toString() ?: System.getenv("GITHUB_ACTOR")
password = project.findProperty("gpr.key")?.toString() ?: System.getenv("GITHUB_TOKEN")
}
}
}
Ensure you have a PAT (Personal Access Token) with the read:packages
privilege to access the library. Detailed instructions on generating a PAT can be found here.
Subsequently, insert the dependency into the project:
dependencies {
implementation("com.yonatankarp:cat-fact-client:0.2.0")
}
After refreshing your Gradle project, if configured appropriately, the library should be available.
Loading the Facts
We begin by defining the RequestContext
interface, which will load facts for each request. Spring will use its implementation, RequestContextImpl
, to inject facts into the controller.
/**
* Provides facts about cats into the request context.
*
* This interface is used by SpringBoot to inject the user context into
* the controller for each call made for the service.
*/
interface RequestContext {
var facts: Set<Fact>?
}
/**
* The implementation of the [RequestContext] interface. Used by
* SpringBoot to populate the facts into the request context.
*/
open class RequestContextImpl(
override var facts: Set<Fact>? = null
) : RequestContext
We will now configure our context to be included with each request arriving at the service. Note that we're using Jackson's ObjectMapper
for the cat-fact-client
library, as this is the default serialization library for Spring. Moreover, the @Scope
annotation requires a none-suspended function, and therefore we have to use the runBlocking{}
to bridge between the library and our service.
@Configuration
class ApplicationConfiguration {
/**
* Creates a new instance of the [CatFactProvider] that will be
* used to fetch the facts about cats.
*/
@Bean
fun catFactProvider(objectMapper: ObjectMapper): CatFactProvider =
CatFactFactory.getInstance(ProviderType.API, objectMapper)
/**
* Creates a new instance of the [RequestContext] that will be
* used to inject the facts into the request context.
*/
@Bean
@Scope(
WebApplicationContext.SCOPE_REQUEST,
proxyMode = ScopedProxyMode.INTERFACES
)
fun requestContext(
catFactProvider: CatFactProvider
): RequestContext = runBlocking {
RequestContextImpl(catFactProvider.get(getMaxFactsNumber()))
}
/**
* Returns the maximum number of facts that should be returned
* to the caller, or the default value if not specified.
*/
private fun getMaxFactsNumber(): Int {
val servletRequestAttributes =
RequestContextHolder.getRequestAttributes() as ServletRequestAttributes
return servletRequestAttributes
.request
.getParameterValues("max")
?.first()
?.toInt()
?.coerceIn(MIN_FACTS_NUMBER, MAX_FACTS_NUMBER)
?: DEFAULT_FACTS_NUMBER
}
companion object {
private const val DEFAULT_FACTS_NUMBER = 5
private const val MIN_FACTS_NUMBER = 1
private const val MAX_FACTS_NUMBER = 10
}
}
Storing and Reading Facts
With the facts now in the request context, they can be utilized in our controller.
@RestController
class CatFactController(
// Would be automatically injected by Spring
private val requestContext: RequestContext,
private val catFactService: CatFactService,
) {
@GetMapping("/api/v1/cat/facts")
suspend fun getCatFacts(): ResponseEntity<FactsResponse> {
val facts = requestContext.facts ?: throw RuntimeException("Could not read facts")
catFactService.storeFacts(facts)
return ok(facts.toResponse())
}
}
private fun Set<Fact>.toResponse() = FactsResponse(this)
data class FactsResponse(val facts: Set<Fact>)
Our relatively straightforward service merely iterates over the available facts, storing each in turn.
@Service
class CatFactService(private val repository: CatFactRepository) {
suspend fun storeFacts(facts: Set<Fact>) =
facts.forEach { repository.storeFacts(it) }
}
The remaining step is saving our facts to the database. We'll use Flyway for database migrations and JOOQ for database interaction. Begin by establishing a table in the database.
Add the following SQL script to the src/main/resources/db/migration/V1.0.0__init_db.sql
folder:
CREATE TABLE cat_facts (
hash INT PRIMARY KEY,
fact TEXT NOT NULL
);
The facts lack unique identifiers, so we'll use the fact's hash to check if it's already in the database. Although this method isn't ideal, it suits our needs.
Lastly, we'll develop a repository to save the facts in the database. This straightforward repository will add facts and skip those already present.
@Repository
class CatFactRepository(private val jooq: DSLContext) {
suspend fun storeFacts(fact: Fact) =
with(Tables.CAT_FACTS) {
jooq.insertInto(this, HASH, FACT)
.values(fact.hashCode(), fact.value)
.onConflict(HASH)
.doNothing()
.execute()
}
}
Dockerizing the Project
To replicate our services' production behavior, we'll use Docker to create an image, subsequently testing our service.
FROM --platform=linux/x86_64 eclipse-temurin:17-jre-alpine
ENV APP_BASE="/home" \
APP_NAME="cat-fact-service" \
SERVER_PORT="8080"
EXPOSE ${SERVER_PORT}
# Install helper tools for debugging
RUN apk update && apk upgrade && apk add curl openssl gcompat bash busybox-extras iputils
RUN mkdir -p ${APP_BASE}/${APP_NAME}
COPY build/libs/${APP_NAME}*.jar ${APP_BASE}/${APP_NAME}.jar
CMD java -jar ${APP_BASE}/${APP_NAME}.jar
To verify your project's functionality, execute:
$ ./gradlew assemble
$ docker build -t cat-fact-service .
You should be output similar to the following:
[+] Building 2.3s (10/10) FINISHED docker:desktop-linux
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 453B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/eclipse-temurin:17-jre-alpine 1.5s
=> [auth] library/eclipse-temurin:pull token for registry-1.docker.io 0.0s
=> [1/4] FROM docker.io/library/eclipse-temurin:17-jre-alpine@sha256:e90e0d654765ab3ae33f5c5155daafa4a907d0d738ce98c3be8f402a8edcee2b 0.0s
=> [internal] load build context 0.6s
=> => transferring context: 82.75MB 0.6s
=> CACHED [2/4] RUN apk update && apk upgrade && apk add curl openssl gcompat bash busybox-extras iputils 0.0s
=> CACHED [3/4] RUN mkdir -p /home/cat-fact-service 0.0s
=> [4/4] COPY build/libs/cat-fact-service*.jar /home/cat-fact-service.jar 0.1s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:0b65dc5d8e98c9b095ce4bd38ab28eae8320ff1cfb089b59b039d3753cf6ec45 0.0s
=> => naming to docker.io/library/cat-fact-service
Setting up docker-compose
While the service is operational, the database setup remains. Docker-compose will establish both the database and the service.
At your project's root, create a new file named docker-compose.yml
with the following content:
version: '3'
services:
cat-fact-service:
container_name: cat-fact-service
networks:
- proxynet
build: ../..
ports:
- "8080:8080"
depends_on:
- postgres
environment:
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=facts
- DB_USER=postgres
- DB_PASSWORD=secret
postgres:
container_name: cat-fact-service-postgres
networks:
- proxynet
image: postgres:14
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
POSTGRES_DB: facts
ports:
- "5432:5432"
networks:
proxynet:
name: cat-fact-service-network
Running the Service
You can now launch and assess the service by executing:
$ docker-compose up
Once Spring notifies you that the service is operational, access it via your browser at http://localhost:8080/api/v1/cat/facts.
If executed correctly, the response should resemble:
To retrieve a custom number of facts, use the max
query parameter. For ten facts, navigate to http://localhost:8080/api/v1/cat/facts?max=10.
Inspecting the database will confirm the successful storage of facts.
Our service logic is ready!
Alternatively, you can pull the docker image that I have created from GHCR by changing your docker-compose file as follows:
version: '3'
services:
cat-fact-service:
container_name: cat-fact-service
image: ghcr.io/yonatankarp/cat-fact-service:latest
networks:
- proxynet
ports:
- "8080:8080"
depends_on:
- postgres
environment:
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=facts
- DB_USER=postgres
- DB_PASSWORD=secret
postgres:
container_name: cat-fact-service-postgres
networks:
- proxynet
image: postgres:14
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
POSTGRES_DB: facts
ports:
- "5432:5432"
networks:
proxynet:
name: cat-fact-service-network
Conclusion
In this part of the Observability in Action series, we demonstrated how to build a Spring-based service that returns cat facts, saves them to a database, and sends them back to the caller. We covered loading facts into a request context, storing and reading facts, Dockerizing the project, and setting up docker-compose for easy deployment. Stay tuned for the upcoming parts of the series, where we will explore instrumenting the service, integrating OpenTelemetry Collector, and more.
Acknowledgments
- Mariusz Sołtysiak - for moral support, review, and suggestions while writing this series of articles.