# Device telemetry (what the device app reports, and where it goes)

What the Endpoint device app — the downloadable macOS agent — sends once a
device is enrolled, how each report is stored, and how to get it back out.

**This is not the Chrome proctoring extension.** The proctoring product has its
own extension, its own API and its own docs. Everything below is about the
device-monitoring product only. Enrollment and credentials — QR enrollment,
`POST /endpoint/register`, API keys, the `endpoint.registered` webhook — are
covered by the [Partner API doc](/docs/partners/partner-api); this doc starts
where that one ends, at an enrolled device holding a `device_token`.

## The shape of it

```
Enrolled device (see the Partner API doc for how it got here)
  → wss://<server>/cable                          (ActionCable WebSocket)
      X-Device-Token: <device_token>              (preferred auth)
  → subscribe {"channel":"EndpointChannel",...}
  ← welcome                                       (effective config: blacklist, whitelist, work_hours)
  → report frames: screenshot, process_list, app_change, app_violation,
                   browser_url, motion_detected, no_motion, monitoring_status
      stored as endpoint_events / endpoint_violations rows + screenshot files
  ← violation_acknowledged / app_list_update
Operator, any time
  → /endpoint_console                             (dashboard, devices, screenshots)
Partner backend, any time
  → GET /partner/api/v1/endpoints                        (enrollment state)
  → GET /partner/api/v1/endpoints/:id/events             (telemetry pull)
  → GET /partner/api/v1/endpoints/:id/violations         (telemetry pull)
  → GET /partner/api/v1/endpoints/:id/events/:event_id/screenshot
```

## 1. Connection and authentication

The device opens an ActionCable WebSocket to `wss://<server>/cable` and
authenticates the handshake with a request header:

- **`X-Device-Token`** (preferred) — the `device_token` minted by
  `POST /endpoint/register` or `POST /endpoint/login`. An unknown token is
  rejected.
- **`X-User-Id`** (legacy) — the scanned QR enrollment token, which the shipped
  macOS 1.6.0 client presents instead of the device token. It is accepted only
  when it matches an enrolled, non-disabled device; every acceptance writes a
  `[DEPRECATION]` log line, and the path will be removed once client 1.7 sends
  `X-Device-Token` (#166).

| Handshake | Result |
|---|---|
| `X-Device-Token` matching a device | accepted; device marked online |
| `X-Device-Token` matching nothing | rejected |
| `X-User-Id` matching an enrolled, non-disabled device | accepted, deprecation-logged |
| `X-User-Id` matching nothing, or a disabled device | rejected |
| neither header | rejected |

A device is marked `online` at connect and `offline` at disconnect.

## 2. The channel protocol

Subscribe with the identifier
`{"channel":"EndpointChannel","user_id":"<id>"}`. Identity comes from the
authenticated connection, never from the identifier — the `user_id` field is
carried for wire compatibility with the shipped client.

On subscribe the server transmits a `welcome` config frame — the device's
effective configuration, org settings merged under per-device overrides
(device values win):

```json
{
  "type": "welcome",
  "message": "Connected to Endpoint channel",
  "blacklist": ["Discord", "Slack"],
  "whitelist": [],
  "work_hours": {
    "enabled": true,
    "start_time": "9:00 AM",
    "end_time": "5:00 PM",
    "work_days": [1, 2, 3, 4, 5]
  }
}
```

### Report framing

Reports are ActionCable `message` commands whose `data` field is a
**JSON-encoded string** (not an object) carrying `{"action":"report","data":{...}}`:

```json
{
  "command": "message",
  "identifier": "{\"channel\":\"EndpointChannel\",\"user_id\":\"<id>\"}",
  "data": "{\"action\":\"report\",\"data\":{\"type\":\"app_change\",\"from_app\":\"Safari\",\"to_app\":\"Slack\",\"timestamp\":\"2026-08-28T12:00:00Z\"}}"
}
```

The inner `data` object's `type` selects the telemetry type (section 3).

### Device → server: update_work_hours

The device may also send `{"action":"update_work_hours","work_hours":{...}}`
(same string-encoded framing). Only the keys `enabled`, `start_time`,
`end_time` and `work_days` are accepted; they merge into the device's
per-device override, which then wins over org settings in `effective_config`.

### Server → device frames

| Frame | Payload | When |
|---|---|---|
| `violation_acknowledged` | `{"type":"violation_acknowledged","violation_id":42,"timestamp":"..."}` | echoed to the reporting device after each stored `app_violation` |
| `app_list_update` | `{"type":"app_list_update","blacklist":[...],"whitelist":[...]}` | defined, but nothing in the server triggers it today — config changes reach a device via the `welcome` frame at its next connect |

## 3. Telemetry catalogue

| Type | Cadence | Payload fields | Stored as |
|---|---|---|---|
| `screenshot` | every 600 s while monitoring | `timestamp`, `image_data`, `display_width`, `display_height` | `endpoint_events` row + bytes in `endpoint_screenshots` |
| `process_list` | every 600 s | `timestamp`, `processes[]`, `total_count` | `endpoint_events` row |
| `app_change` | on focus change | `from_app`, `to_app`, `timestamp` | `endpoint_events` row |
| `app_violation` | on a blacklisted app | `app_name`, `bundle_id`, `timestamp` | `endpoint_violations` row |
| `browser_url` | on browser URL change | `url`, `browser`, `timestamp` | `endpoint_events` row |
| `motion_detected` | 60 s check cycle | `timestamp` | **no row** — updates last-activity only |
| `no_motion` | after >900 s without motion | `duration`, `timestamp` | `endpoint_events` row |
| `monitoring_status` | on start/stop | `status`, `timestamp` | device status flip + `endpoint_events` row |

Unknown types are logged server-side and dropped without error. Every report,
of any type, refreshes the device's `last_seen_at`.

### screenshot

Sent every 600 seconds while monitoring is active. `image_data` is a base64
JPEG of the main display at 0.5 compression quality. Capture (both screenshots
and the motion check below) pauses while a video-conferencing app is running
and resumes when it exits.

```json
{
  "type": "screenshot",
  "timestamp": "2026-08-28T12:00:00Z",
  "image_data": "<base64 JPEG>",
  "display_width": 2560,
  "display_height": 1440
}
```

Stored as an `endpoint_events` row whose `event_data` holds the display
dimensions and `file_size_bytes`, plus an `endpoint_screenshots` row holding
the decoded JPEG bytes themselves. The bytes go to the primary database — not
to the ingesting pod's local disk — so any web pod can serve them (#196; the
app runs multiple dynos with separate ephemeral filesystems). A frame whose
decoded payload exceeds 10 MB, or does not decode at all, is dropped with a
server-side warning and no rows.

### process_list

Sent every 600 seconds: every running application with a localized name.

```json
{
  "type": "process_list",
  "timestamp": "2026-08-28T12:00:00Z",
  "processes": [
    {
      "name": "Safari",
      "bundle_id": "com.apple.Safari",
      "pid": 4242,
      "is_active": true,
      "is_hidden": false,
      "is_terminated": false
    }
  ],
  "total_count": 1
}
```

### app_change

Sent when application focus changes (observed via workspace notifications,
with a 30-second fallback poll).

```json
{
  "type": "app_change",
  "from_app": "Safari",
  "to_app": "Slack",
  "timestamp": "2026-08-28T12:00:00Z"
}
```

### app_violation

Sent when a blacklisted app becomes active. Stored as an
`endpoint_violations` row (severity `medium`); the server echoes
`violation_acknowledged` back to the device.

```json
{
  "type": "app_violation",
  "app_name": "Discord",
  "bundle_id": "com.hnc.Discord",
  "timestamp": "2026-08-28T12:00:00Z"
}
```

### browser_url

Sent when the active browser's URL changes.

```json
{
  "type": "browser_url",
  "url": "https://example.com/page",
  "browser": "Safari",
  "timestamp": "2026-08-28T12:00:00Z"
}
```

### motion_detected

The client hashes a screen capture every 60 seconds; a changed hash means
motion. Updates the device's last-activity timestamp only — **no row is
stored**.

```json
{
  "type": "motion_detected",
  "timestamp": "2026-08-28T12:00:00Z"
}
```

### no_motion

Sent when the screen has not changed for more than 900 seconds. `duration` is
the elapsed seconds, as an integer.

```json
{
  "type": "no_motion",
  "duration": 947,
  "timestamp": "2026-08-28T12:00:00Z"
}
```

### monitoring_status

Sent when the user starts or stops monitoring. `"active"` flips the device
`online`, anything else flips it `offline` (a `disabled` device is never
touched); an `endpoint_events` row is stored either way.

```json
{
  "type": "monitoring_status",
  "status": "active",
  "timestamp": "2026-08-28T12:00:00Z"
}
```

## 4. Storage and retention

Three tables plus a file store:

| Where | What |
|---|---|
| `endpoint_devices` | one row per device: hostname, serial_number, os_version, app_version, `status` (`registered` \| `online` \| `offline` \| `disabled`), `last_seen_at`, `last_ip`, per-device config overrides. `serial_number` is the hardware serial (IOKit `IOPlatformSerialNumber`) reported by client 1.7+ at registration/login; it is null for devices registered by client <= 1.6 until they re-register with 1.7, and a serial-less payload never clears a previously captured value. `last_ip` is the source IP of the most recent registration/login; the partner API derives an approximate `location` from it at read time via a local GeoIP lookup (country-level with the default DB-IP database, city-level if a City MMDB is configured through `GEOIP_DB_PATH`; null for private IPs or when unresolvable — see the [Partner API doc](/docs/partners/partner-api)). IP Geolocation by [DB-IP](https://db-ip.com) |
| `endpoint_events` | one row per stored report (`screenshot`, `process_list`, `app_change`, `browser_url`, `no_motion`, `monitoring_status`) |
| `endpoint_violations` | one row per `app_violation` |
| `endpoint_screenshots` | one row per screenshot event: the JPEG bytes, `content_type`, `byte_size`. Bytes live in the primary database so every web pod can serve them; nothing is written to pod-local disk (#196) |

If the org configures S3 in the console, `Endpoint::UploadScreenshotJob`
moves each screenshot's bytes to that bucket under
`<prefix>endpoint/screenshots/<device_id>/<file>` and then destroys the
database blob — the only deletion anywhere in the pipeline. The console
still serves offloaded screenshots by reading them back from that bucket.

Retention is currently indefinite; this is not a contractual guarantee — a
retention policy is tracked in #167. Until it lands, note the growth math:
a monitored device produces one screenshot every 600 s at roughly 500 KB, so
about **500 KB of database growth per device per 10 minutes** while
monitoring is active. Orgs that configure S3 offload move that growth into
their own bucket almost immediately; without offload it accrues in the
primary database until #167's policy prunes it.

Screenshots recorded before the database migration (#196) were files on one
web pod's ephemeral disk. Any such file that survived is still served while
it exists; once it is gone the event reports `available: false` and its
bytes are unrecoverable.

## 5. Getting it back out

The Endpoint console at `/endpoint_console` (session login) is the primary
surface:

- dashboard — device counts and recent events
- device list, and a per-device page with recent events, violations and a
  screenshot gallery (`GET /endpoint_console/devices/:id/screenshot/:event_id`
  serves the image bytes, from the database or from the org's S3 bucket after
  offload)
- disable / enable a device
- org settings — blacklist, whitelist, work hours
- QR enrollment

The programmatic surface is the Partner API: `GET /partner/api/v1/endpoints`
plus the `endpoint.registered` webhook cover enrollment state — including each
device's `last_ip` and the approximate IP-derived `location`, which live on
the device row there, not on individual events (documented in the
[Partner API doc](/docs/partners/partner-api)) — and the telemetry pull
routes below cover everything a device has reported (issue #165). All of them
take the same `Authorization: Bearer <api key>` header, share the same
per-key 60 requests/minute throttle, and are structurally scoped: another
partner's device or event answers a 404 `{"error": {...}}` envelope whatever
parameters arrive.

### Events

    GET /partner/api/v1/endpoints/:id/events
    Authorization: Bearer <api key>

Filters and pagination, all optional query parameters:

| Parameter | Meaning |
|---|---|
| `event_type` | one of the telemetry catalogue's stored types (`screenshot`, `process_list`, `app_change`, `browser_url`, `no_motion`, `monitoring_status`) |
| `occurred_from` / `occurred_to` | ISO8601 bounds on `occurred_at`, each side independent; a malformed timestamp is a 400 `invalid_parameter` envelope |
| `page` / `per_page` | page number (default 1) and page size (default 100, max 500) |

Events come in ascending id order — stable across requests, so pages never
shuffle. `total_count` is the filtered total; keep fetching pages until
`page * per_page >= total_count`.

    {
      "events": [
        {
          "id": 91,
          "event_type": "screenshot",
          "occurred_at": "2026-08-28T12:00:00Z",
          "data": {"display_width": 2560, "display_height": 1440, "file_size_bytes": 48213},
          "screenshot": {
            "available": true,
            "url": "/partner/api/v1/endpoints/42/events/91/screenshot"
          }
        },
        {
          "id": 92,
          "event_type": "app_change",
          "occurred_at": "2026-08-28T12:00:30Z",
          "data": {"from_app": "Safari", "to_app": "Slack"}
        }
      ],
      "page": 1,
      "per_page": 100,
      "total_count": 2
    }

`data` is the stored `event_data` for the type, per the telemetry catalogue
above. Screenshot events additionally carry a `screenshot` availability
object — never a server filesystem path:

| `screenshot` | Meaning |
|---|---|
| `{"available": true, "url": "/partner/api/v1/..."}` | the bytes are in the server's database; GET that path (with your API key) for them |
| `{"available": true, "url": "s3://bucket/key"}` | offloaded to your configured S3 bucket — the bytes are already yours; read them there |
| `{"available": false}` | no bytes anywhere (never captured, or a pre-#196 pod-local file is gone) |

`available: true` with an API url is a serving guarantee, not a guess: the
list applies exactly the checks the bytes route applies, so a pull of a
listed-available screenshot cannot 404 (#196's secondary defect).

### Violations

    GET /partner/api/v1/endpoints/:id/violations
    Authorization: Bearer <api key>

Same pagination contract (`page`, `per_page`, `total_count`, ascending id
order). One row per `app_violation` report:

    {
      "violations": [
        {
          "id": 7,
          "violation_type": "blacklisted_app",
          "app_name": "Discord",
          "bundle_id": "com.hnc.Discord",
          "severity": "medium",
          "occurred_at": "2026-08-28T12:00:00Z",
          "acknowledged": true,
          "acknowledged_at": "2026-08-28T12:00:01Z",
          "reviewed": false,
          "recorded_at": "2026-08-28T12:00:01Z"
        }
      ],
      "page": 1,
      "per_page": 100,
      "total_count": 1
    }

### Screenshot bytes

    GET /partner/api/v1/endpoints/:id/events/:event_id/screenshot
    Authorization: Bearer <api key>

| Status | Meaning |
|---|---|
| 200 | the JPEG bytes (`Content-Type: image/jpeg`), served from the database until offload |
| 404 | `screenshot_not_found` envelope — the event has no screenshot bytes anywhere (routing/scoping misses are a `not_found` envelope) |
| 410 | `screenshot_offloaded` envelope — the file was uploaded to your configured S3 bucket and deleted locally; the `s3://` URL is in the message and in `error.details.url`. The bytes are already in your bucket — read them there, this route will not proxy them |

The 410 is deliberate: once `Endpoint::UploadScreenshotJob` has offloaded a
screenshot to the partner's own bucket, the durable copy is the partner's,
and re-serving it through this API would just proxy their own storage back
at them.

Screenshot bytes that have not been offloaded live in the primary database
and survive restarts and are served from every web pod — the pre-#196
failure mode, where the bytes existed only on the one pod that held the
device's WebSocket, is gone. Growth is bounded by the retention policy
tracked in #167 (about 500 KB per device per 10 minutes of monitoring until
then); configure S3 offload to own the durable copies immediately.

One honest limitation: devices enrolled through the Partner API currently
deliver **enrollment state only**. The shipped 1.6.0 client authenticates the
WebSocket with its enrollment token as `X-User-Id`, and partner-owned device
rows deliberately carry no such value — so their connections are rejected,
no telemetry is stored, and their `status` stays `registered` (it never flips
`online`/`offline`). This resolves when client 1.7 authenticates with
`X-Device-Token` (#166).

## Traps

- **The report frame is nested.** The channel reads `data.data` from the
  decoded frame; a flat `{"action":"report","type":"screenshot",...}` frame is
  silently ignored — no error, no row.
- **`motion_detected` stores no row.** It only refreshes the device's
  last-activity timestamp; don't search `endpoint_events` for it. Absence of
  motion is what gets a row (`no_motion`).
- **Offloaded screenshots disappear from the database by design.** After the
  S3 upload succeeds the `endpoint_screenshots` blob is destroyed; the
  `endpoint_events` row keeps the S3 location and the console reads the bytes
  back from the bucket.
- **Unknown event types are dropped without error.** A typo'd `type` is logged
  server-side and discarded; the device gets no failure signal.
- **The Linux client delivers none of this today.** It posts to an `/api/*`
  surface this server does not implement, so the telemetry above comes from
  the macOS app only.

_Last verified: 2026-08-28 against main._
