---
title: Share view events (privacy model + audit log for the view-tracking pipeline)
---
# Share view events (privacy model + audit log for the view-tracking pipeline)

## 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](shares-view.md) (the toggles) and [share-artifacts.md § Per-share protection](share-artifacts.md) (the publish-time model). If you want the dashboard sparkline + per-row "viewed 2m ago" badge UI, see [shares-view.md](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:

1. **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.
2. **SQLite** (`share_view_events` table, schema v181 in [src/main/db/queries-share-view-events.ts](../../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](screen-recorder.md) 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](../../src/main/services/share/share-view-poller.ts) and runs on a 60-second `createPeriodicTask` interval. Per tick:

1. **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.
2. **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)`).
3. **Read mirror state.** `fetchMirrorState(chunk)` does a batch `getAll` against `shares/<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 via `withTimeoutOrNull`; 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.
4. **Diff each share's count.** For every remote row where `remote.viewCount > local.viewCount`, call `reconcileShare()`:
   - Pull new event rows via `fetchRecentViewEvents(token, sinceIso)` where `sinceIso` is the local `MAX(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 IGNORE` those rows into `share_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 parent `share_links` row's `view_count` / `last_viewed_at` columns. **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`, fire `notificationService.showOnce()` with dedupe key `share-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_UPDATED` push so any open Shares view re-renders the badge.

**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_view` is 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](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](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](artifact-sharing.md) — the sibling feature for session/message/selection shares, same view-event pipeline
- [shares-view.md](shares-view.md) — the sidebar UI that surfaces the counter + Edit modal for per-share protection toggles
- [share-cli.md](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
