Logging Share Outcomes Without Leaking Payloads

This guide is part of Testing and Debugging Web Share Flows, which itself is part of Web Share API and Security Contexts.

You cannot improve a share flow you cannot measure. How many users get the native sheet? How often does canShare reject a payload? Which platform produces the most permission denials? All reasonable questions — and all answerable without recording a single character of what the user actually shared.

The risk is easy to underestimate because the payload looks innocuous in development, where it is a public article URL. In production it is a private document link, a personal note, or a photo whose file name is itself sensitive. A telemetry call that forwards payload wholesale turns a UI metric into a data-collection pipeline, usually without anyone deciding to build one.

Feature Detection Gate

Instrumentation must never be the thing that breaks sharing, so the gate runs first and the telemetry hangs off its result.

// share-with-telemetry.js
import { shareEvent } from './share-event.js';
import { report } from './report.js';

export async function shareWithTelemetry(payload) {
  if (window.isSecureContext !== true || typeof navigator.share !== 'function') {
    report(shareEvent('fallback', payload, null, 'unsupported'));
    return { outcome: 'fallback' };
  }
  if (typeof navigator.canShare === 'function' && !navigator.canShare(payload)) {
    report(shareEvent('fallback', payload, null, 'payload-rejected'));
    return { outcome: 'fallback' };
  }

  try {
    await navigator.share(payload);
    report(shareEvent('shared', payload));
    return { outcome: 'shared' };
  } catch (err) {
    const outcome = err.name === 'AbortError' ? 'dismissed' : 'failed';
    report(shareEvent(outcome, payload, err));
    return { outcome, error: err };
  }
}

Four outcomes, and only four: shared, dismissed, fallback, failed. Collapsing dismissed into failed is the single most common instrumentation bug — it inflates the error rate with ordinary user behaviour and buries the genuine failures underneath it, as handling AbortError when the share sheet is dismissed explains.

What Leaves the Device and What Does Not The share payload on the left lists title, text, url and files as content that stays on the device. A reduction step converts them into the safe facts on the right: booleans for which fields were present, a file count, MIME types and a coarse size bucket. Stays on device title: "Q3 board pack" text: "notes for Priya" url: "…/d/8f3a?token=…" files: [scan-2026.pdf] never serialised, never sent shareEvent() allow-list reduction Safe to transmit outcome: "failed" errorName: "NotAllowedError" hasTitle / hasText / hasUrl: true fileCount: 1 · types: ["pdf"] sizeBucket: "1-10MB"

Step 1 — Reduce the Payload to a Shape

The event builder is the only place that ever touches the payload, and it is written as an explicit allow-list. Nothing is copied; every field is derived.

// share-event.js
const SIZE_BUCKETS = [
  [1_000_000, '<1MB'],
  [10_000_000, '1-10MB'],
  [50_000_000, '10-50MB']
];

export function shareEvent(outcome, payload, error = null, reason = null) {
  const files = payload?.files ?? [];
  return {
    outcome,                                   // shared | dismissed | fallback | failed
    reason,                                    // unsupported | payload-rejected | null
    errorName: error?.name ?? null,            // AbortError | NotAllowedError | DataError
    hasTitle: Boolean(payload?.title),
    hasText: Boolean(payload?.text),
    hasUrl: Boolean(payload?.url),
    urlIsSameOrigin: sameOrigin(payload?.url), // a boolean, never the URL
    fileCount: files.length,
    fileTypes: [...new Set(files.map((f) => subtype(f.type)))].sort(),
    sizeBucket: bucket(files.reduce((sum, f) => sum + f.size, 0))
  };
}

function sameOrigin(url) {
  if (!url) return null;
  try { return new URL(url, location.href).origin === location.origin; }
  catch { return null; }
}

function subtype(mime) {
  return (mime || 'unknown').split('/').pop().split(';')[0];
}

function bucket(bytes) {
  if (!bytes) return 'none';
  for (const [limit, label] of SIZE_BUCKETS) if (bytes < limit) return label;
  return '>50MB';
}

urlIsSameOrigin is the pattern worth copying elsewhere: it answers the question you actually had — are people sharing our pages or links they pasted in? — as a single boolean, with no way to reconstruct the address. subtype reduces image/jpeg to jpeg, keeping the diagnostic value of MIME types without the parameters some browsers append.

Step 2 — Make Leaks Structurally Impossible

An allow-list is only a convention until something enforces it. Two cheap guards turn it into a property of the code.

// report.js
const ALLOWED_KEYS = new Set([
  'outcome', 'reason', 'errorName', 'hasTitle', 'hasText', 'hasUrl',
  'urlIsSameOrigin', 'fileCount', 'fileTypes', 'sizeBucket'
]);

export function report(event) {
  const unexpected = Object.keys(event).filter((k) => !ALLOWED_KEYS.has(k));
  if (unexpected.length) {
    if (import.meta.env?.DEV) throw new Error(`share telemetry: blocked keys ${unexpected}`);
    for (const key of unexpected) delete event[key];
  }
  navigator.sendBeacon?.('/telemetry/share', JSON.stringify(event));
}

In development an unexpected key is a hard error, so the moment someone adds title to the event they see it immediately. In production the same key is dropped rather than thrown, because telemetry must never break the feature it measures. sendBeacon is the right transport here: it survives the page being backgrounded while the share sheet is open, which a fetch in flight may not.

A unit test pins the guarantee:

// share-event.test.js
import { it, expect } from 'vitest';
import { shareEvent } from './share-event.js';

it('never carries payload content', () => {
  const event = shareEvent('shared', {
    title: 'Q3 board pack',
    text: 'notes for Priya',
    url: 'https://app.example.com/d/8f3a?token=secret',
    files: [new File(['x'], 'scan-2026-medical.pdf', { type: 'application/pdf' })]
  });

  const serialised = JSON.stringify(event);
  for (const secret of ['Q3 board pack', 'Priya', '8f3a', 'secret', 'scan-2026-medical']) {
    expect(serialised).not.toContain(secret);
  }
  expect(event.fileTypes).toEqual(['pdf']);
  expect(event.sizeBucket).toBe('<1MB');
});

Step 3 — Read the Numbers

The reduced event still answers the questions that drive real decisions. A high fallback rate with reason: 'unsupported' tells you the desktop path matters more than assumed and deserves a better-designed custom share sheet. A spike in errorName: 'NotAllowedError' on one platform points at a lost user gesture, not at users refusing anything. A steady payload-rejected share concentrated on fileTypes: ['heic'] tells you exactly which conversion to add.

From Signal to Action Four rows pair an observed metric with the fix it implies: a high unsupported fallback rate means invest in the fallback UI, a NotAllowedError spike means an await crept in before the share call, repeated payload rejection on one file type means convert it, and a rising dismissal rate means the sheet is opening at the wrong moment. Signal What it means you should change fallback + reason "unsupported" is high invest in the fallback UI — most users never see the sheet errorName "NotAllowedError" spikes an await crept in before share() — restore the gesture payload-rejected on one fileType convert that format before offering the share dismissed rate climbing the sheet opens too early — move it later in the flow Why sendBeacon and Not fetch When the share sheet takes over, the page is backgrounded and a fetch in flight may be cancelled before it completes, losing the event. sendBeacon is queued by the browser and delivered regardless of what happens to the page. fetch() tied to the document's lifetime may be cancelled when backgrounded events lost exactly when the sheet opens the moment you most wanted to measure navigator.sendBeacon() handed to the browser to deliver survives backgrounding and unload fire and forget, never awaited cannot delay or break the share

Failure Modes and Recovery

Symptom Cause Minimal fix
Shared URLs appear in analytics payload forwarded wholesale Build events from an allow-list only
Error rate dominated by cancellations AbortError classified as a failure Map it to dismissed before reporting
Events lost when the sheet opens fetch cancelled as the page backgrounds Send with navigator.sendBeacon
File names in logs Names treated as metadata Log count, MIME subtype and size bucket
New payload field silently transmitted No enforcement of the allow-list Throw on unexpected keys in development
Telemetry failure breaks sharing Reporting inside the try block Report after the outcome is decided; never rethrow

Browser and Platform Caveat

navigator.sendBeacon is available across Chrome, Safari, Firefox and Edge and is the only transport that reliably survives the page being suspended behind a modal share sheet — on iOS in particular, a pending fetch may never complete once the sheet takes over. Everything else here is plain JavaScript with no platform dependency. Two operational notes: error.name is stable across engines while error.message is not, so branch and report on the name only; and if your analytics vendor auto-captures DOM events or network bodies, confirm it is not separately recording the share payload behind your back — an allow-list in your own code does not constrain a script that reads the page.

FAQ

Why is logging the shared URL a privacy problem?

Shared URLs routinely carry unguessable identifiers — a private document link, an invitation token, a password-reset path. Sending them to analytics copies a capability the user meant for one recipient into a third-party system, where retention and access are outside your control.

Can I log file names if I strip the contents?

File names are user content too — scan-2026-medical.pdf and offer-letter-acme.docx describe the person as clearly as the bytes do. Log the file count, the MIME types and a coarse size bucket, which answer every operational question without naming anything.

Should a dismissal be recorded as an error?

No. AbortError means the user chose not to share, which is a normal outcome and not a defect. Recording it as an error inflates your error rate, hides real failures such as NotAllowedError, and can trigger alerting on ordinary user behaviour.