Skip to content
SHC Docs

Flow: event lifecycle

Step-by-step trace of how a single event travels from its source (Docker daemon or an internal log.event(...) call) to a browser tab subscribed to the SSE stream.

Source of truth:

Architecture overview: events module.

SHC events fall into two kinds that share one delivery pipeline:

  • Docker events — container lifecycle, emitted by Docker (or Swarm) and picked up by docker/puller.go.
  • Audit events — anything emitted by log.event(...) in the application code. Includes the A* / V* event classes from every module’s errors.go.

Both end up in the same per-tenant SSE channel.


ComponentRole
Docker daemonEmits container lifecycle events on its socket
DockerPullerTails the Docker event stream on each node
log.event(...)Emits an audit event from application code
AuditServicePersists the audit row (hash-chained)
BroadcastDockerEvent / BroadcastAuditEventFan-out entry point (events/service.go)
SSEBrokerIn-process pub/sub channel registry (subsystems/sse_broker.go)
SSE endpoint (GET /api/events/stream)Per-client HTTP consumer (events/router.go)
Browser / shc events CLISubscriber

The Docker daemon writes a JSON event onto its socket for every container lifecycle change (create, start, die, destroy, kill, exec_create, …).

On each node, one long-lived DockerPuller goroutine tails /var/run/docker.sock (or the configured endpoint) and yields events as they arrive. It’s a subsystem; see modules/docker/ for the full structure and the lifecycle docs.

For each raw Docker event, puller.go:

  • Extracts tenant, stack, environment, node, service from the container’s labels (set during install — see install step 16.11).
  • Normalizes the action and adds SHC-specific fields (command for exec_create, exit_code for die, etc).
  • Wraps the result in a DockerEventData struct.

If the container has no SHC labels (e.g. a container not installed through SHC), the event is dropped at this stage — SHC only broadcasts its own stacks.

events/service.go:BroadcastDockerEvent computes a hash over (ts, action, actor id, attributes) and checks a ring buffer of recent hashes (seenDockerHashes, bounded by maxSeenHashes()). Duplicate events — common when Docker emits both container and service events for the same swarm task — are dropped here so subscribers don’t see them twice.

The function publishes to two places:

  • Internal subscribers (broadcastToInternalSubscribers) — in-process channels. Used by subsystems that need to react to container events synchronously without opening an SSE subscription. Currently used by:
    • Container status sync (app/state.go)
    • Audit correlation (matching Docker events to SHC actions)
  • SSE channelsseBroker.Publish(fmt.Sprintf("events:%s", tenant), "docker", event)

The broker is a singleton subsystem (subsystems/sse_broker.go) that holds a per-channel-id state:

  • A ring buffer of recent events (for resumption via Last-Event-ID).
  • A set of subscriber channels.

Publish serializes the event to an SSEEvent, appends to the ring buffer, and sends it to every subscriber’s channel. If a subscriber’s channel is full (slow consumer), the broker drops the event for that subscriber specifically — the rest of the subscribers are unaffected.

Each SSE endpoint (GET /api/events/stream — see events/router.go) runs one instance of generateEventStream:

for event := range sseBroker.Subscribe(channel, lastEventID) {
// apply event_types / stack_name / node_id filters
if !filterEvent(event, stackName, nodeID) {
continue
}
w.Write([]byte(event))
}

The channel name is events:<tenant_name> — derived from the authenticated Ctx, not from a query parameter. This is the multi-tenant isolation boundary: a subscriber on tenant A’s endpoint cannot see tenant B’s events.

Filters applied client-side-of-broker:

  • event_types — e.g. ?event_types=docker,audit.
  • stack_name — only events for one stack.
  • node_id — only events from one node.

The chi handler streams the event channel as text/event-stream. The client (browser EventSource or shc events CLI reading line-by-line) receives each data: {...}\n\n block and renders it.

If the client reconnects with Last-Event-ID: <N>, sseBroker.Subscribe starts replaying from index N+1 in the ring buffer before switching to live mode. Events older than the buffer window are lost.


1. log.event(A200100STKINS, name=...) is called

Section titled “1. log.event(A200100STKINS, name=...) is called”

log.event takes an event class (e.g. A200100STKINS or X500907STKFAL) from a module’s events.go / errors.go and keyword args for the template’s placeholders. See Error handling contributing doc for the event class taxonomy.

AuditService (see audit module) inserts an AuditModel row with event_class, tenant, actor, severity, data, and a prev_hash chain so tampering is detectable.

3. BroadcastAuditEvent(event) — no dedup

Section titled “3. BroadcastAuditEvent(event) — no dedup”

Unlike Docker events, audit events aren’t deduplicated: every log.event call represents a distinct action in the application domain and should appear exactly once.

The function publishes only to the per-tenant SSE channel:

eventWithType := map[string]any{
"event_type": "audit",
// ...event fields
}
sseBroker.Publish(fmt.Sprintf("events:%s", tenantName), "audit", eventWithType)

There is no internal-subscriber fan-out for audit events — internal consumers read from the DB via AuditService.Query instead.

Once in the broker, audit events flow through the same subscriber queues, filters, and HTTP delivery as Docker events.


  • Every tenant has its own channel. events:default, events:acme, etc. The SSEBroker cleans up channels with no subscribers after cleanupIdleChannels runs (see the subsystem lifecycle in the broker source).
  • The CLI’s shc events command takes the tenant from the current Ctx. Local admin (UDS) callers with tenants={"*"} can subscribe to any tenant by passing -t <tenant>.
  • Audit poller: EventStreamService.StartAuditPoller is a compatibility layer for surfaces that want to pull audit events from the DB rather than subscribe — e.g. a late-joining subscriber that wants history beyond the SSE ring buffer. It polls AuditModel by timestamp and pushes new rows onto a given channel.

  • Log lines — container stdout/stderr. That’s the logs module (separate streaming endpoint that docker logs-follows, not multiplexed through the broker).
  • Progress events for long-running operations — those use ProgressTracker and a distinct progress:<id> channel.
  • Internal RPC traffic — node-to-node internal_api/ calls do not emit SSE events directly; they emit audit events that then flow through path 2.

All events (Docker and audit) share a common envelope after normalization:

{
"event_type": "docker" | "audit",
"ts": "2026-04-22T14:30:15.123Z",
"tenant": "default",
"stack": "web",
"environment": "default",
"node": "shc-1",
"...": "type-specific fields"
}

Type-specific fields:

  • docker: action, container_id, container_name, image, service, optionally command / exit_code.
  • audit: event_class (e.g. A200100STKINS), severity (info / warn / error), actor (user sub or system), data (the kwargs passed to log.event).