Skip to content
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,8 @@ run2: ## Start 2nd node
.PHONY: start-monitoring
start-monitoring: ## Start monitoring stack
@docker run -d --rm --add-host=host.docker.internal:host-gateway --name jaeger -p 4318:4318 -p 16686:16686 jaegertracing/jaeger:2.6.0
@docker run -d --rm --add-host=host.docker.internal:host-gateway --name prometheus -p 9101:9090 -v ./scripts/prometheus.yml:/etc/prometheus/prometheus.yml prom/prometheus --config.file=/etc/prometheus/prometheus.yml --web.enable-remote-write-receiver
@docker run -d --rm --add-host=host.docker.internal:host-gateway --name alertmanager -p 9093:9093 -v ./scripts/alertmanager.yml:/etc/alertmanager/alertmanager.yml prom/alertmanager --config.file=/etc/alertmanager/alertmanager.yml
@docker run -d --rm --add-host=host.docker.internal:host-gateway --name prometheus -p 9101:9090 -v ./scripts/prometheus.yml:/etc/prometheus/prometheus.yml -v ./scripts/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml prom/prometheus --config.file=/etc/prometheus/prometheus.yml --web.enable-remote-write-receiver
@docker run -d --rm --add-host=host.docker.internal:host-gateway --name=grafana -p 9100:3000 -v ./scripts/grafana_provisioning:/etc/grafana/provisioning grafana/grafana
@# Host CPU / memory / disk usage + disk I/O. On Linux this reports the real host;
@# on macOS Docker Desktop it reports the Docker VM (see scripts/prometheus.yml).
Expand All @@ -291,6 +292,7 @@ start-monitoring: ## Start monitoring stack
stop-monitoring:
@docker stop grafana
@docker stop prometheus
@docker stop alertmanager
@docker stop jaeger
@docker stop node-exporter

Expand Down
1 change: 1 addition & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ Distributed tracing settings using OpenTelemetry.
| `name` | string | `TRACING_APP_NAME` | `ZenBPM` | Application name for tracing |
| `transferHeaders`| []string | `TRACING_TRANSFER_HEADERS` | — | HTTP headers to propagate through trace context |
| `endpoint` | string | `OTEL_EXPORTER_OTLP_ENDPOINT` | — | OTLP exporter endpoint (e.g., for Jaeger/Tempo) |
| `samplerRatio` | float64 | `TRACING_SAMPLER_RATIO` | `1.0` | Fraction of new traces sampled (0.0 - 1.0); child spans follow their parent's sampling decision |

---

Expand Down
136 changes: 136 additions & 0 deletions docs/reference/observability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Observability

ZenBPM exposes metrics via a Prometheus endpoint, distributed traces via OTLP
(e.g. to Jaeger) and health endpoints for orchestrators and load balancers.

## Endpoints

| Endpoint | Purpose |
| -------- | ------- |
| `GET /system/metrics` | Prometheus metrics scrape endpoint |
| `GET /system/status` | Verbose diagnostic status: full cluster state. Always returns **200** (legacy contract, kept stable for existing consumers) |
| `GET /system/health/live` | Liveness probe. Returns 200 whenever the process is up. Does **not** check raft state, so a leaderless node is not restarted in a loop |
| `GET /system/health/ready` | Node readiness probe. Returns **503** until the main cluster has a raft leader, this node is registered in the cluster state, and all partitions owned by this node are initialized. Cluster-wide partition health is reported by metrics/alerts so one degraded partition does not remove every node from a load balancer |

Health responses have the shape:

```json
{"status": "DOWN", "reasons": ["no cluster leader elected", "partition 2 has no leader"]}
```

`/system/status` returns the raw cluster state (unchanged from previous releases).

## Tracing

- Configured via `tracing` section (`internal/config`): `enabled`, `endpoint`
(OTLP HTTP), `name`, `samplerRatio` (env `TRACING_SAMPLER_RATIO`, default
`1.0`; `0` disables root sampling; values outside `[0, 1]` fail startup).
Sampling is parent-based: child spans follow the parent decision.
- Spans cover: REST requests, BPMN engine operations (instances, tokens,
flow nodes, jobs, timers, incidents), DMN decision evaluations, and rqlite
exec/query statements.
- The W3C `TraceContext` + `Baggage` propagators are registered globally.
gRPC (public and node-to-node) is **not yet instrumented** — cross-partition
proxied requests currently start separate traces.
- Span attributes use the `zenbpm.` namespace (e.g. `zenbpm.process.instance_key`,
`zenbpm.job.key`, `zenbpm.decision.id`). Span (operation) names are stable —
e.g. DMN evaluations use `dmn.evaluate-decision` with the decision id carried
as an attribute — to keep tracing-backend operation indexes bounded.

> ⚠️ **BREAKING CHANGE:** span attributes were renamed from the legacy `bpmn-*`
> keys (dash separated) to the `zenbpm.*` namespace in this release, with no
> dual-write overlap period. Saved Jaeger searches, collector `attributes`
> processors and tail-sampling policies that reference the old keys **must** be
> updated before upgrading. This must be called out in the release notes.

## Metrics catalog

Prometheus names shown (OpenTelemetry counters get a `_total` suffix, `ms`
histograms a `_milliseconds` suffix).

### Engine (per node)

| Metric | Type | Attributes | Description |
| ------ | ---- | ---------- | ----------- |
| `processes_started_total` | counter | `bpmn_process_id` | Process instances started |
| `processes_completed_total` | counter | `bpmn_process_id` | Process instances ended (completed or failed) |
| `processes_running` | up/down counter | `bpmn_process_id` | Instances currently being executed |
| `process_instance_duration_milliseconds` | histogram | `bpmn_process_id`, `state` | Creation → completion/failure duration |
| `jobs_created_total` / `jobs_completed_total` / `jobs_failed_total` | counter | `type`, `internal` | Job lifecycle counters |
| `job_lifetime_milliseconds` | histogram | `type`, `outcome` | Job creation → terminal state duration |
| `incidents_created_total` / `incidents_resolved_total` | counter | `element_id` | Incident lifecycle counters (recorded only after the write batch is successfully flushed) |
| `timers_scheduled_total` / `timers_fired_total` / `timers_cancelled_total` | counter | — | Timer lifecycle counters (recorded only after the write batch is successfully flushed; covers instance-level timers as well as definition-level timer start events and their cycle renewals) |
| `messages_correlated_total` / `message_correlation_failed_total` | counter | `message_name` (+ `reason` on failures) | Message correlation outcomes. Failed lookups (no active subscription) use `message_name="unknown"`, `reason="subscription_not_found"` — the caller-provided name is never used as a label to keep cardinality bounded. Failures after a subscription was found use the (definition-bounded) real name with `reason="publish_failed"` |

### DMN

| Metric | Type | Attributes | Description |
| ------ | ---- | ---------- | ----------- |
| `dmn_evaluations_total` | counter | `decision_id`, `outcome` | Decision evaluations |
| `dmn_evaluation_duration_milliseconds` | histogram | `decision_id` | Evaluation duration |

### Cluster / raft (main Zen cluster, exported by every node)

| Metric | Type | Attributes | Description |
| ------ | ---- | ---------- | ----------- |
| `cluster_has_leader` | gauge 0/1 | — | Main cluster has an elected leader |
| `node_is_leader` | gauge 0/1 | — | This node is the main cluster leader |
| `partition_has_leader` | gauge 0/1 | `partition` | Partition leader registered in cluster state (replicated view; see `partition_raft_has_leader` for the local raft view) |
| `cluster_partitions` / `cluster_desired_partitions` | gauge | — | Actual vs. configured partition count; feeds the `PartitionDeficit` alert (missing partitions emit no `partition_has_leader` series, so a count comparison is required) |
| `raft_term`, `raft_last_log_index`, `raft_applied_index`, `raft_fsm_pending` | gauge | — | Raft internals of the main cluster |

### Partition / rqlite

| Metric | Type | Attributes | Description |
| ------ | ---- | ---------- | ----------- |
| `jobs_waiting` | gauge | `partition` | Jobs waiting to be worked on. Exported by **every replica** of a partition — deduplicate with `max by(partition)` before aggregating |
| `process_instances_active` | gauge | `partition` | Active process instances. Exported by every replica — deduplicate with `max by(partition)` |
| `partition_raft_has_leader` | gauge 0/1 | `partition` | Partition raft group has a leader (local raft view; see `partition_has_leader` for the replicated cluster-state view) |
| `partition_node_is_leader` | gauge 0/1 | `partition` | This node leads the partition raft group |
| `partition_leader_changes_total` | counter | `partition` | Leader changes observed by this node (the first election after node start, repeated observations of the same leader and leadership-loss observations are not counted) |
| `rqlite_db_size_bytes` | gauge | `partition` | SQLite files size on disk (db + WAL/SHM) |
| `rqlite_exec_duration_milliseconds` | histogram | `partition`, `outcome` | Raft-replicated write duration |
| `rqlite_query_duration_milliseconds` | histogram | `partition`, `outcome` | Read query duration |

### Job manager

| Metric | Type | Attributes | Description |
| ------ | ---- | ---------- | ----------- |
| `jobs_distributed_total` | counter | `type`, `client` | Jobs successfully handed to worker streams |
| `job_activation_latency_milliseconds` | histogram | `type` | Job creation → distribution latency (successful sends only; clamped at 0 to guard against cross-node clock skew) |

### REST / runtime

- `request_total`, `request_uri_total`, `request_body_size`, `response_body_size`,
`request_duration_milliseconds` — REST server. `request_uri_total` carries
`path`, `method` and `status` labels.
- Go runtime metrics (`go_*`, `process_*`) — exported by the Prometheus
`client_golang` default collectors that the `/system/metrics` promhttp
handler serves.

## Alerting

`make start-monitoring` starts Prometheus with `scripts/prometheus-rules.yml`
(technical + business alerts) and an Alertmanager (`scripts/alertmanager.yml`,
placeholder webhook receiver). Key alerts:

- **NoClusterLeader / NoPartitionLeader / PartitionDeficit** (critical) — driven
by the leadership and partition-count gauges exported by every node, not by
probing a single node's health URL. `PartitionDeficit` covers partitions that
were never created (absent series cannot fire `NoPartitionLeader`).
- **TargetDown, HighErrorRate, RestLatencyDegradation, RqliteExecLatencyDegradation**
- **RqliteDbSizeLarge, RqliteDbGrowthPrediction, DiskSpaceLow, HighCPU, HighMemory**
- **ThroughputDrop, RaftLeaderFlapping, GoroutineLeak**
- **IncidentCreated, HighJobFailureRate, JobBacklogGrowing, StuckProcessInstances, NoJobDistribution**

## Dashboards

Provisioned automatically from `scripts/grafana_provisioning/dashboards/zenbpm/`:

- `main.json` — processes, jobs, distribution, request duration
- `cluster.json` — leadership, partition deficit, raft health, leader changes
- `incidents.json` — incidents, job failures, message correlation, timers, DMN
- `storage.json` — rqlite DB size/growth, read/write latency percentiles, disk
- `latency.json` — business latency percentiles and throughput
- `host.json` — node_exporter CPU/memory/disk/network
- `go.json` — Go runtime
9 changes: 8 additions & 1 deletion internal/cluster/jobmanager/otel.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"

otelPkg "github.com/pbinitiative/zenbpm/pkg/otel"
"go.opentelemetry.io/otel"

"go.opentelemetry.io/otel/metric"
Expand All @@ -15,16 +16,22 @@ const (

var (
JobsDistributed metric.Int64Counter
// JobActivationLatency measures time between job creation and its distribution to a worker, in ms.
JobActivationLatency metric.Float64Histogram
)

func registerMetrics() error {
var err error
var errJoin error
JobsDistributed, err = otel.Meter(jobManagerMeter).Int64Counter("jobs_distributed", metric.WithDescription("Number of jobs sent to the clients"))
errJoin = errors.Join(errJoin, err)
JobActivationLatency, err = otel.Meter(jobManagerMeter).Float64Histogram("job_activation_latency",
metric.WithUnit("ms"),
metric.WithDescription("Time between job creation and distribution to a worker, milliseconds"),
metric.WithExplicitBucketBoundaries(otelPkg.LatencyBucketsMs()...))
errJoin = errors.Join(errJoin, err)
if errJoin != nil {
return fmt.Errorf("failed to create otel instruments: %w", err)
return fmt.Errorf("failed to create otel instruments: %w", errJoin)
}
return nil
}
17 changes: 13 additions & 4 deletions internal/cluster/jobmanager/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,14 +233,23 @@ func (s *jobServer) distributeJobs() {
CreatedAt: &job.CreatedAt,
},
})
JobsDistributed.Add(context.Background(), 1, metric.WithAttributes(
attribute.String("type", job.Type),
attribute.String("client", string(clientID)),
))
if err != nil {
s.logger.Error("Failed to send job to node", "jobType", jType, "key", job.Key, "err", err)
continue
}
JobsDistributed.Add(s.ctx, 1, metric.WithAttributes(
attribute.String("type", job.Type),
attribute.String("client", string(clientID)),
))
if JobActivationLatency != nil && job.CreatedAt > 0 {
latencyMs := float64(time.Now().UnixMilli() - job.CreatedAt)
if latencyMs < 0 {
latencyMs = 0
}
JobActivationLatency.Record(s.ctx, latencyMs, metric.WithAttributes(
attribute.String("type", job.Type),
))
}
}
}
}
Expand Down
37 changes: 37 additions & 0 deletions internal/cluster/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"hash/fnv"
"net"
"slices"
"sort"
"time"

"github.com/bwmarrin/snowflake"
Expand Down Expand Up @@ -113,6 +114,9 @@ func StartZenNode(mainCtx context.Context, conf config.Config) (*ZenNode, error)
if err = node.store.Open(); err != nil {
return nil, fmt.Errorf("failed to open store: %w", err)
}
if err = node.store.RegisterMetrics(); err != nil {
node.logger.Error("Failed to register cluster store metrics", "err", err)
}

node.client = client.NewClientManager(node.store)
err = node.controller.Start(node.store, node.client)
Expand Down Expand Up @@ -1868,6 +1872,39 @@ func (node *ZenNode) GetStatus() state.Cluster {
return node.store.ClusterState()
}

// Health evaluates the readiness of this node in the cluster. Cluster-wide
// partition health is deliberately excluded: making every node unready when a
// remote partition is degraded would remove the entire cluster from a load
// balancer. Cluster-wide deficits and leader loss are exposed by metrics and
// alerts instead.
func (node *ZenNode) Health() (bool, []string) {
if node.store == nil {
return false, []string{"cluster store is not initialized"}
}
reasons := make([]string, 0)
if !node.store.HasLeader() {
reasons = append(reasons, "no cluster leader elected")
}
cs := node.store.ClusterState()
reasons = append(reasons, nodeReadinessReasons(cs, node.store.NodeID())...)
return len(reasons) == 0, reasons
}

func nodeReadinessReasons(cs state.Cluster, nodeID string) []string {
partitionReasons := make([]string, 0)
if self, err := cs.GetNode(nodeID); err != nil {
partitionReasons = append(partitionReasons, "node is not registered in the cluster state")
} else {
for id, partition := range self.Partitions {
if partition.State != state.NodePartitionStateInitialized {
partitionReasons = append(partitionReasons, fmt.Sprintf("partition %d on this node is in state %s", id, partition.State))
}
}
}
sort.Strings(partitionReasons)
return partitionReasons
}

func (node *ZenNode) StartProcessInstanceOnElements(ctx context.Context, processDefinitionKey int64, startingElementIds []string, variables map[string]any) (*proto.ProcessInstance, error) {
state := node.store.ClusterState()
candidateNode, err := state.GetLeastStressedPartitionLeader()
Expand Down
46 changes: 46 additions & 0 deletions internal/cluster/node_health_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package cluster

import (
"testing"

"github.com/pbinitiative/zenbpm/internal/cluster/state"
"github.com/stretchr/testify/assert"
)

func TestNodeReadinessIgnoresClusterWidePartitionDegradation(t *testing.T) {
cs := state.Cluster{
Config: state.ClusterConfig{DesiredPartitions: 3},
Partitions: map[uint32]state.Partition{
1: {Id: 1, LeaderId: ""},
},
Nodes: map[string]state.Node{
"node-1": {
Id: "node-1",
Partitions: map[uint32]state.NodePartition{
2: {Id: 2, State: state.NodePartitionStateInitialized},
},
},
},
}

assert.Empty(t, nodeReadinessReasons(cs, "node-1"))
}

func TestNodeReadinessReportsLocalPartitionState(t *testing.T) {
cs := state.Cluster{Nodes: map[string]state.Node{
"node-1": {
Id: "node-1",
Partitions: map[uint32]state.NodePartition{
2: {Id: 2, State: state.NodePartitionStateInitializing},
},
},
}}

assert.Equal(t, []string{"partition 2 on this node is in state NodePartitionStateInitializing"}, nodeReadinessReasons(cs, "node-1"))
}

func TestNodeReadinessReportsUnregisteredNode(t *testing.T) {
cs := state.Cluster{Nodes: map[string]state.Node{}}

assert.Equal(t, []string{"node is not registered in the cluster state"}, nodeReadinessReasons(cs, "node-1"))
}
Loading
Loading