Skip to main content

Real-time events

A Xenon hub tells the dashboards connected to it what is happening as it happens: phones coming and going, sessions starting and ending, each command, heals, recordings and captured network requests. It sends these over Socket.IO, and a script can listen to the same stream, for a wallboard or a chat bridge. This page explains how to connect, who receives which event, and what each event carries.

Where the stream runs​

  • On a hub, or a standalone server: any server started without the hub option. It runs whether or not enableDashboard is set. A node runs no stream of its own; it connects to its hub's as a client.
  • On Appium's port, at the path /socket.io/, in Socket.IO's default namespace. The path doesn't change with Appium's --base-path.
  • From what that server does itself. A hub sends events about its nodes' phones as it learns of them: phones a node reports, sessions it routes to a node, those sessions' commands and the heals the node hands back with them, recordings it makes of a node's phone. Network capture for a session on a node's phone happens on the node, and sends no events to the hub.

Connect​

The connection has to present one of these credentials. Xenon checks them in this order:

CredentialHow to send itJoins as
A bearer token with the audience xenon-rest, from POST /xenon/api/auth/tokenauth: { bearer: '<token>' }A dashboard client
An access key and tokenauth: { accessKey, token }, or the x-xenon-access-key and x-xenon-token headersA node
The dashboard's sign-inThe xenon_dashboard_session cookie, which a browser on the dashboard's own address sends by itself. A cookie that holds an API key instead of a sign-in is accepted too, as the REST API accepts it.A dashboard client
  • The user must be Active. A credential that doesn't check out, or an Inactive user, gets the connect error unauthorized, and the server logs why.
  • Register to receive. After connecting, a client emits register_dashboard to receive the dashboard's events. A connection made with an access key and token is a node's: if it emits register_dashboard it is disconnected, so a script uses a bearer token. In the same way, a dashboard client that emits register_node is disconnected.
  • With sign-in off, every connection is accepted and may register as either.
  • Credentials are checked when the socket connects, and again while it stays connected. A client stays connected only while its credential would still be accepted:
    • Who the user is now: when someone changes the user's role or teams, the connection takes the change at once, without reconnecting.
    • Closed: the connection is closed when the user is made Inactive or deleted, the sign-in is signed out, the API key is revoked or expires, the user's access key is rotated (a node's connection), or the bearer token runs out (when the REST API stops taking it, a minute after the expiry it carries).
    • Reconnecting: Socket.IO then reconnects by itself, and the handshake checks the credential it sends this time. One that is no longer accepted gets unauthorized, and Socket.IO doesn't try again.
    • Bearer tokens: a bearer token lasts an hour, so give a long-running client a function that fetches a fresh one. Socket.IO calls it each time it reconnects, including after the token runs out.
import { io } from 'socket.io-client';

// Returns a token from POST /xenon/api/auth/token with {"audience":"xenon-rest"}.
async function freshToken() {
const res = await fetch('http://hub.internal:4723/xenon/api/auth/token', {
method: 'POST',
headers: {
'x-xenon-access-key': process.env.XENON_ACCESS_KEY,
'x-xenon-token': process.env.XENON_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ audience: 'xenon-rest' }),
});
return (await res.json()).token;
}

const socket = io('http://hub.internal:4723', {
auth: (cb) => freshToken().then((bearer) => cb({ bearer })),
transports: ['websocket'],
});

// Register on every connect: a reconnect is a new connection.
socket.on('connect', () => socket.emit('register_dashboard'));
socket.on('connect_error', (err) => console.error('refused:', err.message));

socket.on('session_stopped', (e) => console.log(e.id, e.status, e.failure_reason));
socket.on('healing_event', (e) => console.log(e.originalSelector, '->', e.healedSelector));

Authentication explains bearer tokens.

Who receives which event​

Events go only to registered dashboard clients. Most are about a phone, and go by the phone's team. Network capture events go to admins only, selector events by who may see the selector, and node events to every client:

  • An Admin or a Super admin, or any client with sign-in off, gets every event.
  • A Member gets the events about phones in the shared pool and in their teams. A token bound to one team narrows that to the shared pool and that team. See Teams.
  • A change of role or membership applies at once, to clients already connected: a Member taken off a team stops getting its phones' events, and an Admin made a Member stops getting network capture events. A change made on another server that shares the database applies within a minute. A client connected with a token bound to one team keeps that team, as the REST API does.
  • An event about a phone reaches only the clients that can see the phone. If the phone can't be identified, or looking up its team takes longer than 2 seconds, the event goes to admins only. That is every event below except the selector and node events, and the network capture events, which go to admins only.
  • A recording of several phones reaches each client cut down to the phones it can see, and not at all when it sees none of them.
  • Selector events reach a Member only for a selector that healed, at any time, in a session they can see: the selectors they may see on the Selector health page. If that can't be checked within 2 seconds, the event goes to admins only.
  • Node events go to every dashboard client.
  • Network capture events go to admins only. interceptor_request carries each captured request's headers and bodies, which can hold sign-in details and personal data, so interceptor_session_started, interceptor_request and interceptor_session_stopped reach Admins and Super admins only, as the REST routes and the session's Network panel do. A test's own getRequests and exportHar commands are unaffected.
  • One phone's events arrive in the order they were sent, and so do one selector's. Events about different phones, or different selectors, may interleave.

Events​

The payloads below are the fields each event carries. Times are ISO 8601 strings unless the field says otherwise.

Phones​

EventSent whenPayload
device_addedA phone appears on this server, or a node reports a new one.The phone's record: udid, name, platform, sdk, host, busy, teamId and the rest of what the Devices page shows.
device_removedA phone goes away.udid, host
device_blockedA live preview or a recording takes a phone.udid, host, session_id (the hold, such as manual_<user id>_<udid>)
device_unblockedA phone is freed.udid, host
device_progressA step of setting a session up on a phone starts, such as Allocating node resources..., or setup ends (an empty progress).udid, host, progress

Sessions​

session_started, session_command and healing_event are sent for the sessions the server records: with enableDashboard on, and not for a cloud provider's sessions. session_stopped is sent for those, and also for every session a hub routes to a node or a cloud provider, whatever enableDashboard says, since the hub keeps a record of each. bug_report_generated is sent whatever enableDashboard says.

EventSent whenPayload
session_startedA session has started on a phone.The session's record: id, name, build_name, status (running), device_udid, device_name, device_platform, device_version, node_id, user_id, api_key_id, trace_id, video_recording_enabled, and the session's capabilities.
session_commandA command of the session has finished.A summary of it: session_id, command_name, method, is_success, is_error, duration (milliseconds), is_healed, original_strategy, original_selector, healed_strategy, healed_selector, healing_confidence, healing_tier, trace_id, span_id.
healing_eventA find was healed.id, sessionId, deviceUdid, deviceName, devicePlatform, commandName, originalSelector, healedSelector, confidence, tier, isSuccess, createdAt
session_stoppedA session ended, or a test set its result with xenon: setSessionStatus.id, status (success or failed), failure_reason
bug_report_generatedSomeone downloaded a session's bug report.sessionId, mode, durationMs, warnings

session_command doesn't carry what the command typed or answered. Those stay in the session's own record: read them with GET /xenon/api/session/<id>/session_log, which follows the session's visibility.

Selector health​

See Selector health for what each status means. These go to admins, and to a Member who may see the selector on the Selector health page.

EventSent when
selector_fixedSomeone marked a selector fixed: it is now Being verified.
selector_progressThe verification's count of clean builds changed, still short of 3.
selector_resolvedThe verification moved a selector to Fixed after 3 clean builds.
selector_regressedA selector being verified, or fixed, healed again and went back to To fix.
selector_cancelledSomeone cancelled a selector's verification: it went back to To fix.
selector_mutedSomeone muted a selector.
selector_unmutedSomeone unmuted a selector: it went back to To fix.

Each carries the selector's record: id, original_strategy, original_selector, status and clean_builds_count. selector_progress and selector_resolved add resolved_at; the others add fixed_at, fixed_by_api_key, resolved_at, muted_at, muted_by_api_key, regression_count, last_event_at, createdAt and updatedAt. The two _by_api_key fields hold the id of the API key that marked the selector fixed or muted it, and are empty when it wasn't done with an API key, such as from the dashboard. status is active for To fix, pending for Being verified, resolved for Fixed and muted for Muted. When a cancel or an unmute leaves no record, the event carries only original_strategy, original_selector and status: "deleted".

Network capture​

See Network interceptor. These go to admins only. A hub sends them for a capture on its own phones; a capture on a node's phone sends none, since a node has no live events.

EventSent whenPayload
interceptor_session_startedA session's network capture has started.sessionId, host, port (the capture's proxy)
interceptor_requestA request was captured: answered, or failed.The captured request: id, sessionId, ts (milliseconds since 1970), method, url, host, path, reqHeaders, reqBody, resStatus (-1 for a request that failed), resHeaders, resBody, durationMs, mocked, modified, mockId, commandHint (the test command it followed), and failed, failureReason and failureKind for a failure.
interceptor_session_stoppedThe session ended and its capture was saved.sessionId

Recordings​

See Recordings.

EventSent whenPayload
recording_startedA recording started, of one phone or of several together.groupId, recordings (each id and udid), startedAt
recording_stoppedA recording stopped.groupId, recordings (each id, udid, status, durationMs, sizeBytes)
recording_failedOne phone's recording failed.groupId, recordingId, udid, reason
recording_bookmark_addedSomeone added a bookmark.groupId, bookmark
recording_annotation_addedSomeone added an annotation.groupId, annotation

Nodes​

EventSent whenPayload
node_connectedA node connected to this hub's stream and registered.host
node_disconnectedThat connection closed.host

From a client​

EventSent byPayload
register_dashboardA dashboard client, to receive the events aboveNone
register_nodeA node, after connectinghost
handshakeA node, after connecting. Optional.version, host, timestamp

Protocol version​

The stream's protocol version is 1.0.0. A client may send it in handshake. If the version differs from the server's, the server logs the mismatch and disconnects the client. Nodes send it; the dashboard doesn't.

The event log​

The server also writes the events it sends to dashboards into its database, unless XENON_EVENT_LOG=off: each session_command as the summary above, and no interceptor_request. See The event log.