Skip to content
Merged
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
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"lang": "en_US",
"skipWords": [
"pageview",
"cpu",
"pageviews",
"localhost",
"uid"
Expand Down
81 changes: 79 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ enableAutoPageviews(plausible);

## Automatically track outbound clicks

To track outbound clicks automatically, call `enableAutoOutboundTracking`:
To track outbound clicks automatically (same-origin links are skipped automatically), call `enableAutoOutboundTracking`:

```ts
import { Plausible, enableAutoOutboundTracking } from 'plausible-client';
Expand All @@ -69,8 +69,85 @@ const plausible = new Plausible({
domain: 'example.org',
});

// Function returns cleanup callback and starts track outbound clicks
// Function returns cleanup callback and starts tracking outbound clicks
enableAutoOutboundTracking(plausible);

// Optionally capture link text or apply a custom filter
enableAutoOutboundTracking(plausible, {
captureText: true,
filter: (url, text) => !url.includes('internal'),
});
```

## Track any link clicks

For fine-grained control over which links to track, use `enableLinkClicksCapture` directly:

```ts
import { Plausible, enableLinkClicksCapture } from 'plausible-client';

const plausible = new Plausible({
apiHost: 'https://plausible.io',
domain: 'example.org',
});

// Function returns cleanup callback
enableLinkClicksCapture(plausible, {
eventName: 'Link click', // default
captureText: true, // include link text as a prop
filter: (url, text) => url.startsWith('https://'),
});
```

## Score user sessions

To collect device/region signals and detect trivial bots, call `enableSessionScoring`:

```ts
import { Plausible, enableSessionScoring } from 'plausible-client';

const plausible = new Plausible({
apiHost: 'https://plausible.io',
domain: 'example.org',
});

// Sends a "Session scored" event on the next animation frame
enableSessionScoring(plausible);
```

The `Session scored` event includes these props:

| Prop | Description |
|---|---|
| `botScore` | Numeric score — ≥ 3 indicates a likely bot |
| `botSignals` | Comma-separated triggered signals, e.g. `webdriver,headless_ua` |
| `sessionAge` | Seconds since the first recorded visit |
| `timeZone` | IANA timezone resolved from `Intl.DateTimeFormat` |
| `language` | `navigator.language` |
| `languages` | `navigator.languages` joined with `,` |
| `screenSize` | `{width}x{height}` from `window.screen` |
| `hardwareConcurrency` | Number of logical CPU cores |
| `deviceMemory` | Device RAM in GB (where available) |
| `devicePixelRatio` | Screen pixel density |

You can customise the storage backend or key used to persist the first-visit timestamp:

```ts
import { Plausible, enableSessionScoring, CookieStorage } from 'plausible-client';

enableSessionScoring(plausible, {
storage: new CookieStorage(),
firstVisitKey: 'my_first_visit',
});
```

You can also use `getBotSignals()` independently:

```ts
import { getBotSignals } from 'plausible-client';

const { isBot, score, signals } = getBotSignals();
// isBot: boolean, score: number, signals: string[]
```

## Filter events
Expand Down
6 changes: 4 additions & 2 deletions src/Plausible.server-environment.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// @vitest-environment node
import { enableAutoOutboundTracking } from './enableAutoOutboundTracking';
import { enableAutoPageviews } from './enableAutoPageviews';
import { filters, skipByFlag, skipForHosts } from './filters';
import { Plausible } from './Plausible';
import { enableAutoOutboundTracking } from './plugins/enableAutoOutboundTracking';
import { enableAutoPageviews } from './plugins/enableAutoPageviews';
import { enableSessionScoring } from './plugins/enableSessionScoring';
import { transformers, userId } from './transformers';

const mockFetch = vi.fn();
Expand Down Expand Up @@ -80,6 +81,7 @@ test('plugins must not throw in a server environment', async () => {

enableAutoPageviews(plausible);
enableAutoOutboundTracking(plausible);
enableSessionScoring(plausible);

await plausible.sendEvent('test', {
props: {
Expand Down
26 changes: 0 additions & 26 deletions src/enableAutoOutboundTracking.ts

This file was deleted.

4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export * from './Plausible';
export * from './enableAutoPageviews';
export * from './enableAutoOutboundTracking';

export * from './plugins';
export * from './transformers';
export * from './filters';
100 changes: 100 additions & 0 deletions src/plugins/enableAutoOutboundTracking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/* eslint-disable spellcheck/spell-checker */
// @vitest-environment jsdom

import { Plausible } from '../Plausible';
import { enableAutoOutboundTracking } from './enableAutoOutboundTracking';

const mockFetch = vi.fn();
globalThis.fetch = mockFetch;

const locationSpyon = vi.spyOn(window, 'location', 'get');

beforeEach(() => {
vi.clearAllMocks();
locationSpyon.mockReturnValue(new URL('https://example.org') as any as Location);

mockFetch.mockReturnValue(new Response('ok'));

document.body.innerHTML = `<div><a id="internal" href="#foo">internal link</a> <a id="external" href="https://google.com/">external link</a> <a id="nested" href="https://example.com"><span>nested text</span></a></div>`;
});

const plausible = new Plausible({
apiHost: 'https://plausible.io',
domain: 'example.org',
});

enableAutoOutboundTracking(plausible, { captureText: true });

test('Ignore clicks by links with the same origin', async () => {
locationSpyon.mockReturnValue(new URL('https://example.org') as any as Location);

const anchorElm = document.querySelector('a#internal') as HTMLAnchorElement;
expect(anchorElm).toBeInstanceOf(HTMLAnchorElement);

anchorElm.click();
expect(mockFetch).not.toHaveBeenCalled();

anchorElm.href = 'https://example.org/foo';
anchorElm.click();
expect(mockFetch).not.toHaveBeenCalled();

anchorElm.href = 'https://example.org/nested/foo';
anchorElm.click();
expect(mockFetch).not.toHaveBeenCalled();

anchorElm.href = '/';
anchorElm.click();
expect(mockFetch).not.toHaveBeenCalled();

anchorElm.href = 'https://google.com';
anchorElm.click();
expect(mockFetch).toHaveBeenCalledTimes(1);
});

test('Capture click for simple link', async () => {
const anchorElm = document.querySelector('a#external') as HTMLAnchorElement;
expect(anchorElm).toBeInstanceOf(HTMLAnchorElement);
anchorElm.click();

expect(mockFetch).toHaveBeenLastCalledWith('https://plausible.io/api/event', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: JSON.stringify({
n: 'Outbound Link: Click',
u: 'https://example.org/',
d: 'example.org',
r: null,
w: 1024,
h: 0,
p: JSON.stringify({
url: 'https://google.com/',
text: 'external link',
}),
}),
keepalive: true,
});
});

test('Capture click for link text', async () => {
const spanElm = document.querySelector('a#nested > span') as HTMLSpanElement;
expect(spanElm).toBeInstanceOf(HTMLSpanElement);
spanElm.click();

expect(mockFetch).toHaveBeenLastCalledWith('https://plausible.io/api/event', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: JSON.stringify({
n: 'Outbound Link: Click',
u: 'https://example.org/',
d: 'example.org',
r: null,
w: 1024,
h: 0,
p: JSON.stringify({
url: 'https://example.com/',
text: 'nested text',
}),
}),
keepalive: true,
});
});
31 changes: 31 additions & 0 deletions src/plugins/enableAutoOutboundTracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Plausible } from '../Plausible';
import {
enableLinkClicksCapture,
LinkClickPluginConfig,
} from './enableLinkClicksCapture';

export const enableAutoOutboundTracking = (
plausible: Plausible,
config: LinkClickPluginConfig = {},
) => {
return enableLinkClicksCapture(plausible, {
eventName: 'Outbound Link: Click',
...config,
filter(url, text) {
// Skip links with the same origin
if (typeof window !== 'undefined') {
try {
const linkUrl = new URL(url, location.href);
if (linkUrl.origin === location.origin) return false;
} catch {
// Invalid URL, let it through to be filtered by user or tracked
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Apply user filter
if (config.filter) return config.filter(url, text);

return true;
},
});
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Plausible } from './Plausible';
import { noop } from './utils/noop';
import { Plausible } from '../Plausible';
import { noop } from '../utils/noop';

// Source: https://github.com/plausible/plausible-tracker/blob/ab75723ad10660cbaee3718d1b0a670e2dfd717d/src/lib/tracker.ts#L253-L284
export const enableAutoPageviews = (plausible: Plausible) => {
Expand Down
52 changes: 52 additions & 0 deletions src/plugins/enableLinkClicksCapture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Plausible } from '../Plausible';
import { noop } from '../utils/noop';

export type LinkClickPluginConfig = {
eventName?: string;
captureText?: boolean;
filter?: (url: string, text: string) => boolean;
};

export const enableLinkClicksCapture = (
plausible: Plausible,
config: LinkClickPluginConfig = {},
) => {
if (typeof window === 'undefined') return noop;

const {
eventName = 'Link click',
captureText = false,
/**
* When filter returns `false`, the event will be dropped
*/
filter,
} = config;

const clickCallback = (event: MouseEvent) => {
// Iterate over all targets to find Anchor element and take its text
// We do it instead of handle target, since click may appear on nested element
const linkElement = event
.composedPath()
.find((node) => node instanceof HTMLAnchorElement);
if (!linkElement) return;

const url = linkElement.href;
const text = (linkElement.textContent as string | null)?.trim() ?? '';

// Skip event
if (filter && !filter(url, text)) return;

plausible.sendEvent(eventName, {
props: {
url,
text: captureText ? text : undefined,
},
});
};

document.addEventListener('click', clickCallback, { capture: true });

return function cleanup() {
document.removeEventListener('click', clickCallback, { capture: true });
};
};
Loading