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?" →
remoteConfigPollevents - "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?" →
auditevents
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) —Telemetrysection 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_atheartbeat as remote-config; if a fleet'slast_seen_atis 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-Originfor 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
maxAgeMsevicts them. Watch for theflushAuthErroronFlushreport — 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.