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.
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.
One OTLP receiver feeds three independent pipelines. Processor order is deliberate and is where most real-world Collector configs go wrong:
memory_limiterfirst — it must be able to reject work before anything else allocates.resource— enrich while the data is still small (addsdeployment.environment,observability.stack).filter/logs— drop below-INFOrecords before batching, so the batcher never packs data that gets thrown away.batchlast — 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.
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.
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.
git clone https://github.com/TheLoop705/pulse-observability.gitcd pulse-observability && cp .env.example .env && docker compose up -dOpen 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 | — |
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 --buildWithin about a minute every dashboard shows live data. The demo app is at localhost:8080; see demo/README.md for the endpoints.
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. |
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-networkHTTP 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.
| 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. |
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-nodewith 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_samplingprocessor, which is exactly the kind of change the Collector layer exists to absorb. - Alert rules are defined but not routed.
prometheus/alert-rules.ymlcontains working rules; wiring an Alertmanager receiver is left out deliberately.
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.
./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 everythingpulse-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)
The four diagrams are generated from code so they never drift from the stack they describe:
node tools/diagrams/build.mjsEach diagram is emitted twice — as .excalidraw (open and edit at excalidraw.com)
and as .svg for embedding. Details in docs/diagrams/README.md.
- Getting Started — first run, step by step
- Architecture — components and data flow in depth
- Instrumenting Your App — Java, Node.js, Python, Go
- Configuration Guide — every config file explained
- Thesis Context — research background and evaluation
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.

