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:
modules/events/service.go— the fan-out entry points.subsystems/impl/sse_broker.go— the generic channel-multiplexing broker.modules/events/router.go— the HTTP SSE endpoints.subsystems/impl/docker_events.go(ce module) — Docker event ingress.
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 theA*/V*event classes from every module’serrors.go.
Both end up in the same per-tenant SSE channel.
Actors
Section titled “Actors”| Component | Role |
|---|---|
| Docker daemon | Emits container lifecycle events on its socket |
DockerPuller | Tails the Docker event stream on each node |
log.event(...) | Emits an audit event from application code |
AuditService | Persists the audit row (hash-chained) |
BroadcastDockerEvent / BroadcastAuditEvent | Fan-out entry point (events/service.go) |
SSEBroker | In-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 CLI | Subscriber |
Path 1: Docker → subscriber
Section titled “Path 1: Docker → subscriber”1. Docker emits
Section titled “1. Docker emits”The Docker daemon writes a JSON event onto its socket for every
container lifecycle change (create, start, die, destroy, kill,
exec_create, …).
2. DockerPuller tails the socket
Section titled “2. DockerPuller tails the socket”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.
3. Enrichment
Section titled “3. Enrichment”For each raw Docker event, puller.go:
- Extracts
tenant,stack,environment,node,servicefrom the container’s labels (set during install — see install step 16.11). - Normalizes the action and adds SHC-specific fields (
commandforexec_create,exit_codefordie, etc). - Wraps the result in a
DockerEventDatastruct.
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.
4. BroadcastDockerEvent(event) — dedup
Section titled “4. BroadcastDockerEvent(event) — dedup”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.
5. Fan-out split
Section titled “5. Fan-out split”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)
- Container status sync (
- SSE channel —
sseBroker.Publish(fmt.Sprintf("events:%s", tenant), "docker", event)
6. sseBroker.Publish
Section titled “6. sseBroker.Publish”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.
7. Subscriber channel → HTTP response
Section titled “7. Subscriber channel → HTTP response”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.
8. HTTP delivery
Section titled “8. HTTP delivery”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.
9. Last-Event-ID resumption
Section titled “9. Last-Event-ID resumption”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.
Path 2: Audit event → subscriber
Section titled “Path 2: Audit event → subscriber”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.
2. Audit persistence
Section titled “2. Audit persistence”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.
4-9. Same as Path 1 from step 6 onward
Section titled “4-9. Same as Path 1 from step 6 onward”Once in the broker, audit events flow through the same subscriber queues, filters, and HTTP delivery as Docker events.
Cross-tenant notes
Section titled “Cross-tenant notes”- Every tenant has its own channel.
events:default,events:acme, etc. TheSSEBrokercleans up channels with no subscribers aftercleanupIdleChannelsruns (see the subsystem lifecycle in the broker source). - The CLI’s
shc eventscommand takes the tenant from the currentCtx. Local admin (UDS) callers withtenants={"*"}can subscribe to any tenant by passing-t <tenant>. - Audit poller:
EventStreamService.StartAuditPolleris 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 pollsAuditModelby timestamp and pushes new rows onto a given channel.
What is NOT in the event pipeline
Section titled “What is NOT in the event pipeline”- Log lines — container stdout/stderr. That’s the
logsmodule (separate streaming endpoint thatdocker logs-follows, not multiplexed through the broker). - Progress events for long-running operations — those use
ProgressTrackerand a distinctprogress:<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.
Events schema (quick reference)
Section titled “Events schema (quick reference)”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, optionallycommand/exit_code. - audit:
event_class(e.g.A200100STKINS),severity(info / warn / error),actor(usersuborsystem),data(the kwargs passed tolog.event).
See also
Section titled “See also”- Architecture: events module
- Architecture: audit module
- Architecture: docker module — the puller source
- Communication patterns
- Error reference — all audit event classes