Observability in Action Part 2: Enhancing Your Codebase with OpenTelemetry

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

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

A cat wearing a hard hat with the word cat on it

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 facts

  • Each 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

loading screen nyan cat

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

russian blue cat in brown cardboard box

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

blue and red cargo ship on sea during daytime

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!

International Cat Day

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.

Did you find this article valuable?

Support Yonatan Karp-Rudin by becoming a sponsor. Any amount is appreciated!