Skip to content

pulse-events

Audience

PLANA staff. The event bus that ties every PLANA service together.

pulse-events is PLANA's event bus: a thin REST API over a Redis Stream that producers publish CloudEvents to and consumer groups read from. It is the spine of cross-service communication — provisioning signals, subscription state changes, agent reports, all flow here.

Stack

PropertyValue
Repogit.planapulse.com/plana-pulse/pulse-events
LanguageJavaScript ESM
FrameworkFastify v5
Backing storeRedis Streams (Valkey 7)
Event formatCloudEvents 1.0
Test coverage100%
Image basenode:20-alpine
Namespacepulse-events
Port3001
Redis keyPLANA:events, MAXLEN ~50000

The contract

PLANA's services publish and consume events on a single Redis Stream called PLANA:events. Every event is a CloudEvents 1.0 envelope:

json
{
  "specversion": "1.0",
  "id": "01H8…",
  "type": "client.provisioned",
  "source": "//plana-pulse/crossplane/planaclient",
  "subject": "acme",
  "time": "2026-05-29T08:23:14Z",
  "datacontenttype": "application/json",
  "data": {
    "subdomain": "acme",
    "projectId": 23,
    "tier": "pro",
    "odooVersion": "18",
    "ownerEmail": "ceo@acme.bg"
  }
}

The bus is append-only. There is no retraction or update — every event is immutable. Errors and corrections flow as new events with their own type (e.g. client.provisioning.failed).

API surface

POST /api/v1/events                  publish a CloudEvent → Redis XADD
POST /api/v1/events/groups/:group    create / ensure a consumer group
GET  /api/v1/events/info             stream stats (length, last-id, groups)
GET  /api/v1/status                  health summary
GET  /healthz                        liveness
GET  /readyz                         readiness (Redis reachable)

Authentication: X-API-Key header. Each producer has its own key in SOPS:

ProducerSOPS path
pulse-account-apipulse_events.publisher_key
pulse-billingsame
Crossplane Jobssame
pulse-bankingsame

A future hardening pass will split the publisher key per producer. Today, one shared publisher key + NetworkPolicies limits which pods can reach pulse-events:3001.

Event types in flight

TypeProducerConsumers
client.provisionedCrossplane PLANAClient Compositionpulse-notifications (welcome email), pulse-account-api (account-portal cache invalidate)
client.provisioning.failedsamepulse-notifications (error to PLANA staff)
subscription.activatedpulse-billingpulse-account-api, pulse-notifications
subscription.cancelledpulse-billingsame
invoice.paidpulse-billing (Stripe webhook receiver)pulse-account-api, pulse-notifications
deployment.readyForgejo CI on docs-portal deploynone yet (will feed status dashboard)
agent.report.readyai-agentspulse-account-api (push to BOS via SSE)
agent.tool_call.executedai-agentspulse-account-api (execution log)
tenant.upgradedCrossplane TenantUpgrade Compositionpulse-notifications
tenant.backup.completedper-tenant backup CronJobpulse-account-api (status badge)

The list is append-only: we add new types, we don't change the schema of an existing type without a migration plan.

Consumer groups

Each consumer service has its own consumer group:

PLANA:events → consumers:
  - group "pulse-notifications"        (welcome emails, alerts)
  - group "pulse-account-api"          (cache invalidation, push to SSE)
  - group "pulse-billing-status"       (status mirror)
  - group "pulse-analytics"            (BI ingestion)

Consumers commit their cursor inside Redis via standard XACK. A consumer that falls behind reads from where it left off; we do not lose events as long as the stream's MAXLEN ~50000 hasn't trimmed past the unacked entries.

Why Redis Streams, not Kafka

CriterionRedis StreamsKafka
Volume today~500–2000 events/day(would handle 10⁶+)
Operational complexityZero (Redis already deployed)Significant (Zookeeper-free Kafka still adds 3 brokers)
Replay windowMAXLEN ~50,000 (≈ 30 days at our rate)Configurable retention
At-least-once deliveryYes, via XACKYes
Order guaranteePer-streamPer-partition

We will revisit Kafka when PLANA:events exceeds ~1M events/day. We are ~3 orders of magnitude away.

Configuration

Env varPurpose
REDIS_URLredis://:<password>@redis.redis:6379/0
STREAM_KEYPLANA:events
STREAM_MAXLEN~50000 (approximate, with XADD MAXLEN ~)
PUBLISHER_API_KEYaccepts requests with this X-API-Key

NetworkPolicies

  • Ingress: only pods in pulse-account, ai-bos-agent, pulse-billing, crossplane-system, pulse-banking can reach pulse-events:3001
  • Egress: pulse-events can reach redis.redis:6379 only

A compromise of pulse-events cannot reach the cluster's other services laterally.

Operational tasks

See recent events

bash
kubectl -n pulse-events exec deploy/pulse-events -- \
  redis-cli -h redis.redis -a "$REDIS_PASSWORD" \
  XREVRANGE PLANA:events + - COUNT 20 | head -100

Check consumer lag

bash
kubectl -n pulse-events exec deploy/pulse-events -- \
  redis-cli -h redis.redis -a "$REDIS_PASSWORD" \
  XINFO CONSUMERS PLANA:events pulse-notifications

The pending column shows unacked entries; a growing pending count means the consumer is lagging.

Force-replay events from a specific point

If a consumer needs to re-process events from a known good point:

bash
# Recreate the group at a specific entry id
kubectl -n pulse-events exec deploy/pulse-events -- \
  redis-cli -h redis.redis -a "$REDIS_PASSWORD" \
  XGROUP CREATE PLANA:events pulse-notifications-replay <id> MKSTREAM

The replay group consumes from the historical point; the original group keeps its position.

Add a new event type

  1. Document the type in the table above (this page)
  2. Add a Zod / JSON Schema in pulse-events/schemas/<type>.js (we validate event payloads at publish time)
  3. Producer publishes a sample event in a feature branch
  4. Consumer adds handling for the new type
  5. Merge to main; the new type flows in production

Health

GET /readyz performs a Redis PING and returns 503 if Redis is unreachable. Alertmanager pages if /readyz returns 503 for 1 minute.

Where to read more

© PLANA Digital Ltd.