Skip to content

Repository files navigation

Pulse — OpenTelemetry Observability Stack

Docker Compose OpenTelemetry Prometheus Grafana Jaeger OpenSearch License: MIT

A self-hosted, OpenTelemetry-native observability stack that runs with a single docker compose up. It gives any application metrics, traces and logs — correlated by trace ID — without touching its source code.

Pulse was designed as a software architecture exercise: the interesting part is not that Grafana draws charts, but where the boundaries sit. Applications know exactly one protocol (OTLP). Everything behind the Collector — the time-series database, the trace store, the log store — is an implementation detail that can be swapped without redeploying a single service.


Architecture at a glance

System architecture

Four layers, one direction of dependency:

Layer Responsibility Replaceable?
Application Emit telemetry via OTLP. Instrumented by the OTel Java Agent — no application code involved. The app is the only thing that isn't.
Collection Receive, limit, enrich, filter, batch, and fan out to backends. Single point of policy. Yes — the Collector is a standard binary driven by one YAML file.
Storage One store per signal, each picked for its query model (PromQL / span search / full-text). Yes — Prometheus → Mimir, Jaeger → Tempo, OpenSearch → Loki, one exporter line each.
Presentation Grafana as the single pane of glass; the native UIs stay available for deep dives. Yes — it only reads.

The key architectural property: the application layer has exactly one outbound dependency, and it is a CNCF standard rather than a vendor SDK. Everything to the right of the Collector is negotiable.


The telemetry pipeline

Collector pipelines

One OTLP receiver feeds three independent pipelines. Processor order is deliberate and is where most real-world Collector configs go wrong:

  1. memory_limiter first — it must be able to reject work before anything else allocates.
  2. resource — enrich while the data is still small (adds deployment.environment, observability.stack).
  3. filter/logs — drop below-INFO records before batching, so the batcher never packs data that gets thrown away.
  4. batch last — always. Batching before a filter or a limiter wastes the work it just did.

Each pipeline ends in the exporter that speaks the backend's native protocol (prometheusremotewrite, otlp/jaeger, opensearch). See collector/otel-collector-config.yml.


Deployment topology

Deployment topology

Eight containers on one bridge network: six core services plus two that only exist in the demo overlay. Configuration lives in git and is bind-mounted read-only; mutable state lives in named volumes. A clean checkout plus docker compose up -d reproduces the entire stack, dashboards included — there is no manual setup step and no click-ops.


How one request becomes three correlated signals

Signal correlation

This is the part that makes the stack worth more than the sum of its tools. The agent propagates one W3C trace context and stamps it on all three signals, so a latency spike in a Grafana panel, a failing span in Jaeger, and an ERROR log line in OpenSearch are three views of the same request — reachable from one another instead of correlated by squinting at timestamps.


Quick start

git clone https://github.com/TheLoop705/pulse-observability.git
cd pulse-observability && cp .env.example .env && docker compose up -d

Open Grafana at http://localhost:3000 (admin / pulse). Datasources and dashboards are already provisioned.

Service URL Credentials
Grafana localhost:3000 admin / pulse
Prometheus localhost:9090
Jaeger localhost:16686
OpenSearch Dashboards localhost:5601

Demo mode

Starts a Spring Boot order API plus a load generator that produces realistic traffic — including the ~20 % notification failures that make the error paths in the dashboards light up:

docker compose -f docker-compose.yml -f docker-compose.demo.yml up -d --build

Within about a minute every dashboard shows live data. The demo app is at localhost:8080; see demo/README.md for the endpoints.


Dashboards

Five dashboards are provisioned from JSON at startup — no manual import, and they are diffable in code review.

Dashboard What it answers
System Overview Is the system healthy right now? Request rate, error rate, P50/P95/P99, active services.
HTTP Requests Which endpoint is slow, and with which status codes? Duration histograms, per-route latency.
JVM Metrics Is the runtime the problem? Heap vs. committed vs. max, GC pauses, threads, class loading.
Trace Analysis Where did the time actually go? Span metrics, durations, error spans, links into Jaeger.
Log Analysis What was logged around the incident? Volume, severity mix, pipeline health, links into OpenSearch.

System Overview dashboard

HTTP Requests dashboard


Instrumenting your own application

Java needs no code changes at all — attach the agent and point it at the Collector:

services:
  my-app:
    environment:
      - OTEL_SERVICE_NAME=my-service
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
      - JAVA_TOOL_OPTIONS=-javaagent:/app/opentelemetry-javaagent.jar
    networks:
      - pulse-network

HTTP metrics, JVM metrics, traces and log forwarding start flowing immediately. Node.js, Python, Go and .NET follow the same pattern with their own agents or SDKs — see Instrumenting Your App.


Design decisions and trade-offs

Decision Alternatives considered Why this one
Collector as the only ingest path Apps write to backends directly Sampling, filtering and enrichment become a config change instead of a redeploy of every service. One place to reason about telemetry cost.
One store per signal A single store for everything Each signal has a different query model. Forcing metrics into a document store (or logs into a TSDB) trades a good fit for a uniform one.
Prometheus remote-write, not scraping Prometheus scrapes the Collector Short-lived and batch workloads never live long enough to be scraped; push keeps the ingest path uniform for every workload type.
OpenSearch for logs Elasticsearch, Loki Apache-2.0 licensed with no license reversal risk, and full-text search out of the box. Loki is cheaper but expects label-first querying.
Auto-instrumentation over manual SDK calls Hand-written spans Coverage on day one and no instrumentation code to maintain. Custom business spans can still be added where they earn their keep.
Docker Compose, not Kubernetes Helm chart on k3s The architecture is identical; Compose keeps the reviewable surface small. The Collector config transfers unchanged to the official Helm chart.

Known limitations

Honest scope boundaries — this is a reference architecture, not a production deployment:

  • No authentication or TLS between components. The bridge network is treated as trusted; a production deployment needs mTLS on OTLP and real Grafana auth (anonymous viewer access is enabled here on purpose).
  • Single-node backends. OpenSearch runs discovery.type=single-node with security disabled; Jaeger uses all-in-one in-memory storage. Both are demo-grade and lose data on restart.
  • No tail-based sampling. Every span is exported. That is fine at demo volume and expensive at real volume — the fix is a tail_sampling processor, which is exactly the kind of change the Collector layer exists to absorb.
  • Alert rules are defined but not routed. prometheus/alert-rules.yml contains working rules; wiring an Alertmanager receiver is left out deliberately.

Configuration

Everything is driven by .env (start from .env.example):

Variable Default Description
GRAFANA_ADMIN_PASSWORD pulse Grafana admin password
PROMETHEUS_RETENTION 15d How long Prometheus keeps metrics
OPENSEARCH_JAVA_OPTS -Xms512m -Xmx512m OpenSearch heap size

Ports for every service are configurable too. Full reference: Configuration Guide.

Helper scripts

./scripts/setup.sh        # First-time setup (check prerequisites, pull images)
./scripts/start.sh        # Start the core stack
./scripts/demo.sh         # Stack + demo app + load generator
./scripts/health-check.sh # Verify every service is up
./scripts/stop.sh         # Stop everything

Repository layout

pulse-observability/
├── docker-compose.yml           # Core stack — 6 services
├── docker-compose.demo.yml      # Demo overlay — app + load generator
├── collector/                   # OTel Collector pipeline config
├── prometheus/                  # Scrape config + alert rules
├── grafana/provisioning/        # Datasources + 5 dashboards as JSON
├── opensearch/                  # Node config + log index template
├── jaeger/                      # Jaeger notes
├── demo/
│   ├── spring-boot-app/         # Java 21 / Spring Boot 3 order API
│   └── load-generator/          # Traffic generator
├── docs/
│   ├── architecture.md          # Deep dive
│   ├── diagrams/                # Architecture diagrams (.excalidraw + .svg)
│   └── images/                  # Dashboard screenshots
├── scripts/                     # Setup, start, stop, demo, health check
└── tools/diagrams/              # Diagram generator (source of truth for docs/diagrams)

Diagrams

The four diagrams are generated from code so they never drift from the stack they describe:

node tools/diagrams/build.mjs

Each diagram is emitted twice — as .excalidraw (open and edit at excalidraw.com) and as .svg for embedding. Details in docs/diagrams/README.md.


Documentation

Background

Pulse was built as part of a Bachelor thesis on OpenTelemetry-based observability architectures. The research question — how far can you get on standard protocols and zero-code instrumentation? — is what shaped the layering above. See Thesis Context.

License

MIT

About

Composable OpenTelemetry reference environment for traces, metrics, logs, and local platform experimentation.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages