Skip to content

Device data collection โ€‹

What is this? โ€‹

A decision brief, not a proposal. The operator asked for it on 2026-08-30: "Device data collection > I need waaay more info in relation to this."

She is being asked whether Lantern starts collecting device data so the Platform and Device dashboard can exist. She declined to answer the one-line version. This document is the long version.

It deliberately does not build toward a default. The privacy axioms make this a real fork, and the option to collect nothing and delete the route is presented as a first-class answer rather than a fallback.

What is true today, measured rather than assumed? โ€‹

Zero events carry any device data, and that is structural rather than incidental. โ€‹

  • lantern-app-dev.analytics.events in us-central1 has twelve columns and none of them is device related: event_id, event_name, event_tier, user_id, service_id, entity_id, entity_type, metadata, timestamp, environment, client_event_time, session_id.
  • The schema is defined in tooling/schemas/bigquery-events.json and created by tooling/scripts/setup-bigquery.sh. There is no terraform and no DDL file.
  • So the fields are not empty, they do not exist. That is a stronger starting position than #999 claimed.

The 1,597 figure was correct when measured and is now 1,643. โ€‹

Re-verified 2026-08-30 against us-central1, read-only, two queries, 10 MB billed each (the BigQuery per-table minimum, roughly $0.0001 total).

Measure#999 claimedActual, same 60-day windowVerdict
Events (60d as of 2026-08-28)1,5971,643Off by 46
Distinct users1010Confirmed
Distinct sessions7373Confirmed
Events carrying session_id777777Confirmed
Events carrying any device, platform, browser, OS or user-agent field00Confirmed
  • The three matching figures are the tell. Users, sessions and session_id coverage had all saturated for that window while the event count kept climbing through 2026-08-28. 46 events landed after the original query ran. Nobody was wrong.
  • For scale: 3,213 rows exist across all 43 retained partitions, 20 distinct users, first retained day 2026-06-01.

The only place device data could hide is metadata, and it is not hiding there. โ€‹

  • metadata is a JSON column whose own description says "no PII", and the ingest schema types it as record(string -> string | number | boolean), so arbitrary client-supplied keys pass validation today.
  • A regex sweep for user_agent, device, browser, os, platform, screen, viewport, mobile, android, iphone, ios over every retained row returned 6 apparent hits. All 6 are the same test interest string, literally named "Mobile loop A", on interest_added events.
  • The finding underneath the false positive matters more than the false positive. Nothing rejects a client that starts putting {"userAgent": "..."} into metadata. The absence of device data today is the absence of client code that sends it, not a guardrail. That is policy, not architecture.

A user-agent is already retained, just not in the warehouse. โ€‹

  • Every HTTP request to the analytics service carries a User-Agent header, as all HTTP requests do. No application code reads it: a repo-wide grep for req.headers['user-agent'] across services/, apps/ and packages/ returns zero hits, and the analytics service reads only req.headers.authorization and req.headers.origin.
  • But services/api/analytics/src/index.js uses pinoHttp({ logger, redact: PINO_REDACT_CONFIG }), and pino-http's default request serializer emits the whole headers object. PINO_REDACT_CONFIG in packages/shared/services/index.js redacts authorization, cookie, set-cookie and a list of PII body and query fields. It does not redact user-agent. Cloud Run's own httpRequest logs carry it independently.
  • So "we collect no device data" is not accurate today. The accurate statement is "we store no device data in the warehouse, and we retain raw user-agent strings in Cloud Logging under a retention nobody has examined." That is a standing finding this decision did not create and does not resolve. See What is still open.

What exactly would be collected, tier by tier? โ€‹

Four tiers, minimal to maximal. Every field is named, and every field is tied below to a question a person actually asked.

Tier 0: nothing new. โ€‹

FieldTypeWhere it comes from
(none)
  • The warehouse gains no column. The wire contract does not change. apps/web/src/lib/flash.js sends exactly what it sends now: eventName, eventTime, sessionId, entityId, entityType, metadata.
  • Tier 0 is not the same as "the status quo is clean." The Cloud Logging user-agent above and the privacy policy gap below both exist at Tier 0 and both need answering regardless.

Tier 1: one derived enum, five possible values. โ€‹

FieldTypeValuesDerived where
platformSTRINGios, android, other_mobile, desktop, unknownServer side at ingest, from the User-Agent header that already arrives
  • The header is parsed and discarded. Only the enum is written. The client sends nothing new, so the wire contract, the zod schema in services/api/analytics/src/routes/tracking.js and the Flash SDK are all unchanged.
  • Cardinality is 5. A five-valued column cannot carry a fingerprint, because five buckets over any population is a partition, not an identifier.
  • The alternative derivation already exists client-side. apps/web/src/lib/deviceOptimization.js already computes isMobile(), isLowMemoryDevice() and getDeviceConfig() from navigator, and logDeviceInfo() gathers userAgent, deviceMemory and hardwareConcurrency. All of it is devLog-only and never leaves the device. Deriving the enum there and sending it is equivalent in output and strictly worse in trust, because the client can lie.

Tier 2: parsed enums, no free text. โ€‹

Everything in Tier 1, plus:

FieldTypeValues
os_familySTRINGios, android, macos, windows, linux, other
os_majorINT64Major version only. 17, never 17.4.1
browser_familySTRINGsafari, chrome, firefox, edge, samsung_internet, other
browser_majorINT64Major version only
form_factorSTRINGmobile, tablet, desktop
  • No raw string is ever stored. Every field is a bounded enum or a coarsened integer.
  • The risk is not in any one field, it is in the combination. Six bounded fields multiply to roughly 5 x 6 x 10 x 6 x 10 x 3, which is on the order of fifty thousand possible combinations. At ten users, each user's combination is almost certainly unique. A per-user fingerprint assembled out of individually harmless enums is still a per-user fingerprint.

Tier 3: raw string plus client-measured signals. โ€‹

Everything in Tier 2, plus:

FieldTypeNote
user_agentSTRINGThe raw header, unparsed
screen_width, screen_heightINT64Physical screen
device_pixel_ratioFLOAT64
viewport_width, viewport_heightINT64Rendered viewport
localeSTRINGe.g. en-US
timezoneSTRINGIANA name
device_memoryFLOAT64navigator.deviceMemory
hardware_concurrencyINT64navigator.hardwareConcurrency
  • This is the shape a general-purpose analytics vendor collects. It requires a client change, because most of these values only exist in the browser.
  • This is a browser fingerprint in the literal, technical sense. It is the field set fingerprinting research uses, and it is what a fingerprint is made of.

What does each tier actually let the dashboard show? โ€‹

Every field has to answer a question someone asked. A field that answers no asked question does not belong in any tier, and applying that test is what makes Tier 3 collapse.

FieldThe question it answersWho asked, and where
platformDo Alpha users carry iPhones, so is iOS PWA push part of the experiment?#722: iOS PWA push verification, an open P1 row in docs/business/launches/ALPHA.md
platformPush is built for Android and desktop Chrome. What share of users cannot receive one?The Push notifications row in ALPHA.md
platformWhich platform should the next design pass prioritise?Ongoing design work
os_family, os_majorCan we stop supporting iOS 16, or is somebody still on it?Nobody has asked this yet
browser_familyThe PWA install prompt does not appear for some users. Which browser?Nobody has asked this yet
browser_majorIs this bug all of Safari or only Safari 17?Bug triage, hypothetically
form_factorWhich breakpoint deserves the design attention?Answerable from the design system's own breakpoints
user_agent (raw)Nothing that os_family + os_major + browser_family + browser_major does not already answerNobody
screen_*, viewport_*What viewport should responsive QA target?Nobody, and breakpoints are already chosen
locale, timezoneWhere and in what language are users?Nobody. Alpha is San Diego, invite-only, in English. Both fields are known a priori and add only entropy
device_memory, hardware_concurrencyIs this a low-memory device?Nobody. No dashboard row is specified for it

The raw user-agent answers no question the parsed enums do not. โ€‹

  • It is a superset representation of the Tier 2 fields, not a superset of the answers. Parsing it at ingest and storing the parse yields every answer; storing the original adds only the fingerprint.
  • That makes Tier 3 the easiest tier to rule out on its own merits, before any privacy argument: near-zero marginal value, maximal marginal risk.

Only one question on this list has actually been asked, and it is answerable without collecting anything. โ€‹

  • The iOS share question, from #722. Everything below platform in the table is a question somebody might ask later.
  • Building a collection pipeline for questions nobody has asked yet is how a fingerprint gets shipped by accident.

What is the privacy cost, specifically? โ€‹

The events table already stores user_id on every row, so the harm is not "linking a row to a person." โ€‹

  • That link exists today. analytics.events is clustered on event_name, user_id and carries the Firebase UID in plaintext. A device column would not reveal whose row it is.
  • Being precise about this matters, because the vague version of the objection is wrong and the specific version is serious.

A user-agent is a cross-context identifier. A Firebase UID is not. โ€‹

  • A UID is meaningless outside Lantern. It joins to nothing.
  • A user-agent string is the same string that person presents to every other server on the internet. Storing it creates a join key between Lantern's warehouse and any external dataset: someone's own server logs, an ad network, an ISP record.
  • This is the exact re-identification channel the sealed-identity work exists to close on the phone side. Per the privacy-architecture skill, the goal is that the server can go neither phone to userId nor userId to phone at rest.

Adding a fingerprint while sealing the phone channel is not a net privacy gain. โ€‹

  • The axiom is architecture is the privacy guarantee, not policy. A system that genuinely cannot produce data cannot be compelled to.
  • Sealing phoneHash to userId and then storing a durable device fingerprint beside venue and timestamp moves the identity channel rather than closing it. A subpoena that sealed identity defeats is answerable again if the warehouse holds a fingerprint plus a venue plus a timestamp.

Any column on analytics.events is automatically in scope for every consumer of that table. โ€‹

  • There is no per-field classification anywhere in the pipeline. Adding a column adds it to every downstream reader at once.
  • Known consumers today, from #874: the public analytics endpoint, merchant-facing reports, and the admin assistant's read_collection, which sends whole documents to an external LLM processor.
  • A device column would be the fifth ad hoc answer to "what may this surface emit, and to whom", which is precisely what #874 was filed to prevent.

At a ten-person alpha, k-anonymity of 3 is not anonymity in a cohort the operator personally knows. โ€‹

  • The canonical floor is K_ANON_DISPLAY_FLOOR = 3 in packages/shared/ads/delivery.js, re-exported as PRIVACY_MIN_GROUP_SIZE in services/api/analytics/src/config/analyticsConfig.js, applied by applyDisplayGate.
  • It is a display gate, not a collection gate. It governs what a dashboard renders. The per-event row still exists in BigQuery at full resolution, and every non-dashboard consumer reads the row.
  • ALPHA.md describes Alpha as "December 2026. Invite-only. San Diego, a small invited group plus one or two pilot venues." A bucket of exactly 3 people, in one city, at one or two venues, from a group she invited personally, is identifiable to her and to anyone who knows two of the three.
  • A small user base makes a weak identifier strong. Uniquely identifying one of ten people takes about 3.3 bits. A five-valued platform enum carries roughly 2.3 bits on its own.

The practical consequence, which makes Tiers 2 and 3 self-defeating. โ€‹

  • Tier 2's roughly fifty thousand combinations across ten users means essentially every cell has n = 1, so the k = 3 gate suppresses essentially every cell.
  • Tiers 2 and 3 buy a dashboard that our own privacy gate renders almost entirely blank during exactly the period it would be used. Full architectural cost, no product.
  • Tier 1's five buckets over ten users is the only tier that produces cells large enough to render at all, and even then a 7/2/1 split displays only the 7.

What does the retention model do with it? โ€‹

A column added to analytics.events inherits 90 days automatically, with no new configuration. โ€‹

Verified from BigQuery table metadata on 2026-08-30:

Propertyanalytics.events
Partitioned ontimestamp, DAY
Partition expiration7776000000 ms, which is 90 days
Partition filterREQUIRED
Clustered onevent_name, user_id
Locationus-central1
  • So Tier 1, 2 or 3 columns on this table expire with their partition. No separate retention policy is needed, and none should be invented.
  • The reader that renders this in the portal is apps/admin/src/admin/analytics/config/retentionModel.js, which reads BigQuery rather than restating it, so a new column needs no portal change either.

The rollup tables never expire, which is where device data would become permanent. โ€‹

  • analytics.event_counts_daily is partitioned on day with no expirationMs at all. Verified from its metadata directly. analytics.ad_delivery_daily is in the same family.
  • This is the trap. The base table is disciplined and the aggregates are forever. If a device dimension is ever added to a rollup, whether for the dashboard's own performance or by a later optimisation, it becomes permanent by default and nobody has to decide that.
  • Any tier above 0 must therefore carry an explicit rule: no device dimension in any rollup without an expiration set in the same change. This applies to the write-time aggregation alternative below more than anywhere else, because that option's entire output is a rollup.

Can this be decided independently of the data classification issue? โ€‹

The open question is #874: design(privacy): one data-classification and egress policy, before search, the assistant, and the apps platform each invent their own. It is unanswered, and it is explicitly scoping-only.

Tier 0 and Tier 1 are independent of it. Tiers 2 and 3 are downstream of it. โ€‹

TierIndependent of #874?Why
Tier 0YesAdds no field, so there is nothing to classify
Tier 1YesOne five-valued enum, no free text, no cross-context identifier. It is safe to emit to every existing consumer at once, so the per-consumer ceiling question does not arise. The existing k = 3 display gate already covers how it renders
Tier 2NoIts fields are safe for an internal admin dashboard and not safe for an external LLM or a merchant report. That per-field, per-consumer difference is the vocabulary #874 exists to define
Tier 3No, and it would prejudge itA raw fingerprint reaching read_collection and an external processor is the worst case #874 is trying to prevent, decided by default rather than on purpose

The test that produces that split: does the field's safe-to-emit answer differ by consumer? โ€‹

  • If one answer covers every consumer, no vocabulary is needed and the decision stands alone.
  • If the answer is "fine here, not fine there", the decision needs the classification scheme first, and making it now means inventing the fifth private answer #874 was filed to stop.

What are the alternatives to collecting anything? โ€‹

A. Derive coarse platform server-side and discard the header. โ€‹

  • This is Tier 1 stated as a technique. The header already arrives; parse it at ingest, write the enum, never store the string.
  • Best trust properties of any collecting option, because the client cannot inflate or fake the value beyond what it already controls.

B. Derive on the client and send the enum. โ€‹

  • The code already exists and already runs: isMobile() and getDeviceConfig() in apps/web/src/lib/deviceOptimization.js.
  • Same output as A, strictly worse trust, and it requires a wire-contract change that A does not. Prefer A over B.

C. Aggregate at write time so no per-event device field is ever stored. โ€‹

  • Increment a (day, platform) counter in a rollup carrying no user_id and no event_id. The per-user device fact never exists at rest.
  • This is the option the axioms point at, because it is architectural rather than procedural: the raw fact cannot leak, cannot be subpoenaed, and cannot reach the assistant's external LLM, since it is destroyed at ingest rather than filtered on the way out.
  • Costs: no drill-down and no retroactive re-cut, ever. And it lands in a rollup, so it must set an explicit partition expiration or it is permanent, per the trap above.

D. Sample. โ€‹

  • Collect from a fraction of events or users.
  • Not appropriate at this scale. At ten users, sampling destroys the signal without meaningfully reducing the fingerprint for whoever is sampled. It is a tool for large populations, and it would make the k = 3 suppression strictly worse.

E. Ask the ten people. โ€‹

  • Alpha is invite-only with a cohort the operator recruits personally. One question in the invite, or one message, answers the iOS share question.
  • Higher accuracy than user-agent sniffing, which mis-detects iPadOS as macOS, cannot see which browser a PWA was installed from, and is degraded by every browser vendor's ongoing UA reduction work.
  • Zero collection, zero retention, zero schema change, and it is available today.

F. Delete the tab. โ€‹

  • Remove the platform entry from DASHBOARD_TABS in apps/admin/src/admin/analytics/DashboardTabs.jsx and delete the route and its Scaffold.
  • This is a legitimate outcome, not a failure. #999's own acceptance criterion already says so: "Each route either renders a real dashboard, or is removed from the nav rather than left as a placeholder for a fourth month."
  • The route has been a placeholder since 2026-04-27, which is 125 days as of 2026-08-30. It is reversible at any time.

What would we have to tell users? โ€‹

The privacy policy is already wrong about this, before any tier is chosen. โ€‹

  • The user-facing policy is apps/web/src/screens/legal/PrivacyPage.jsx, route #/privacy, marked "Draft, August 2026" and an explicit pre-counsel skeleton (#837).
  • Its "What we collect" section names a phone number, the lantern name, interests and vibe, and venue check-ins, and asserts "We do not collect photos, contact lists, or continuous location."
  • The file contains zero occurrences of "analytic", "telemetry", "tracking" or "event". Analytics events, session identifiers and ad impression and click telemetry are all collected today and none is disclosed.
  • So the disclosure gap exists now and this decision did not create it. Closing it is a precondition for any tier, including Tier 0.
  • Flash begins tracking at flash.init() unconditionally. No cookie banner, no consent gate, no opt-out flag exists in apps/web, apps/site or apps/admin.
  • The #/profile/privacy toggle is location history, not analytics.

What each tier adds to the disclosure burden. โ€‹

TierPolicy change neededIn-app copy change needed
Tier 0The missing analytics paragraph, which is owed regardlessNone
Tier 1One clause: we record which broad platform you useNone. A five-valued enum does not contradict any existing promise
Tier 2A clause naming OS and browser versionsProbably none, but it should be reviewed against the guarantees card
Tier 3A sentence that says, accurately, we store a device fingerprintYes. It sits badly beside the "Privacy Guarantees" card in apps/web/src/screens/profile/ProfileSettings.jsx, whose five affirmative bullets include "All sensitive data encrypted client-side"
  • The marketing "Why it's private" section at apps/site/index.html is prose, not policy, but it sets the same expectation and would read as contradicted by Tier 3.

The lean: Tier 0, delete the tab, and answer the one real question by asking the ten people. โ€‹

Why in one line: the only question anyone has actually asked is the iOS share, a ten-person invite-only cohort can be asked directly with better accuracy than user-agent sniffing, and every tier above 0 pays a permanent architectural cost for a dashboard our own k = 3 gate renders almost entirely blank at alpha scale.

The matrix โ€‹

OptionWhat it doesWhat it affectsRisks / implications
Tier 0 + F + E (the lean)Collects nothing. Deletes the Platform and Device route. Answers the iOS question by asking the Alpha cohortDashboardTabs.jsx, one route file, one Scaffold. No schema, no pipeline, no policy beyond the analytics paragraph already owedLoses a nav item that has shown nothing for 125 days. No device drill-down if a device-specific bug appears, which would then be diagnosed the way it is diagnosed today. Fully reversible
Tier 1 via A (server-derived enum)Adds one 5-valued platform column, derived from the header already arriving, header discardedOne BigQuery column, ingest parsing in the analytics service, one policy clause. No client change, no wire-contract changeSmallest real collection option. Still creates a per-row device attribute joined to user_id. Independent of #874. Displays a 7/2/1 split as just the 7 under k = 3
Tier 1 via C (write-time aggregation)Same resolution, but stores only (day, platform, count) with no user_id. Per-user device fact never exists at restA rollup table, an ingest aggregation path, one policy clauseThe most architecturally honest option: cannot leak what does not exist. Must set an explicit partition expiration or it is permanent, since rollups do not expire. No drill-down, no retroactive re-cut
Tier 2Adds os_family, os_major, browser_family, browser_major, form_factorFive more columns, a UA parser to maintain, every downstream consumer of analytics.events at onceDownstream of #874, so deciding it now prejudges that. Roughly 50,000 combinations over 10 users is a per-user fingerprint. Nearly every cell suppresses under k = 3, so it buys a blank dashboard
Tier 3Adds the raw user-agent, screen and viewport, locale, timezone, memory and coresAll of the above plus a client change and a materially different privacy promiseAnswers no question Tier 2 does not. A literal browser fingerprint, a cross-context join key, reachable by the assistant's external LLM. Contradicts existing in-app copy. Not recommended at any population size on this evidence
D (sampling)Collects from a fractionSame surfaces as whichever tier it modifiesNot viable at n = 10. Destroys the signal, keeps the fingerprint for those sampled, worsens k = 3 suppression

What would change the recommendation. โ€‹

  • Population. At a few thousand users, k = 3 becomes meaningful, cells render, and Tier 1 via C becomes clearly worth it. The right time to revisit is Beta, not Alpha.
  • #874 landing. Once a classification vocabulary exists, Tier 2 becomes a decision that can be made on its merits instead of by default.
  • A real device-specific defect that cannot be diagnosed any other way. That would be evidence, and there is none today.

What the recommendation does not do. โ€‹

  • It does not foreclose anything. Every tier remains available later, and none of them is cheaper to adopt now than after Beta.
  • It does not treat deleting the tab as an admission of failure. A route that has rendered nothing for 125 days is already not a product.

Which findings were filed as their own issues? โ€‹

Three findings surfaced while writing this brief and none of them is about device data collection. Each is true at Tier 0 and independently actionable, so none waits on the decision this document exists to support.

What is still open? โ€‹

Nothing rejects device data arriving in metadata today. โ€‹

  • The ingest schema accepts arbitrary keys in the metadata record. If any tier above 0 is adopted, the enum should be a typed column with a closed value set, and metadata should not be the transport, or the bounded-cardinality guarantee is only a convention.

The analytics infrastructure doc is partly stale. โ€‹

Built with VitePress