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.
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.
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.
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:
- Three separate OpenTelemetrySdk instances are created (one per module:
catalog,cart,checkout) - Each instance has its own TracerProvider with a module-specific Resource
- Each Resource has a unique
service.name:astronomy-shop-catalogastronomy-shop-cartastronomy-shop-checkout
- 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
Service map showing the three virtual services and their interactions
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.
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.
The easiest way to run everything together:
docker-compose up --buildThis starts:
- The application on http://localhost:8080
- OpenTelemetry Collector
- Jaeger UI on http://localhost:16686
- A load generator for continuous traffic
See DOCKER.md for detailed Docker Compose instructions.
- Java 21 installed
- OpenTelemetry Collector running and configured to receive OTLP data on
http://localhost:4317(gRPC) orhttp://localhost:4318(HTTP)
-
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
-
Build and run the application:
./gradlew bootRun
-
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
-
View traces in Jaeger:
- Open http://localhost:16686
- You should see three separate services in the service dropdown:
astronomy-shop-catalogastronomy-shop-cartastronomy-shop-checkout
- Select a service to see traces for that module
- Service maps will show relationships between the virtual services
Trace timeline showing spans from multiple virtual services (checkout and cart) in a single request
-
View collector logs:
docker-compose logs -f otel-collector
This project is licensed under the MIT License - see the LICENSE file for details.

