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.
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.
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.