Share view events (privacy model + audit log for the view-tracking pipeline)
When you protect a share (password / view cap / notify-on-view) or even just publish one and leave it open, Omniscio needs to know whether — and how often — strangers on the internet are actually opening your link. That bookkeeping is done by the view-event pipeline: a Firestore-backed audit trail that records one row per view, mirrors a per-share view counter back to the desktop app every 60 seconds, and fires an OS notification the first time each new view lands if the share has notify-on-v...
What it is
When you protect a share (password / view cap / notify-on-view) or even just publish one and leave it open, Omniscio needs to know whether — and how often — strangers on the internet are actually opening your link. That bookkeeping is done by the view-event pipeline: a Firestore-backed audit trail that records one row per view, mirrors a per-share view counter back to the desktop app every 60 seconds, and fires an OS notification the first time each new view lands if the share has notify-on-view turned on.
This page is the privacy contract for that pipeline. It answers: what is captured per view, where does it live, who can read it, how long does it stay, and what happens when you revoke or delete a share. If you only want to know how to turn the feature on, see shares-view.md § Editing a share (the toggles) and share-artifacts.md § Per-share protection (the publish-time model). If you want the dashboard sparkline + per-row "viewed 2m ago" badge UI, see shares-view.md.
What gets captured per view
When a viewer opens https://shares.omniscio.com/s/<token> and the share gate decides to serve (i.e. not revoked, not expired, password unlocked or absent, view cap not yet hit), the Cloud Function increments the view counter inside a Firestore transaction and writes one event row into the shares/<token>/events subcollection. The event row has four fields and only four fields:
| field | type | what it is |
|---|---|---|
viewedAt |
ISO-8601 timestamp | When the CF served the view. Single source of truth for ordering — Omniscio's poll cadence and clock drift don't matter |
country |
ISO 3166-1 alpha-2 or null |
Two-letter country code (US, DE, JP…) from Cloudflare's cf-ipcountry request header. Coarse geo only — no region, no city, no coordinates. null if the CF couldn't determine a country (rare; usually datacenter/proxy traffic) |
userAgentHash |
SHA-256 hex (first 32 chars) or null |
Hash of the raw User-Agent request header (truncated to 32 hex chars). Same browser → same hash. The hash is unsalted, so the same UA produces the same value across every share and every install (see "Hash salting" below) |
ipHash |
SHA-256 hex or null |
Unsalted SHA-256 of the viewer's IP address. Used to estimate unique-viewer counts in the future sparkline; the raw IP itself is never persisted (only this hash). Note an unsalted SHA-256 of an IPv4 address is brute-forceable, so treat ipHash as pseudonymous, not anonymous |
Things that are NOT captured: raw IP, browser fingerprint, screen resolution, referrer URL, time spent on the share page, click-through events, cookies (other than the password-unlock cookie which is scoped to one share's hostname-token tuple), URL hash, any data the viewer typed into a form on the share page (forms inside the share's HTML are sandboxed and can't reach the CF), or anything from the share content itself.
The CF also updates the parent doc's viewCount (incremented by 1) and lastViewedAt (set to viewedAt) in the same transaction. Those two fields are what the Omniscio poller actually reads on the 60s tick — the event-subcollection diff is only walked when the count delta indicates new rows.
Where to find it
Where the data lives
There are two stores, kept in sync one-way:
- Firestore (authoritative writer) — Cloud Function writes the row inside the same transaction that increments
viewCount. No Omniscio instance can write event rows; only the CF can. - SQLite (
share_view_eventstable, schema v181 in src/main/db/queries-share-view-events.ts) — local mirror, populated by the view-event poller. Capped at 100 rows per share with oldest-by-viewedAt-first trimming inside the same transaction as the insert, so a viral share that gets 1000 views still only consumes 100 SQLite rows ("1000 total / 100 most-recent details" surfacing).
There is no log file, no analytics provider, no email digest. The pipeline lives entirely inside Firebase (your project) and your local Omniscio's SQLite.
How it behaves
Hash salting
userAgentHash and ipHash are salted SHA-256 hashes computed at the Cloud Function with a per-deploy secret (GEOIP_HASH_SALT, via saltedIpHash / hashUserAgent); the desktop stores them verbatim and never re-hashes. The salt is fail-closed (RT-F010): with the per-deploy salt (or the IP) empty, the hash is recorded as null, never a bare/unsalted value — an unsalted SHA-256 of an IPv4 is rainbow-table-reversible, and these rows PERSIST. Each event row also carries a server-stamped expireAt so a console-enabled TTL policy purges the audit row after ~180 days.
Treat ipHash (especially paired with userAgentHash) as a stable pseudonymous identifier, not "anonymous": the same viewer yields the same hash across the publisher's own shares — the cross-viewer-joinable property the "unique viewers" count needs (F150) — while the per-deploy salt blocks cross-install linkage. The rows never leave the publisher's own Firebase + local SQLite, so only the publisher can correlate them.
RT-F043 tripwire: any surface that AGGREGATES these events and presents them as "anonymized" MUST first apply a minimum-cohort (k) suppression — the screen-recording watch-analytics panel does exactly this (k=5) for the sibling share_watch_sessions store.
How Omniscio pulls the data back
The poller is src/main/services/share/share-view-poller.ts and runs on a 60-second createPeriodicTask interval. Per tick:
- Short-circuit on missing creds.
isMirrorAvailable()returns false if no Firebase service-account key is wired up. The poller keeps running so the user can add credentials without restarting Omniscio, but the body of the tick is skipped — no Firestore round-trip, no work. - List local active shares.
listActiveShareTokens()selects every share row that is not revoked / not expired / not pending. Tokens are chunked at 30 per Firestore round-trip (db.getAll(...refs)). - Read mirror state.
fetchMirrorState(chunk)does a batchgetAllagainstshares/<token>docs and returns{ token, viewCount, lastViewedAt }per existing doc. Tokens not present in Firestore are silently skipped (share predates the mirror, or publish-time sync failed — either way the local count stays unchanged). Each mirror read carries a 30s deadline viawithTimeoutOrNull; a transient timeout (machine asleep → dead network, flaky wifi) is logged locally and retried on the next tick, not escalated to Sentry — only a permanent/config error (e.g.PERMISSION_DENIED) is (Sentry issue 7559695231). The poller's own per-share reconcile-failure inbox alert (F115) is the user-facing escalation for a share that stays broken. - Diff each share's count. For every remote row where
remote.viewCount > local.viewCount, callreconcileShare():- Pull new event rows via
fetchRecentViewEvents(token, sinceIso)wheresinceIsois the localMAX(viewed_at)— capped at 50 events per call so a viral share surfaces in batches across multiple ticks rather than blowing one call. INSERT OR IGNOREthose rows intoshare_view_events. Idempotent on row id — a duplicate poller tick is a no-op.- Trim each affected share's local event count back down to 100 inside the same transaction.
- Call
setShareViewCountFromMirror(shareId, remoteViewCount, remoteLastViewedAt)to update the parentshare_linksrow'sview_count/last_viewed_atcolumns. The mirror is the writer, Omniscio is the reader. Omniscio's local count is never pushed back to Firestore. - If the share has
notify_on_view = 1, firenotificationService.showOnce()with dedupe keyshare-view-<shareId>-<remoteViewCount>and the message"<label>" was just viewed (<count> total). The dedupe key includes the new count, so each successive view fires its own notification. - Emit
IPC.SHARE_UPDATEDpush so any open Shares view re-renders the badge.
- Pull new event rows via
Crash-safety: the SQLite write commits before the notification fires. If Omniscio crashes between insert and showOnce(), the next tick re-reads the count, sees no delta (already-mirrored count), and doesn't double-notify. The cost is at most one missed notification, never a duplicate. (showOnce's own 24h dedupe map provides additional belt-and-suspenders coverage if the process restarts mid-tick.)
How notify-on-view actually fires
The OS-level notification is the only outbound signal in the whole pipeline — every other action is reading data the publisher already has access to. The notification:
- Fires from the desktop app's process (main-process
notificationService), not from the CF and not from a push-notification service. If Omniscio isn't running, the notification is deferred until the next time Omniscio starts (and Firestore has the count delta waiting). - Is per-share, per-count —
dedupeKey: share-view-<id>-<count>. View 1 fires once, view 2 fires once, view 1 + view 2 arriving in the same poll tick fire twice with two different keys. - Has a 24h dedupe window baked into
notificationService.showOnce(), so a single share+count combination can't fire more than once a day even if the poller restarts mid-batch (which would otherwise re-run the reconciliation). - Surfaces with the share's user-supplied label if one exists; falls back to
"A shared item was just viewed (<count> total)"if no label. - Is NOT batched by Focus Mode. View notifications are informational and not in Focus Mode's eligible-items set — they always fire immediately if
notify_on_viewis on, regardless of focus state.
If you want to stop receiving view notifications for one share, open it in the Shares view and untick "Notify on view" in the Edit modal — the change syncs to the Firestore mirror on save, but the poller's check is against the local share_links.notify_on_view column, so the toggle takes effect on the next tick (≤60s) regardless of whether the mirror sync has landed yet.
What happens on revoke and delete
| Operation | What stays | What goes |
|---|---|---|
| Revoke (Edit modal → "Revoke share") | Mirror doc (with active: false); all SQLite event rows; the storage blob |
Future views — the gate refuses with 410 "this share has been revoked" |
| Delete (Edit modal → "Delete permanently") | Nothing | Mirror doc + entire events subcollection (batch-deleted, 500 at a time); SQLite event rows (deleteShareViewEventsForShare); the storage blob; the share_links row |
Expiration (expiresAt in past) |
Mirror doc with active: true, gate-level 410 with "expired" copy |
No new events; existing events stay until the share is deleted manually |
Delete is irreversible — the share row, the Firestore doc, the events subcollection, and the storage blob are all hard-removed. The audit trail is gone the moment you click Delete; if you want to keep the history, revoke instead of delete.
What an outside AI sees vs. what you see
If you publish a share, anyone with the URL can view the share content. They cannot see:
- The view counter (it's not rendered into the share page — only the publisher's Shares view shows it)
- The list of past viewers (no row of view events is ever served to anyone except the publisher's Omniscio)
- Whether other people have viewed it
- Whether you have notify-on-view turned on
- Whether you've set a view cap (until they hit it — then they get a 410 "view limit reached" page)
The publisher (you) sees the count + most-recent-100-event details in the per-share row in shares-view.md. A future per-share detail panel (not yet shipped) will visualise the event timeline as a sparkline + grouped country/UA list.
Disabling view tracking entirely
The view-counter increment is always on — even for shares with no password, no view cap, and notify_on_view = false. The reason: a future version of the Shares view will show "viewed 12 times" badges for every share, and breaking the counter into an opt-in feature would silently disable that for existing shares.
To stop storing events locally, the share has to be deleted (revoke keeps the events). To stop firing notifications, untick notify-on-view per-share. There is no global "kill view tracking" toggle today — the design rationale is that the data is hash-only (no raw IP/UA persisted) and capped at 100 rows per share, so the storage and privacy cost is bounded regardless of how many shares you publish. Note the hashes are unsalted (see "Hash salting"), so an ipHash is pseudonymous rather than anonymous — a GDPR-regulated deployment should weigh that. If that calculus needs to change, the lever to add is a disableViewTracking AppSetting that the publish path checks before writing the Firestore mirror doc — but it doesn't exist yet.
Related
- share-artifacts.md — the publish-time view of the same pipeline (how to set a view cap / password / notify-on-view at create time)
- artifact-sharing.md — the sibling feature for session/message/selection shares, same view-event pipeline
- shares-view.md — the sidebar UI that surfaces the counter + Edit modal for per-share protection toggles
- share-cli.md — CLI publishes go through the same view-event pipeline; the CLI body schema does not accept protection fields, so CLI-published shares start unprotected and use the in-app Edit modal to add gates
Last verified 2026-09-23