MobiLauncher

Telemetry

Tier: Enterprise · feature flag telemetryExport

Telemetry forwards a small stream of structured events from each kiosk to your sink (Datadog, Splunk, your-own-S3-via-Lambda, whatever). Use it to answer:

  • "Did the fleet pick up the new config?" → remoteConfigPoll events
  • "Why did this kiosk show an error?" → configError, licenseError
  • "Who's launching what and when?" → tileLaunch
  • "Did our reboot script actually run?" → agentAction
  • "Who entered the admin PIN at 3am?" → audit events

1. Enable the feature

In the Portal Builder → Features tab → Enterprise → toggle Telemetry export. The flag must also be present in the device's license.

2. Build the sink

The launcher does:

POST https://telemetry.example.com/ingest
Content-Type: application/json
Authorization: Bearer <resolved auth token>

{
  "events": [
    { "category": "tileLaunch", "name": "chrome",
      "ts": 1716661200000, "attrs": { "packageName": "com.android.chrome" } },
    { "category": "configError", "name": "schema",
      "ts": 1716661260000, "attrs": { "message": "..." } }
  ]
}

Return any 2xx to acknowledge — the launcher removes the batch from its queue. Anything else triggers retry with backoff (except 401/403 which pause until the auth token is refreshed).

Minimum sink (Node / Express sample)

import express from 'express';

const app = express();
app.use(express.json({ limit: '1mb' }));

app.post('/ingest', (req, res) => {
  const events = req.body.events ?? [];
  // Replace with your storage / forwarder.
  for (const event of events) {
    console.log('[telemetry]', JSON.stringify(event));
  }
  res.status(202).send();
});

app.listen(8787);

Forward-to-Datadog sample (Cloudflare Worker)

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    if (req.method !== 'POST') return new Response('', { status: 405 });
    const expected = `Bearer ${env.MOBILAUNCHER_INGEST_TOKEN}`;
    if (req.headers.get('authorization') !== expected) {
      return new Response('unauthorized', { status: 401 });
    }
    const { events } = (await req.json()) as { events: TelemetryEvent[] };
    // Bucket into Datadog's intake shape.
    const ddBody = events.map((e) => ({
      ddsource: 'mobilauncher',
      ddtags: `category:${e.category},name:${e.name}`,
      timestamp: e.ts,
      message: JSON.stringify(e.attrs ?? {}),
    }));
    await fetch('https://http-intake.logs.datadoghq.com/api/v2/logs', {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        'dd-api-key': env.DATADOG_API_KEY,
      },
      body: JSON.stringify(ddBody),
    });
    return new Response('', { status: 204 });
  },
};

3. Configure in the Builder

Portal → Launcher tab → Telemetry (Ent) section:

Field Notes
Endpoint Full https://… URL.
Auth token ref A secret://… reference (e.g., secret://acme/telemetry). The MDM build pipeline swaps the ref for the real token at deploy time. Never put raw tokens in the config — they'd ride along in support emails and Slack threads.
Events Multi-select of event categories you want exported. Disabled categories are dropped at the enqueue step on-device — they don't even touch the queue.

The corresponding config block:

{
  "telemetry": {
    "enabled": true,
    "endpoint": "https://telemetry.acme.test/ingest",
    "authTokenRef": "secret://acme/telemetry",
    "events": [
      "tileLaunch",
      "configError",
      "licenseError",
      "remoteConfigPoll",
      "audit"
    ]
  }
}

4. Event reference

Category Fires when Attrs
tileLaunch A tile is tapped + the agent host dispatches it { tileId, type, label, packageName?, url?, action? }
profileSwitch Active profile changes (schedule or manual) { from, to, reason }
configError Schema / parse / asset hash failure { kind, message }
licenseError License missing, expired, or tenant mismatch { kind }
agentAction MDM agent dispatches a script / reboot / sync { action, ok, error? }
remoteConfigPoll Remote-config poll completes { status, etag?, changed? }
audit Admin gesture, PIN entry, overlay open { action, succeeded }

The wire shape per event:

interface WireEvent {
  category: string;   // one of the above
  name: string;       // category-specific sub-name
  ts: number;         // wall-clock ms epoch
  attrs?: Record<string, unknown>;
}

5. What runs on-device

The runtime's TelemetryEmitter (src/runtime/features/telemetry.ts) does:

  • Enqueue — drops events outside enabledCategories; otherwise pushes onto an in-memory queue (default cap 1000 events, oldest- dropped when exceeded). Persists the queue to local storage after every enqueue so a power-cycle doesn't lose events.
  • Flush every 60s — batches the queue, POSTs as a single request.
  • 2xx — removes the batch from the queue, persists the now-shorter queue, resets backoff.
  • 5xx / network error — keeps the batch in the queue, backs off (1s → 2s → 4s → … → 60s cap), retries on the next tick after the backoff window.
  • 401 / 403 — pauses retries entirely; events stay queued. Resumes when setAuthToken(...) is called (license re-validation triggers this).
  • Age-out — events older than maxAgeMs (default 24h) are dropped on flush. Age is tracked in monotonic time so NTP clock jumps don't age-out the queue prematurely.

Cold start: the persisted queue is reloaded; stale events (older than 24h by wall-clock) are dropped; the rest line up for the next flush.

6. Diagnostics

  • Diagnostic overlay (Pro diagnosticOverlay) — Telemetry section shows queue depth, last flush result, time since last successful flush.
  • Self-telemetry — the emitter does NOT emit events about itself (no infinite loops). Use onFlush(report) from your custom build if you want a hook.
  • Portal Devices panel — same last_seen_at heartbeat as remote-config; if a fleet's last_seen_at is fresh but you're getting no telemetry events server-side, the sink is the suspect.

7. Gotchas

  • Don't ship raw tokens in the config. Always use secret://… refs. The launcher resolves them at boot via your MDM's secret-injection pipeline. If you push a config with a plaintext token, it'll end up in the Portal preview iframe / network logs / customer support forwards.
  • CORS matters from a browser kiosk. If the launcher is being loaded as a managed-browser URL (vs. the sideload APK), your sink must include Access-Control-Allow-Origin for the launcher origin. The APK runs in WebView and doesn't enforce CORS.
  • Don't size the events array too large. The schema accepts any subset of categories, but enabling every category at boot × realistic launch volume could send 60+ events/minute per device. Start with configError + licenseError + audit + remoteConfigPoll (low-volume + high-value) and add categories as you scale up capacity on the sink side.
  • Auth-pause is silent in the queue. When the sink returns 401, events keep enqueueing until maxAgeMs evicts them. Watch for the flushAuthError onFlush report — that's your signal to rotate the token.

8. Test fire

The Portal → Launcher → Telemetry block has a "Send test event" button. It synthesizes one audit / portalTestFire event and POSTs the same request shape your sink will see on-device. Confirms:

  • The endpoint URL resolves + is reachable from the public internet.
  • Your sink accepts the Bearer <token> you configured.
  • The sink returns a 2xx.

If the test event fires successfully, real on-device events will follow the same path.