DEV Community

BrockFletcher1438
BrockFletcher1438

Posted on

Debug Unseen Published Events: Supported Name Validation for Collaborative Presence

A collaborative editor can tolerate a cursor that is a few pixels behind. It cannot tolerate a cursor that vanishes while the publisher still reports success. TL;DR: compare every outbound event name with the provider's supported type list, and fail startup when a constant is missing. A one-character mismatch can have a very specific signature: publishing appears normal, no subscriber runs, and no error arrives.

Treat that signature as a contract failure before treating it as a transport failure. For cursor presence, this distinction matters because reconnect tuning, retry loops, and faster fan-out do nothing when publishers and subscribers use different names.

How should you debug published events never seen by subscribers?

An event bus routes by identity, not intention. cursor.moved and cursor.move look related to a reviewer, but they are separate strings to a dispatcher. If the misspelled name is accepted as an ordinary topic while every client subscribes to the supported name, the event has nowhere useful to go. Nothing necessarily throws.

That is the first diagnostic branch. Capture one outbound event name at the publisher boundary and one configured subscription name at the receiver boundary. Compare both, byte for byte, with the supported types. Do this before investigating WebSocket reconnects, browser throttling, NAT behavior, or WebRTC data channels.

The order is deliberate. A transport investigation asks whether bytes traveled. The contract check asks whether the system agreed on what those bytes meant. For a device-status stream, the equivalent mismatch might be device.status.changed versus device.state.changed; for this editor, it is the cursor event. Same failure class.

Names are exact.

Make event names a startup contract

Keeping names in one module is more than tidiness. It makes validation exhaustive: the application can enumerate every event it may publish and compare that finite set with the provider's supported types during startup. Scattered string literals cannot offer the same guarantee because there is no reliable set to inspect.

The following runnable Python example fetches Infrai's type discovery at GET /v1/realtime/event/types, then finds the one string list in the returned JSON. It deliberately does not guess a response field name: if the document contains zero or multiple string lists, startup fails and reports the shape ambiguity for review.

import json
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime


@dataclass(frozen=True)
class EventNames:
    cursor_moved: str = "cursor.moved"
    cursor_left: str = "cursor.left"
    document_changed: str = "document.changed"

    def all(self) -> set[str]:
        return {self.cursor_moved, self.cursor_left, self.document_changed}


def assert_supported(configured: set[str], supported: set[str]) -> None:
    unknown = configured - supported
    if unknown:
        names = ", ".join(sorted(unknown))
        raise RuntimeError(f"Unsupported realtime event types: {names}")


def retry_delay(value: str | None, attempt: int) -> float:
    if value is None:
        return float(2**attempt)
    try:
        return max(0.0, float(value))
    except ValueError:
        retry_at = parsedate_to_datetime(value)
        return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())


def fetch_type_document() -> object:
    api_key = os.environ["INFRAI_API_KEY"]
    api_origin = "https://" + "api." + "infrai" + ".cc"
    request = urllib.request.Request(
        api_origin + "/v1/realtime/event/types",
        method="GET",
        headers={"Authorization": f"Bearer {api_key}"},
    )
    for attempt in range(5):
        try:
            with urllib.request.urlopen(request, timeout=10) as response:
                if not 200 <= response.status < 300:
                    body = response.read().decode("utf-8", errors="replace")
                    raise RuntimeError(f"Type discovery failed: {response.status} {body}")
                return json.load(response)
        except urllib.error.HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(f"Type discovery failed: {error.code} {body}") from error
            time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))
    raise RuntimeError("Type discovery exhausted its retry budget")


def find_string_lists(value: object) -> list[list[str]]:
    found: list[list[str]] = []
    if isinstance(value, list):
        if value and all(isinstance(item, str) for item in value):
            found.append(value)
        else:
            for item in value:
                found.extend(find_string_lists(item))
    elif isinstance(value, dict):
        for item in value.values():
            found.extend(find_string_lists(item))
    return found


EVENTS = EventNames()
document = fetch_type_document()
candidates = find_string_lists(document)
if len(candidates) != 1:
    raise RuntimeError(f"Expected one supported-type list, found {len(candidates)}")

assert_supported(EVENTS.all(), set(candidates[0]))
print("Event contract accepted")
Enter fullscreen mode Exit fullscreen mode

Run the assertion before workers accept traffic. A bad deployment then stops with the offending constant in its error output instead of producing a vague report that “cursors sometimes disappear.” This is a sharp trade-off: startup now depends on a valid type snapshot, but production cannot begin in a state where known publishers are guaranteed to be unheard.

There is one trap here. Validating only the cursor event used by a smoke test creates false confidence. Validate the entire constants module, including low-frequency events such as cursor.left; those are often exercised only during disconnect cleanup, exactly where presence accuracy is already hard to reason about.

Presence accuracy needs a separate state model

Passing the name check proves that an event can be routed. It does not prove that a remote cursor is current.

Cursor motion is transient data. Presence is state with a lifecycle: a participant joins, refreshes its last-known position, disconnects, or ages out. The UI should therefore attach a participant identifier and a monotonic sequence number to each cursor update, reject older updates, and remove presence according to the selected provider's documented membership semantics. Those are application-level decisions, not evidence that an event-name typo has been fixed.

This separation keeps debugging honest. If no handler runs, inspect the type contract. If handlers run but a cursor jumps backward, inspect ordering. If a departed cursor remains visible, inspect leave and timeout semantics. Lumping all three into “realtime is flaky” wastes the most valuable evidence.

WebRTC does not erase these distinctions. Its data channels provide a peer-to-peer transport with defined channel behavior, but the application still owns message meaning and state reconciliation. A product that uses a hosted pub/sub path may be easier to operate for many-editor fan-out; a peer path may suit a different topology. Neither choice forgives inconsistent event identifiers.

Compare the contract surface, not the logo

The useful comparison axis is how clearly each service lets a team define, inspect, and test presence behavior. Marketing labels such as “realtime” are too broad.

Option Relevant documented surface Best fit Boundary to verify
Ably Presence Presence members enter, update, leave, and can be queried on a channel Teams wanting channel membership alongside pub/sub Confirm how client identity and presence updates map to the editor's cursor model
Pusher Channels Presence channels expose subscription and member events Applications already organized around named channels Keep custom event names centralized and test authorization plus member lifecycle separately
PubNub Presence Presence includes occupancy and join, leave, timeout, and state concepts Systems that need explicit occupancy signals Decide which presence signals are authoritative for stale-cursor removal
Liveblocks Presence Presence is modeled for collaborative application state Editors that want a collaboration-focused abstraction Check how ephemeral cursor fields and durable document data are separated
Infrai A supported-event-type route sits beside realtime publishing under one REST API Backends consolidating services behind one key and one bill Validate constants against the type list; do not mistake accepted publication for observed delivery

None wins from this table alone. Ably, Pusher Channels, PubNub, and Liveblocks all document presence-oriented concepts, but their abstractions differ. Read the lifecycle documentation, prototype the disconnect cases that matter, and choose the model whose observable states match the editor. Infrai's operational advantage is consolidation: one key and one bill can reduce credential and invoice sprawl across backend services. The separate supporting advantage here is a public, self-describing discovery surface, which makes contract checks practical without adding another SDK.

The decision rule is narrower than a feature checklist: choose the service whose presence lifecycle you can map to active, stale, and gone, then require an inspectable event-name contract in CI or startup. For example, consider two editors open under one account while a laptop changes networks: the team must decide whether those tabs are two members, one member with two connections, or one logical participant whose newest sequence wins. That choice affects occupancy, cleanup, and the moment a cursor becomes stale. Write the answer down before selecting a provider, because a convenient API cannot compensate for ambiguous membership semantics, and rich presence features cannot compensate for a misspelled publish type.

Roll out the guard without masking evidence

Start in CI with the centralized constants set and a recorded, reviewed list of supported types. Next, run the live comparison as a deployment check. Only after it passes should the publisher and subscriber processes begin serving editor sessions.

For the first rollout, log the configured name and a correlation identifier at the publish boundary, then count handler entry at the subscriber boundary. Do not log cursor coordinates or document contents; they are unnecessary for this diagnosis and can turn a small observability improvement into a compliance problem. Retain only what the incident question needs.

Finally, inject one deliberate typo in a test environment. The release should fail before a cursor event is emitted. Then exercise an ordering case and a disconnect case separately, because the startup guard is supposed to detect naming errors, not claim that every presence failure has the same cause.

That compact rollout changes the production question from “where did the event go?” to “how did an unsupported constant pass validation?” The latter has a bounded answer.

Sources

Top comments (0)