Events
Overview
The Events module handles platform event logging and alerting for streaming events such as followers, subscribers, cheers, raids, channel point redemptions, and tips. Events are stored in a TimescaleDB hypertable with automatic compression for efficient time-series queries. Events are broadcast in real time via Redis pub/sub to WebSocket clients for overlay alerts and dashboard panels. The module supports paginated queries with filters by type, platform, date range, and full-text search.
Architecture
Backend
- GraphQL (
apps/api/src/graphql/events.rs) -- Queries for listing/fetching events with filters. Mutation for emitting test/manual events. - Crate (
crates/lo-events/src/) -- Core event logic includingEventFilter,EventInput,EventRowtypes,list_events,get_event,insert_event, andbroadcast_eventfunctions. - Database -- Events are stored in TimescaleDB (
platform_event_logshypertable) with time-based partitioning and compression. - Real-time -- Events are broadcast via
RedisPubSubusing theGqlPubSubwrapper to all WebSocket subscribers for the account.
Frontend
- Next.js API proxy routes call GraphQL internally.
- A shared
EventsShellcomponent (apps/web/src/components/events-shell.tsx) renders the live feed with filters by type and platform. It backs three surfaces: the dedicated Events page (/dashboard/events), the compact panel on the Dashboard home, and the/popout/eventswindow (cookie or popout-token auth). - Expired-connection alert. Events are produced by the account's channel connections, so a
dead grant makes the feed stop silently. When a channel connection is flagged
reconnect_required, the header's per-platform pill shows an icon-only warning marker and a non-dismissible error alert is pinned directly under the topbar, carrying a Reconnect button that starts that platform's OAuth flow in place. It is shown only to viewers holdingconnections:create(Owner / Administrator among the default roles). See Connections. - Overlay alerts consume events via WebSocket for real-time display.
- First-time chatter & watch streak (Twitch). Both appear as rows in the feed
and are toggleable in the type filter. A
twitch:first_messagerow shows the chatter and their first message. Atwitch:watch_streakrow shows the viewer, a flame streak-count badge, and — resub-style — their attached chat message when they left one (otherwise Twitch's "watched N streams in a row" line). The first-time-chatter signal additionally tags the matching inline row in Multichat.
API
GraphQL Queries
Every account-facing event operation additionally requires the feature:events
feature flag. On GraphQL the guard is FeatureGuard("feature:events") combined
with the permission below; the four REST handlers call the same
require_feature after their permission check.
| Query | Permission | Description |
|---|---|---|
events(filter: EventFilterInput) | feature:events + events:read | Paginated event list with filters for types, platform, search, date range. Returns PaginatedEventResult!. |
event(id: UUID!) | feature:events + events:read | Get a single event by ID (account-scoped) |
GraphQL Mutations
| Mutation | Permission | Description |
|---|---|---|
emitEvent(input: EmitEventInput!) | feature:events + events:create | Emit a test/manual event, insert into TimescaleDB and broadcast via pub/sub. Returns EmitEventResult!. |
emitBotEvent(accountId: ID!, eventType: String!, platform: String!, username: String!, raw: JSON!) | System key only | Bot-worker ingest path with an explicit account id. Broadcast-only — like a test event, it is not persisted. |
REST Endpoints
All paths live under /v1. Bodies are snake_case.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/events | feature:events + events:read | Paginated event list (accepts the same filters as EventFilterInput) |
GET | /v1/events/{id} | feature:events + events:read | Fetch a single event by ID (account-scoped) |
POST | /v1/events/emit | feature:events + events:create | Emit a manual event (insert + broadcast) |
POST | /v1/events/test | feature:events + events:create | Emit a test event — broadcast only, never persisted |
WebSocket
| Channel | Gate | Feature flag |
|---|---|---|
events:{account_id} | events:read on the account in the channel key | none |
Subscribing streams every event inserted or broadcast for that account in real
time — the same payloads the events query returns. The gate is resolved by
channel_gate_for / channel_feature_for in crates/lo-websocket/src/gate.rs;
events carries no feature flag, so an account without feature:events can
still stream (the REST/GraphQL read paths remain flag-gated).
Filter Options (EventFilterInput)
| Field | Type | Description |
|---|---|---|
types | [String!] | Filter by event types (e.g., ["twitch:follower", "twitch:subscribe"]) |
platform | String | Filter by platform (comma-separated for multiple) |
userId | String | Filter by platform user ID (matches raw->>'user_id') |
search | String | Case-insensitive text search in raw JSON data |
from | String | ISO 8601 timestamp -- only events after this time |
to | String | ISO 8601 timestamp -- only events before this time |
page | Int | Page number (1-based, default 1) |
limit | Int | Items per page (default 25, max 100) |
Permissions
| Permission | Description |
|---|---|
events:read | Read/list events. Enforced on events, event, GET /v1/events, GET /v1/events/{id}. |
events:create | Create events (test events, emit). Enforced on emitEvent, POST /v1/events/emit, POST /v1/events/test. |
events:userinfo | View user cards/profiles from event entries. Enforced client-side by the EventsShell component (<Gate permission="events:userinfo">); the underlying profile lookup is guarded by chat:userinfo. |
There is no events:delete permission: no manual event-deletion surface exists on
either protocol. Events are aged out automatically by the per-plan retention sweep
(see Database below), not on demand — so the former events:delete account
permission was removed in ZAF-1094 (it gated nothing).
Database
| Table | Database | Description |
|---|---|---|
platform_event_logs | TimescaleDB | Hypertable, segmented by account_id, compressed after 7 days. Columns: id (UUID), account_id, type (the platform:action string — GraphQL exposes it as eventType), platform, external_id, raw (JSONB), created_at. Primary key (id, created_at). A partial unique index on (account_id, external_id, created_at) where external_id IS NOT NULL makes webhook re-delivery idempotent. |
Retention
Events are retained on the same per-plan window as chat messages — the privacy policy groups "chat messages and events" under one rolling, plan-dependent window. A background sweep (the chat/event retention worker, gated by chat.retention_enforcement_enabled) hard-deletes each account's platform_event_logs rows older than its effective plans.chat_retention_days window (a per-account account_limits.chat_retention_days override takes precedence). Because the window is per-plan, it is enforced by a per-account DELETE sweep rather than a table-wide TimescaleDB retention policy. An account whose effective window is 0 keeps its events indefinitely.
Event Types
Events follow the platform:action naming convention. There are two distinct
lists and they are not identical — quote the right one for your use case.
Extension-subscribable catalogue
LUMIO_EVENT_TYPES in packages/protocol/src/events.ts is the closed enum an
extension may pass to useLumioEvent(). It is validated by a Zod schema, so a
type outside this list is rejected at the SDK boundary:
- Twitch:
twitch:follower,twitch:subscribe,twitch:gift_sub,twitch:resub,twitch:cheer,twitch:raid,twitch:reward,twitch:hype_train,twitch:hype_train_end,twitch:poll,twitch:poll_end,twitch:prediction,twitch:prediction_lock,twitch:prediction_end,twitch:goal,twitch:goal_end,twitch:ad_break,twitch:ban,twitch:unban,twitch:stream_online,twitch:stream_offline,twitch:stream_update - YouTube:
youtube:subscribe,youtube:member,youtube:superchat,youtube:supersticker,youtube:gift_membership,youtube:gift_membership_received,youtube:poll - Kick:
kick:follower,kick:subscribe,kick:gift,kick:stream_online,kick:stream_offline - Trovo:
trovo:subscribe,trovo:spell - Spotify:
spotify:track,spotify:play,spotify:pause,spotify:skip,spotify:volume,spotify:queue_add,spotify:device,spotify:playlist_add,spotify:playlist_remove,spotify:playlist_create,spotify:playlist_edit,spotify:playlist_delete - StreamElements:
streamelements:tip - Chat (per-platform):
twitch:chat,youtube:chat,kick:chat,trovo:chat - Chat (cross-platform):
chat:message(fires for all platforms)
Types the platform workers actually emit
The type column on a platform_event_logs row is a free-form VARCHAR(100),
so the ingest workers write several types that are not in the extension
catalogue above:
| Source | Additional emitted types |
|---|---|
Twitch EventSub (workers/twitch_eventsub.rs) | twitch:gift (sub gifts — the worker's name for what the extension catalogue calls twitch:gift_sub), twitch:gift_upgrade, twitch:prime_upgrade, twitch:pay_it_forward, twitch:bits_badge, twitch:poll_delete, twitch:raid_out, twitch:raid_done, twitch:unraid |
Twitch IRC-tags listener (workers/twitch_irc_tags.rs) | twitch:first_message (first-time chatter — PRIVMSG first-msg=1), twitch:watch_streak (USERNOTICE viewermilestone / watch-streak, carries streak_count) |
Trovo (workers/trovo.rs) | trovo:follower, trovo:raid, trovo:subscriber |
Discord bot (apps/discord-bot) | discord:member_join, discord:member_leave, discord:boost |
Conversely, twitch:ban and twitch:unban appear in the extension catalogue
but are not written to platform_event_logs — Twitch ban/unban is handled on the
chat path (see Chat).
Internal WebSocket channel messages
These share the namespace:action shape but are transient pub/sub payloads, not
platform_event_logs rows: chat:message, chat:delete, chat:clear_user,
chat:moderation_log, chat:user_treatment_update, channel:online,
channel:offline, copyright:blocked/:warning/:clear,
obs:action/:connection_lost/:low_bitrate/:high_dropped_frames/:recovery,
history:session_started/:session_updated, sound:play, overlay:update,
widget:update, storage:update, extension:updated.
Data Flow
- Platform adapter detects an event (e.g., Twitch EventSub webhook receives a follow).
- Event is inserted into TimescaleDB via
lo_events::insert_event(). - Event is broadcast via
lo_events::broadcast_event()through Redis pub/sub. - WebSocket server forwards the event to all connected clients for the account.
- Overlay alerts render the event. The Events page, home event panel, and popout update in real time.
- Users can query historical events with filters and pagination via
eventsquery.
Key Files
| Path | Description |
|---|---|
apps/api/src/graphql/events.rs | GraphQL queries and mutations |
apps/api/src/routes/events.rs | REST endpoints |
crates/lo-events/src/ | Core events crate — TimescaleDB insert/list plus Redis broadcast and the filter types |
apps/api/src/workers/event_relay.rs | Relays platform-worker events onto the account's WebSocket channel |
packages/protocol/src/events.ts | LUMIO_EVENT_TYPES / ACTION_TYPES enums consumed by the extension SDK |
apps/web/src/components/events-shell.tsx | Shared live-feed component behind the Events page, dashboard panel, and popout |