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 .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ MATOMO_URL=https://analytics.example.com
MATOMO_SITE_ID=1
MATOMO_TIMEOUT_MS=5000
LOG_LEVEL=debug
HTTP_METHOD_ALLOWLIST=GET
USER_AGENT_ALLOWLIST_REGEX=(?:ChatGPT-User|MistralAI-User|Gemini-Deep-Research|Claude-User|Perplexity-User|Google-NotebookLM|Devin)
URL_EXCLUDE_REGEX=^[^?]+\.(?:css|js|mjs|map|json|xml|webmanifest|manifest|png|jpe?g|gif|webp|avif|svg|ico|bmp|tiff?|woff2?|ttf|otf|eot|rss|atom|wasm|txt)(?:\?|$)
DOCUMENT_REGEX=^[^?]+\.(?:pdf|docx?|xlsx?|pptx?|csv|json|txt|xml|epub|mobi|azw3|mp3|mp4|mpe?g|webm|mov|avi|ogg|wav|flac|zip|gz|gzip|tgz|tar|bz2|tbz|7z|rar|dmg|exe|msi|apk|jar|md5|sig)(?:\?|$)
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Cloudflare Worker (TypeScript, Node 24 tooling) that sits inline on your zone, p
- `MATOMO_URL` (required): Base Matomo URL, e.g. `https://analytics.example.com`.
- `MATOMO_SITE_ID` (required): Matomo site ID (integer).
- `MATOMO_TIMEOUT_MS` (optional, default `5000`): HTTP timeout in ms for Matomo calls.
- `HTTP_METHOD_ALLOWLIST` (optional, default `GET`): Comma-separated list of HTTP methods to track (e.g. `GET,POST`); empty/unset uses the default.
- `DOCUMENT_REGEX` (optional): Case-insensitive regex to detect downloads; matching URLs add `download=<url>` to Matomo payloads. This regex runs against the full URL (`protocol://host/path?query`) and defaults to a modern/common set of extensions:
- Documents: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`
- Data/text: `.csv`, `.json`, `.txt`, `.xml`
Expand Down Expand Up @@ -50,7 +51,7 @@ Wrangler bundles the TypeScript entry for you; no manual build is required for `

- Install Wrangler (e.g., `npm install -g wrangler` or `npx wrangler --version` to use npx).
- Copy `.dev.vars.example` to `.dev.vars` and set your local values (these are only for `wrangler dev --local`):
- `MATOMO_URL`, `MATOMO_SITE_ID`, `MATOMO_TIMEOUT_MS`, `LOG_LEVEL`, `USER_AGENT_ALLOWLIST_REGEX`, `URL_EXCLUDE_REGEX`, `DOCUMENT_REGEX`
- `MATOMO_URL`, `MATOMO_SITE_ID`, `MATOMO_TIMEOUT_MS`, `LOG_LEVEL`, `HTTP_METHOD_ALLOWLIST`, `USER_AGENT_ALLOWLIST_REGEX`, `URL_EXCLUDE_REGEX`, `DOCUMENT_REGEX`
- Start local dev (serves on http://localhost:8787 by default):

```sh
Expand Down Expand Up @@ -101,7 +102,7 @@ The Worker simply calls `fetch(request)` to reach your origin and separately pos
- Receives each incoming request, proxies to origin with `fetch`, and returns the origin response. If configuration is invalid, logs an error and just proxies (no tracking).
- Measures server time (`pf_srv` in seconds), status, and response bytes from `Content-Length` when present.
- Builds a Matomo payload with `idsite`, `rec:1`, `recMode:1`, `url`, `source:'Cloudflare'`, `cdt` (UTC `YYYY-MM-DD HH:mm:ss`), and `ua`.
- Skips tracking when `URL_EXCLUDE_REGEX` matches; detects downloads via `DOCUMENT_REGEX`; disallowed UAs are skipped by `USER_AGENT_ALLOWLIST_REGEX`.
- Skips tracking when `URL_EXCLUDE_REGEX` matches; detects downloads via `DOCUMENT_REGEX`; disallowed UAs are skipped by `USER_AGENT_ALLOWLIST_REGEX`; skips tracking when request method not in `HTTP_METHOD_ALLOWLIST`.
- Sends a single Matomo hit asynchronously via `waitUntil` to `/matomo.php` (standard tracking API) with timeout.

## Logging
Expand Down
20 changes: 20 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const defaultUserAgentPatterns = [
const defaultAllowlistPattern = `(?:${defaultUserAgentPatterns
.map(escapeRegex)
.join('|')})`;
const defaultHttpMethodAllowlist = ['GET'];
const defaultDocumentPattern =
'^[^?]+\\.(?:pdf|docx?|xlsx?|pptx?|csv|json|txt|xml|epub|mobi|azw3|mp3|mp4|mpe?g|webm|mov|avi|ogg|wav|flac|zip|gz|gzip|tgz|tar|bz2|tbz|7z|rar|dmg|exe|msi|apk|jar|md5|sig)(?:\\?|$)';
const defaultUrlExcludePattern =
Expand All @@ -47,6 +48,24 @@ export function getConfig(

const matomoTimeoutMs = toInt(env.MATOMO_TIMEOUT_MS, 5000) ?? 5000;
const logLevel = (env.LOG_LEVEL || 'warn').toLowerCase() as LogLevel;
const httpMethodAllowlist =
env.HTTP_METHOD_ALLOWLIST && env.HTTP_METHOD_ALLOWLIST.trim()
? env.HTTP_METHOD_ALLOWLIST.split(',').map((v) => v.trim().toUpperCase())
: defaultHttpMethodAllowlist;
const normalizedHttpMethodAllowlist = Array.from(
new Set(httpMethodAllowlist.filter(Boolean))
);
if (normalizedHttpMethodAllowlist.length === 0) {
throw new Error('HTTP_METHOD_ALLOWLIST must include at least one method');
}
const invalidMethod = normalizedHttpMethodAllowlist.find(
(method) => !/^[A-Z]+$/.test(method)
);
if (invalidMethod) {
throw new Error(
`Invalid HTTP_METHOD_ALLOWLIST entry "${invalidMethod}" (expected letters only)`
);
}
const allowlistPattern =
env.USER_AGENT_ALLOWLIST_REGEX || defaultAllowlistPattern;
const urlExcludePattern = env.URL_EXCLUDE_REGEX || defaultUrlExcludePattern;
Expand Down Expand Up @@ -78,6 +97,7 @@ export function getConfig(
matomoSiteId,
matomoTimeoutMs,
logLevel,
httpMethodAllowlist: normalizedHttpMethodAllowlist,
userAgentAllowlistRegex,
urlExcludeRegex,
documentRegex
Expand Down
9 changes: 9 additions & 0 deletions src/matomo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ export function buildMatomoPayload(
throw new Error('matomoSiteId is required in config');
}

const method = request.method.toUpperCase();
if (
Array.isArray(config.httpMethodAllowlist) &&
config.httpMethodAllowlist.length > 0 &&
!config.httpMethodAllowlist.includes(method)
) {
return null;
}

const url = request.url;
if (config.urlExcludeRegex && config.urlExcludeRegex.test(url)) {
return null;
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface Env {
MATOMO_SITE_ID: string;
MATOMO_TIMEOUT_MS?: string;
LOG_LEVEL?: LogLevel;
HTTP_METHOD_ALLOWLIST?: string;
USER_AGENT_ALLOWLIST_REGEX?: string;
URL_EXCLUDE_REGEX?: string;
DOCUMENT_REGEX?: string;
Expand All @@ -23,6 +24,7 @@ export interface MatomoConfig {
matomoSiteId: number;
matomoTimeoutMs: number;
logLevel: LogLevel;
httpMethodAllowlist: string[];
userAgentAllowlistRegex?: RegExp;
urlExcludeRegex?: RegExp;
documentRegex?: RegExp;
Expand Down
26 changes: 24 additions & 2 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ describe('getConfig', () => {
matomoUrl: baseEnv.MATOMO_URL,
matomoSiteId: 42,
matomoTimeoutMs: 5000,
logLevel: 'warn'
logLevel: 'warn',
httpMethodAllowlist: ['GET']
});
expect(config.userAgentAllowlistRegex).toEqual(
/(?:ChatGPT-User|MistralAI-User|Gemini-Deep-Research|Claude-User|Perplexity-User|Google-NotebookLM|Devin)/i
Expand All @@ -31,6 +32,7 @@ describe('getConfig', () => {
...baseEnv,
MATOMO_TIMEOUT_MS: '8000',
LOG_LEVEL: 'debug',
HTTP_METHOD_ALLOWLIST: 'get, post',
USER_AGENT_ALLOWLIST_REGEX: 'CustomBot',
URL_EXCLUDE_REGEX: '\\.(?:js|css)$',
DOCUMENT_REGEX: '\\.custom$'
Expand All @@ -39,13 +41,24 @@ describe('getConfig', () => {
matomoUrl: baseEnv.MATOMO_URL,
matomoSiteId: 42,
matomoTimeoutMs: 8000,
logLevel: 'debug'
logLevel: 'debug',
httpMethodAllowlist: ['GET', 'POST']
});
expect(config.userAgentAllowlistRegex).toEqual(/CustomBot/i);
expect(config.urlExcludeRegex).toEqual(/\.(?:js|css)$/i);
expect(config.documentRegex).toEqual(/\.custom$/i);
});

it('defaults to GET when HTTP_METHOD_ALLOWLIST is empty/blank', () => {
expect(
getConfig({ ...baseEnv, HTTP_METHOD_ALLOWLIST: '' }).httpMethodAllowlist
).toEqual(['GET']);
expect(
getConfig({ ...baseEnv, HTTP_METHOD_ALLOWLIST: ' ' })
.httpMethodAllowlist
).toEqual(['GET']);
});

it('throws on invalid regex config', () => {
expect(() =>
getConfig({ ...baseEnv, USER_AGENT_ALLOWLIST_REGEX: '[' })
Expand All @@ -58,6 +71,15 @@ describe('getConfig', () => {
);
});

it('throws on invalid HTTP_METHOD_ALLOWLIST', () => {
expect(() => getConfig({ ...baseEnv, HTTP_METHOD_ALLOWLIST: '$' })).toThrow(
/Invalid HTTP_METHOD_ALLOWLIST/
);
expect(() => getConfig({ ...baseEnv, HTTP_METHOD_ALLOWLIST: ',' })).toThrow(
/HTTP_METHOD_ALLOWLIST must include at least one method/
);
});

it('throws when MATOMO_URL is missing', () => {
expect(() => getConfig({ MATOMO_SITE_ID: '1' })).toThrow(
/MATOMO_URL is required/
Expand Down
35 changes: 35 additions & 0 deletions tests/matomo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,41 @@ describe('buildMatomoPayload (Worker)', () => {
expect(payload).toBeNull();
});

it('returns null when http method is not allowlisted', () => {
const response = new Response(null, { status: 200 });
const payload = buildMatomoPayload(
new Request('https://example.com/path', {
method: 'POST',
headers: { 'user-agent': 'AgentX' }
}),
response,
10,
config
);
expect(payload).toBeNull();
});

it('tracks request when http method is allowlisted', () => {
const cfg = getConfig({
MATOMO_URL: 'https://analytics.example.com',
MATOMO_SITE_ID: '99',
HTTP_METHOD_ALLOWLIST: 'GET,POST',
USER_AGENT_ALLOWLIST_REGEX: '.*'
});
const response = new Response(null, { status: 200 });
const payload = buildMatomoPayload(
new Request('https://example.com/path', {
method: 'POST',
headers: { 'user-agent': 'AgentX' }
}),
response,
10,
cfg
);
expect(payload).not.toBeNull();
expect(payload?.url).toBe('https://example.com/path');
});

it('uses defaults when user agent allowlist is disabled', () => {
const response = new Response(null, { status: 200 });
const payload = buildMatomoPayload(
Expand Down