Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/copilot/integrate-analytics.prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,23 @@ new SocialShareButton({

Load the adapters file **in addition to** the main library script:

### Optional analytics control APIs

```js
// Runtime option updates, inspector-safe history, and manual reset
shareButton.setAnalyticsOptions({
sampleRate: 0.3,
dedupeWindow: 1500,
dedupeBy: ["eventName", "platform", "url"],
historyLimit: 200,
enrichContext: true,
includeUserAgent: false,
});

console.log(shareButton.getAnalyticsHistory());
shareButton.clearAnalyticsHistory();
```

```html
<!-- After the main social-share-button.js script tag -->
<!-- CDN (pick whichever tag version ships this file): -->
Expand All @@ -98,6 +115,12 @@ const { GoogleAnalyticsAdapter, MixpanelAdapter } = window.SocialShareAnalytics;
new SocialShareButton({
container: "#share-button",
analyticsPlugins: [new GoogleAnalyticsAdapter(), new MixpanelAdapter()],
analyticsOptions: {
sampleRate: 0.5, // send 50% of events
dedupeWindow: 3000, // prevent duplicate events within 3 seconds
enrichContext: true,
includeUserAgent: false,
},
});
```

Expand Down Expand Up @@ -183,6 +206,28 @@ new SocialShareButton({
// Calls: posthog.capture(eventName, { platform, url, ... })
```

### Google Tag Manager (dataLayer)

```js
const { GoogleTagManagerAdapter } = window.SocialShareAnalytics;
new SocialShareButton({
container: "#share-button",
analyticsPlugins: [new GoogleTagManagerAdapter()],
});
// Calls: dataLayer.push({ event: eventName, ...payload })
```

### Console (debug adapter)

```js
const { ConsoleAdapter } = window.SocialShareAnalytics;
new SocialShareButton({
container: "#share-button",
analyticsPlugins: [new ConsoleAdapter()],
});
// Calls: console.info("[SocialShareButton Analytics][ConsoleAdapter]", payload)
```

### Custom / inline function

Wrap any one-off function without subclassing:
Expand Down
1 change: 1 addition & 0 deletions .github/copilot/integrate-social-share-button.prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ function App() {
| `onShare` | function | `null` | `(platform, url) => void` |
| `onCopy` | function | `null` | `(url) => void` |
| `analytics` | boolean | `true` | Set `false` to disable all event emission |
| `analyticsOptions` | object | `{}` | Advanced analytics controls: `sampleRate`, `filter`, `dedupeWindow`, `dedupeBy`, `historyLimit`, `enrichContext`, `includeUserAgent` |
| `onAnalytics` | function | `null` | `(payload) => void` — direct analytics hook |
| `analyticsPlugins` | array | `[]` | Adapter instances from `social-share-analytics.js` |
| `componentId` | string | `null` | Label this instance for analytics tracking |
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,39 @@ No matter which framework you use, integration always follows the same 3 steps:

> 💡 Pick your framework below for the full copy-paste snippet:

## Analytics events (built-in privacy-first observability)

`SocialShareButton` emits a standard event payload for every animation step, click, copy, and error. Electrify your GSOC demo by showing how to connect this to your own analytics stack with zero data collection on the library side.

- `analyticsOptions`: configure sampling, dedupe, enrichment, and history
- `eventId`: unique ID for every analytics payload to support traceability
- `getAnalyticsHistory()`: inspect the last `historyLimit` events
- `clearAnalyticsHistory()`: reset internal debug buffer

```js
const shareButton = new SocialShareButton({
container: "#share-button",
analyticsOptions: {
sampleRate: 0.6, // keep volume down in load tests
dedupeWindow: 3000, // suppress duplicate clicks in short windows
dedupeBy: ["eventName", "platform","url"],
historyLimit: 150,
enrichContext: true,
includeUserAgent: false,
},
analyticsPlugins: [new ConsoleAdapter()],
onAnalytics: (payload) => {
// custom filtering + forwarding
if (payload.eventName !== "social_share_popup_open") {
sendTelemetry(payload);
}
},
});

console.log(shareButton.getAnalyticsHistory());
shareButton.clearAnalyticsHistory();
```
Comment on lines +123 to +145
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Example is incomplete — ConsoleAdapter usage not explained.

The code snippet uses new ConsoleAdapter() directly, but doesn't show how to load or access it. Users following this example will get a ReferenceError.

Consider adding the required setup, similar to how it's documented in the analytics prompt file:

+// Load the analytics adapters script first, then:
+const { ConsoleAdapter } = window.SocialShareAnalytics;
+
 const shareButton = new SocialShareButton({
   container: "#share-button",
   analyticsOptions: {
     sampleRate: 0.6,
     // ...
   },
-  analyticsPlugins: [new ConsoleAdapter()],
+  analyticsPlugins: [new ConsoleAdapter()],
   // ...
 });

Alternatively, link to the detailed analytics integration documentation.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```js
const shareButton = new SocialShareButton({
container: "#share-button",
analyticsOptions: {
sampleRate: 0.6, // keep volume down in load tests
dedupeWindow: 3000, // suppress duplicate clicks in short windows
dedupeBy: ["eventName", "platform","url"],
historyLimit: 150,
enrichContext: true,
includeUserAgent: false,
},
analyticsPlugins: [new ConsoleAdapter()],
onAnalytics: (payload) => {
// custom filtering + forwarding
if (payload.eventName !== "social_share_popup_open") {
sendTelemetry(payload);
}
},
});
console.log(shareButton.getAnalyticsHistory());
shareButton.clearAnalyticsHistory();
```
// Load the analytics adapters script first, then:
const { ConsoleAdapter } = window.SocialShareAnalytics;
const shareButton = new SocialShareButton({
container: "#share-button",
analyticsOptions: {
sampleRate: 0.6, // keep volume down in load tests
dedupeWindow: 3000, // suppress duplicate clicks in short windows
dedupeBy: ["eventName", "platform","url"],
historyLimit: 150,
enrichContext: true,
includeUserAgent: false,
},
analyticsPlugins: [new ConsoleAdapter()],
onAnalytics: (payload) => {
// custom filtering + forwarding
if (payload.eventName !== "social_share_popup_open") {
sendTelemetry(payload);
}
},
});
console.log(shareButton.getAnalyticsHistory());
shareButton.clearAnalyticsHistory();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 123 - 145, The README example references
ConsoleAdapter but doesn't show how to obtain or import it, causing a
ReferenceError; update the snippet to either (a) show the import/registration
for ConsoleAdapter (e.g., an import or adapter factory call) before new
SocialShareButton(...) so ConsoleAdapter is defined, or (b) add a short note and
link to the analytics integration docs explaining how to load ConsoleAdapter;
reference the symbols ConsoleAdapter, SocialShareButton, and analyticsPlugins so
readers can see where to add the adapter setup.


<details>
<summary><b>📦 Create React App</b></summary>

Expand Down
38 changes: 38 additions & 0 deletions src/social-share-analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,42 @@ class PostHogAdapter extends SocialShareAnalyticsPlugin {
}
}

// -----------------------------------------------------------------------------
// Google Tag Manager (DataLayer)
// Requires: window.dataLayer exists. Uses dataLayer.push({ event, ...props }).
// Docs: https://developers.google.com/tag-manager/devguide
// -----------------------------------------------------------------------------
class GoogleTagManagerAdapter extends SocialShareAnalyticsPlugin {
track(payload) {
if (typeof window === "undefined" || !Array.isArray(window.dataLayer)) {
return;
}
window.dataLayer.push({
event: payload.eventName,
source: payload.source,
interactionType: payload.interactionType,
platform: payload.platform,
url: payload.url,
title: payload.title,
componentId: payload.componentId,
timestamp: payload.timestamp,
...(payload.errorMessage ? { errorMessage: payload.errorMessage } : {}),
...(payload.context ? { context: payload.context } : {}),
});
}
}
Comment on lines +249 to +267
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add inline comment for track() method.

Per coding guidelines for src/**/*.{js,jsx}, newly added methods should have brief inline comments explaining what they do, especially for non-obvious logic like the conditional spread operators.

📝 Suggested comment
 class GoogleTagManagerAdapter extends SocialShareAnalyticsPlugin {
+  // Pushes analytics event to GTM dataLayer; includes errorMessage/context only when present
   track(payload) {
     if (typeof window === "undefined" || !Array.isArray(window.dataLayer)) {
       return;
     }

Based on learnings: "Flag any newly added or modified function that lacks a descriptive comment explaining what it does."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/social-share-analytics.js` around lines 249 - 267, Add a brief inline
comment above the GoogleTagManagerAdapter.track method describing its purpose
and behavior: state that it guards against server-side execution (checks typeof
window), ensures window.dataLayer is an array, and then pushes a standardized
analytics payload to the GTM dataLayer, including optional conditional spreads
for errorMessage and context; update the comment to mention why the conditional
spread operators are used to avoid adding undefined fields.


// -----------------------------------------------------------------------------
// Console Adapter
// Lightweight adapter for development / quick debugging.
// -----------------------------------------------------------------------------
class ConsoleAdapter extends SocialShareAnalyticsPlugin {
track(payload) {
// eslint-disable-next-line no-console
console.info("[SocialShareButton Analytics][ConsoleAdapter]", payload);
}
}

// -----------------------------------------------------------------------------
// Custom / Callback Adapter
// Use this adapter to wrap any inline function without subclassing.
Expand Down Expand Up @@ -278,6 +314,8 @@ const adapters = {
SegmentAdapter,
PlausibleAdapter,
PostHogAdapter,
GoogleTagManagerAdapter,
ConsoleAdapter,
CustomAdapter,
};

Expand Down
101 changes: 101 additions & 0 deletions src/social-share-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class SocialShareButton {
// Analytics — the library emits events but never collects or sends data itself.
// Website owners wire up their own analytics tools via these options.
analytics: options.analytics !== false, // set to false to disable all event emission
analyticsOptions: options.analyticsOptions || {}, // advanced analytics controls: sampleRate, filter, dedupeWindow, enrichContext
onAnalytics: options.onAnalytics || null, // callback: (payload) => void
analyticsPlugins: options.analyticsPlugins || [], // array of { track(payload) } adapters
componentId: options.componentId || null, // optional identifier for this instance
Expand All @@ -58,6 +59,8 @@ class SocialShareButton {
this.ownsBodyLock = false; // Track if this instance owns the body overflow lock
this.eventsAttached = false; // Guard against multiple attachEvents() calls
this.isDestroyed = false; // Track if instance has been destroyed (prevents async callbacks)
this._lastAnalyticsEvent = null; // For optional analytics dedupe by eventKey/time window
this.analyticsHistory = []; // keep a local rolling history of emitted events

if (this.options.container) {
this.init();
Expand Down Expand Up @@ -689,6 +692,40 @@ class SocialShareButton {
: this.options.container;
}

/**
* Generates a short unique event ID for each emitted analytics event.
* @returns {string}
*/
_generateEventId() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
Comment on lines +695 to +701
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding brief inline comment explaining the ID format.

Per coding guidelines, inline comments are preferred over JSDoc for this codebase. A short comment explaining the format would aid maintainability:

📝 Suggested enhancement
   _generateEventId() {
+    // Format: {base36-timestamp}-{random-8-chars} for lightweight client-side uniqueness
     return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
   }

As per coding guidelines: "Add comments for logical blocks explaining what they do."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/social-share-button.js` around lines 695 - 701, Add a brief inline
comment for the _generateEventId() function that explains the event ID format
and purpose: note that it returns a base36 timestamp followed by a hyphen and an
8-character base36 random string (timestamp for ordering + random suffix for
uniqueness), and place the comment immediately above the function declaration
(not as JSDoc) so readers quickly understand the ID structure used for emitted
analytics events.


/**
* Update analytics options at runtime (includes dedupe settings, sampling).
* @param {Object} opts
*/
setAnalyticsOptions(opts = {}) {
this.options.analyticsOptions = {
...this.options.analyticsOptions,
...opts,
};
}

/**
* Returns a read-only copy of the event history buffer.
* @returns {Array<Object>}
*/
getAnalyticsHistory() {
return [...this.analyticsHistory];
}

/**
* Clears tracked analytics event history.
*/
clearAnalyticsHistory() {
this.analyticsHistory = [];
}

/**
* Logs analytics warnings only when debug mode is enabled.
* @param {string} message - Description of the failed analytics path.
Expand Down Expand Up @@ -725,9 +762,12 @@ class SocialShareButton {
_emit(eventName, interactionType, extra = {}) {
if (this.options.analytics === false) return;

const analyticsOptions = this.options.analyticsOptions || {};

const payload = {
version: ANALYTICS_SCHEMA_VERSION,
source: "social-share-button",
eventId: this._generateEventId(),
eventName,
interactionType,
platform: extra.platform || null,
Expand All @@ -741,6 +781,67 @@ class SocialShareButton {
payload.errorMessage = extra.errorMessage;
}

// Analytics sample rate and filtering
const sampleRate = Number(analyticsOptions.sampleRate || 1);
if (sampleRate <= 0 || sampleRate > 0 && sampleRate < 1 && Math.random() > sampleRate) {
return;
}

if (typeof analyticsOptions.filter === "function" && !analyticsOptions.filter(payload)) {
return;
}
Comment on lines +784 to +792
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add inline comment explaining sampling logic.

The conditional on line 786 is correct but dense. An inline comment would clarify the edge cases (especially that values > 1 are treated as 100%):

📝 Suggested enhancement
     // Analytics sample rate and filtering
+    // sampleRate <= 0: disabled; 0 < sampleRate < 1: probabilistic; sampleRate >= 1: 100% pass-through
     const sampleRate = Number(analyticsOptions.sampleRate || 1);
     if (sampleRate <= 0 || sampleRate > 0 && sampleRate < 1 && Math.random() > sampleRate) {
       return;
     }

As per coding guidelines: "Add comments for edge cases and non-obvious logic."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Analytics sample rate and filtering
const sampleRate = Number(analyticsOptions.sampleRate || 1);
if (sampleRate <= 0 || sampleRate > 0 && sampleRate < 1 && Math.random() > sampleRate) {
return;
}
if (typeof analyticsOptions.filter === "function" && !analyticsOptions.filter(payload)) {
return;
}
// Analytics sample rate and filtering
// sampleRate <= 0: disabled; 0 < sampleRate < 1: probabilistic; sampleRate >= 1: 100% pass-through
const sampleRate = Number(analyticsOptions.sampleRate || 1);
if (sampleRate <= 0 || sampleRate > 0 && sampleRate < 1 && Math.random() > sampleRate) {
return;
}
if (typeof analyticsOptions.filter === "function" && !analyticsOptions.filter(payload)) {
return;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/social-share-button.js` around lines 784 - 792, Add a brief inline
comment above the sampling conditional that explains the sampling behavior in
plain terms: compute sampleRate from analyticsOptions.sampleRate (defaults to
1), treat any value > 1 as 100% (no sampling), treat values between 0 and 1 as
probabilistic sampling using Math.random(), and short-circuit when sampleRate <=
0; also note that the subsequent analyticsOptions.filter(payload) is an
independent filter step. Place this comment near the sampleRate
declaration/conditional so readers of analyticsOptions, sampleRate, and the
payload/filter logic immediately understand the edge cases.


// Optional event dedupe within rolling window (milliseconds)
const dedupeWindow = Number(analyticsOptions.dedupeWindow || 0);
if (dedupeWindow > 0 && this._lastAnalyticsEvent) {
let sameEvent =
this._lastAnalyticsEvent.eventName === payload.eventName &&
this._lastAnalyticsEvent.platform === payload.platform &&
this._lastAnalyticsEvent.url === payload.url &&
this._lastAnalyticsEvent.interactionType === payload.interactionType;

if (typeof analyticsOptions.dedupeBy === "function") {
sameEvent = analyticsOptions.dedupeBy(this._lastAnalyticsEvent, payload);
} else if (Array.isArray(analyticsOptions.dedupeBy)) {
sameEvent = analyticsOptions.dedupeBy.every((key) =>
Object.prototype.hasOwnProperty.call(payload, key)
? this._lastAnalyticsEvent[key] === payload[key]
: false
);
}
Comment on lines +803 to +811
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Edge case: non-existent keys in dedupeBy array disable deduplication entirely.

If a developer specifies a key that doesn't exist in the payload (e.g., dedupeBy: ["eventName", "typoKey"]), the every() will always return false, effectively disabling deduplication silently.

Consider either logging a debug warning for invalid keys, or skipping non-existent keys:

📝 Alternative approach that skips missing keys
       } else if (Array.isArray(analyticsOptions.dedupeBy)) {
-        sameEvent = analyticsOptions.dedupeBy.every((key) =>
-          Object.prototype.hasOwnProperty.call(payload, key)
-            ? this._lastAnalyticsEvent[key] === payload[key]
-            : false
-        );
+        // Only compare keys that exist in both payloads
+        const validKeys = analyticsOptions.dedupeBy.filter(
+          (key) => Object.prototype.hasOwnProperty.call(payload, key)
+        );
+        sameEvent = validKeys.length > 0 && validKeys.every(
+          (key) => this._lastAnalyticsEvent[key] === payload[key]
+        );
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/social-share-button.js` around lines 803 - 811, The current Array branch
for analyticsOptions.dedupeBy uses every() which returns false if any key is
missing, silently disabling dedupe; update the logic in
src/social-share-button.js where sameEvent is set for analyticsOptions.dedupeBy
so that it first filters the dedupe keys to those actually present on both
payload and this._lastAnalyticsEvent (or alternatively log a debug/warning for
missing keys) and then compare only those existing keys for equality; ensure you
still handle the case where no valid keys remain (e.g., treat as not-duplicate
or log) so deduplication behavior is explicit.


if (sameEvent && Date.now() - this._lastAnalyticsEvent.timestamp < dedupeWindow) {
return;
}
}

// Enrich context if enabled (default true)
if (analyticsOptions.enrichContext !== false) {
payload.context = {
referrer: typeof document !== "undefined" ? document.referrer || null : null,
locale:
typeof navigator !== "undefined" && navigator.language
? navigator.language
: null,
viewport:
typeof window !== "undefined"
? `${window.innerWidth}x${window.innerHeight}`
: null,
};
if (analyticsOptions.includeUserAgent && typeof navigator !== "undefined") {
payload.context.userAgent = navigator.userAgent;
}
}

this._lastAnalyticsEvent = payload;

// Keep rolling event history buffer for debugging/inspection
const historyLimit = Number(analyticsOptions.historyLimit || 100);
this.analyticsHistory.push(payload);
if (this.analyticsHistory.length > historyLimit) {
this.analyticsHistory.shift();
}

// Optional console output for development / debugging
if (this.options.debug) {
// eslint-disable-next-line no-console
Expand Down
Loading