svrnm/monolith-observability

This is an experiment on splitting out a monolithic application into multiple "virtual services" in OpenTelemetry

★ 1Forks 0JavaGitHub ↗Compare

README

Monolith Observability: Virtual Services Experiment

This is an experiment on splitting out a monolithic application into multiple "virtual services" in OpenTelemetry for observability purposes.

Note

The code for this project has been AI generated, it's not intended for any use beyond experimentation.

The Problem

Most (if not all) observability solutions that consume OpenTelemetry data are built to visualize microservices architecture. These tools typically rely on the service.* resource attributes to identify and group services, creating service maps, dependency graphs, and service-level dashboards.

When you have a monolithic application, all telemetry data is associated with a single service resource, making it difficult to:

  • Visualize internal module boundaries
  • Create service maps showing relationships between modules
  • Apply service-level filtering and grouping in observability backends
  • Use tools designed for microservices architecture

While some backends might support using other resource attributes for splitting views, the service.name attribute is the de facto standard that most tools expect.

The Experiment

This project demonstrates a workaround: using multiple TracerProvider instances to create separate resources for different modules within a single monolithic application. Each module gets its own service.name, making it appear as a separate "virtual service" to observability backends.

How It Works

According to the OpenTelemetry Trace SDK specification:

Notwithstanding any global TracerProvider, some applications may want to or have to use multiple TracerProvider instances, e.g. to have different configuration (like SpanProcessors) for each (and consequently for the Tracers obtained from them), or because it's easier with dependency injection frameworks. Thus, implementations of TracerProvider SHOULD allow creating an arbitrary number of TracerProvider instances.

Similarly, a Resource is defined as "a representation of the entity producing telemetry" - which is intentionally loosely defined.

In this application:

  1. Three separate OpenTelemetrySdk instances are created (one per module: catalog, cart, checkout)
  2. Each instance has its own TracerProvider with a module-specific Resource
  3. Each Resource has a unique service.name:
    • astronomy-shop-catalog
    • astronomy-shop-cart
    • astronomy-shop-checkout
  4. Services inject their module-specific OpenTelemetry instance to create traces

This allows observability backends to treat each module as a separate service, enabling:

  • Service maps showing relationships between modules
  • Service-level filtering and dashboards
  • Better visualization of internal architecture

System Architecture View

Service map showing the three virtual services and their interactions

⚠️ Important Disclaimer

This is a hack/workaround and not recommended best practice.

The proper way to achieve this kind of split would be using Instrumentation Scope, which is a better equivalent to represent different components or modules within a service. However, as far as I know, no observability tools currently support using instrumentation scope for service-level visualization and grouping.

Alternative Solutions

The ideal solution would be for observability backends to support:

  • Using instrumentation scope attributes for service-level visualization
  • Configurable ways to split "virtual services" from instrumentation scope
  • More flexible data flow maps that don't rely solely on service.name

Until such support exists, this multiple-TracerProvider approach provides a pragmatic workaround for monoliths that want to leverage microservices-focused observability tools.

Running the Application

Option 1: Docker Compose (Recommended)

The easiest way to run everything together:

docker-compose up --build

This starts:

See DOCKER.md for detailed Docker Compose instructions.

Option 2: Local Development

Prerequisites

  1. Java 21 installed
  2. OpenTelemetry Collector running and configured to receive OTLP data on http://localhost:4317 (gRPC) or http://localhost:4318 (HTTP)

Quick Start

  1. Start OpenTelemetry Collector (if not already running):

    # Example using Docker
    docker run -p 4317:4317 -p 4318:4318 \
      -v $(pwd)/otel-collector-config.yaml:/etc/otelcol/config.yaml \
      otel/opentelemetry-collector:latest
  2. Build and run the application:

    ./gradlew bootRun
  3. Test the API:

    # List products
    curl http://localhost:8080/products
    
    # Get product details
    curl http://localhost:8080/products/telescope-1
    
    # Add to cart
    curl -X POST http://localhost:8080/cart/items \
      -H "Content-Type: application/json" \
      -d '{"productId": "telescope-1", "quantity": 1}'
    
    # View cart
    curl http://localhost:8080/cart
    
    # Checkout
    curl -X POST http://localhost:8080/checkout

Observing Telemetry

With Docker Compose

  1. View traces in Jaeger:

    • Open http://localhost:16686
    • You should see three separate services in the service dropdown:
      • astronomy-shop-catalog
      • astronomy-shop-cart
      • astronomy-shop-checkout
    • Select a service to see traces for that module
    • Service maps will show relationships between the virtual services

    Trace Waterfall View

    Trace timeline showing spans from multiple virtual services (checkout and cart) in a single request

  2. View collector logs:

    docker-compose logs -f otel-collector

License

This project is licensed under the MIT License - see the LICENSE file for details.

Contributors

svrnm

Issues