From d9814ca9f65a2f514db7df9f8ca5d11c784ef362 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 26 Sep 2026 16:28:21 +0100 Subject: [PATCH 1/2] feat: Page title tracking --- .../src/analytics/analytics.controller.ts | 1 + .../cloud/src/analytics/dto/pageviews.dto.ts | 12 +++ .../cloud/src/analytics/utils/transformers.ts | 2 + .../v2/__tests__/page-titles.spec.ts | 86 ++++++++++++++++++ .../src/analytics/v2/registry/dimensions.ts | 7 ++ backend/apps/cloud/src/common/constants.ts | 2 + .../cloud/src/demo-data/demo-data.service.ts | 20 ++++ .../src/analytics/analytics.controller.ts | 1 + .../src/analytics/dto/pageviews.dto.ts | 12 +++ .../src/analytics/utils/transformers.ts | 2 + .../v2/__tests__/page-titles.spec.ts | 86 ++++++++++++++++++ .../src/analytics/v2/registry/dimensions.ts | 7 ++ .../apps/community/src/common/constants.ts | 2 + .../clickhouse/2026_09_26_page_titles.js | 5 + .../clickhouse/2026_09_26_page_titles.md | 10 ++ .../clickhouse/initialise_database.js | 4 +- .../selfhosted_2026_09_26_page_titles.js | 5 + .../docs/analytics-dashboard/traffic.mdx | 3 + docs/content/docs/api/events.mdx | 1 + docs/content/docs/script-reference.mdx | 26 +++++- packages/tracker-js/README.md | 12 +++ packages/tracker-js/src/Lib.ts | 6 ++ packages/tracker-js/tests/pageTitle.test.ts | 91 +++++++++++++++++++ packages/tracker-node/src/index.ts | 1 + web/app/lib/v2Dimensions.ts | 1 + web/app/pages/Project/View/Panels.tsx | 4 + .../Project/View/ViewProject.helpers.tsx | 1 + .../Project/tabs/Traffic/TrafficView.tsx | 6 ++ web/app/ui/FilterValueInput.tsx | 1 + web/public/locales/en.json | 1 + 30 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 backend/apps/cloud/src/analytics/v2/__tests__/page-titles.spec.ts create mode 100644 backend/apps/community/src/analytics/v2/__tests__/page-titles.spec.ts create mode 100644 backend/migrations/clickhouse/2026_09_26_page_titles.js create mode 100644 backend/migrations/clickhouse/2026_09_26_page_titles.md create mode 100644 backend/migrations/clickhouse/selfhosted_2026_09_26_page_titles.js create mode 100644 packages/tracker-js/tests/pageTitle.test.ts diff --git a/backend/apps/cloud/src/analytics/analytics.controller.ts b/backend/apps/cloud/src/analytics/analytics.controller.ts index 868c87b34..2008159ba 100644 --- a/backend/apps/cloud/src/analytics/analytics.controller.ts +++ b/backend/apps/cloud/src/analytics/analytics.controller.ts @@ -2082,6 +2082,7 @@ export class AnalyticsController { pid: logDTO.pid, host: this.analyticsService.getHostFromOrigin(headers.origin), pg: logDTO.pg, + title: logDTO.title, dv: deviceType, br: browserName, brv: browserVersion, diff --git a/backend/apps/cloud/src/analytics/dto/pageviews.dto.ts b/backend/apps/cloud/src/analytics/dto/pageviews.dto.ts index e63d1afc4..9ba8c0836 100644 --- a/backend/apps/cloud/src/analytics/dto/pageviews.dto.ts +++ b/backend/apps/cloud/src/analytics/dto/pageviews.dto.ts @@ -56,6 +56,18 @@ export class PageviewsDto { @IsString() pg?: string + @ApiProperty({ + example: 'Pricing | Swetrix', + required: false, + nullable: true, + description: 'Page title at the time of the pageview', + maxLength: 2048, + }) + @IsOptional() + @IsString() + @MaxLength(2048) + title?: string | null + @ApiProperty({ example: 'en-GB', description: "User's locale", diff --git a/backend/apps/cloud/src/analytics/utils/transformers.ts b/backend/apps/cloud/src/analytics/utils/transformers.ts index e192424b0..bd1ef1e99 100644 --- a/backend/apps/cloud/src/analytics/utils/transformers.ts +++ b/backend/apps/cloud/src/analytics/utils/transformers.ts @@ -55,6 +55,7 @@ interface CommonOptions { interface PageviewOptions extends CommonOptions { type: 'pageview' + title?: string | null } interface CustomEventOptions extends CommonOptions { @@ -147,6 +148,7 @@ export const eventTransformer = (opts: EventTransformerOptions) => { return { type: 'pageview' as const, ...buildCommon(opts), + title: opts.title || null, created, } } diff --git a/backend/apps/cloud/src/analytics/v2/__tests__/page-titles.spec.ts b/backend/apps/cloud/src/analytics/v2/__tests__/page-titles.spec.ts new file mode 100644 index 000000000..00d593d3e --- /dev/null +++ b/backend/apps/cloud/src/analytics/v2/__tests__/page-titles.spec.ts @@ -0,0 +1,86 @@ +import { plainToInstance } from 'class-transformer' +import { validate } from 'class-validator' + +import { AnalyticsService, DataType } from '../../analytics.service' +import { PageviewsDto } from '../../dto/pageviews.dto' +import { eventTransformer } from '../../utils/transformers' +import { buildBreakdownQuery } from '../query/breakdown-query.builder' +import { parseV2Filters, toV1FiltersJson } from '../query/filters.translator' +import { getBreakdownDimension, parseMetricsParam } from '../registry' +import { V2_VIEW_FILTER_DIMENSIONS } from '../../../common/constants' + +const pid = 'testproject1' + +describe('Page titles', () => { + it.each([undefined, null, '', 'Pricing — 日本語', 'a'.repeat(2048)])( + 'accepts an optional title and preserves it for storage (case %#)', + async (title) => { + const dto = plainToInstance(PageviewsDto, { pid, pg: '/pricing', title }) + expect(await validate(dto, { whitelist: true })).toEqual([]) + expect( + eventTransformer({ + type: 'pageview', + pid: dto.pid, + pg: dto.pg, + title: dto.title, + }), + ).toMatchObject({ + pg: '/pricing', + title: title || null, + }) + }, + ) + + it.each([42, {}, ['Pricing'], 'a'.repeat(2049)])( + 'rejects invalid titles (case %#)', + async (title) => { + const errors = await validate( + plainToInstance(PageviewsDto, { pid, title }), + ) + expect(errors.some((error) => error.property === 'title')).toBe(true) + }, + ) + + it('groups traffic by title while excluding historical rows without titles', () => { + const query = buildBreakdownQuery({ + dataType: 'traffic', + dimension: getBreakdownDimension('title', 'traffic'), + metrics: parseMetricsParam('visitors,pageviews', 'traffic'), + subQuery: + "FROM events WHERE pid = {pid:FixedString(12)} AND type = 'pageview'", + ctx: { customEVFilterApplied: false }, + sort: { field: 'visitors', direction: 'desc' }, + }) + expect(query).toContain('title AS value') + expect(query).toContain('AND title IS NOT NULL') + expect(query).toContain('GROUP BY value') + expect(query).toContain('count(DISTINCT psid) AS visitors') + expect(query).toContain('count(*) AS pageviews') + }) + + it.each([ + ['is', 'title ='], + ['is_not', 'NOT title ='], + ['contains', 'title ILIKE'], + ['contains_not', 'NOT title ILIKE'], + ])('compiles the %s title filter with a bound value', (operator, sql) => { + const value = "Pricing's — 日本語" + const filters = parseV2Filters( + JSON.stringify([{ dimension: 'title', operator, value }]), + ) + const service = Object.create( + AnalyticsService.prototype, + ) as AnalyticsService + const [query, params] = service.getFiltersQuery( + toV1FiltersJson(filters, 'traffic'), + DataType.ANALYTICS, + ) + expect(query).toContain(sql) + expect(query).not.toContain(value) + expect(Object.values(params)).toContain(value) + }) + + it('allows title filters in saved views', () => { + expect(V2_VIEW_FILTER_DIMENSIONS).toContain('title') + }) +}) diff --git a/backend/apps/cloud/src/analytics/v2/registry/dimensions.ts b/backend/apps/cloud/src/analytics/v2/registry/dimensions.ts index 22858d453..75bd85cab 100644 --- a/backend/apps/cloud/src/analytics/v2/registry/dimensions.ts +++ b/backend/apps/cloud/src/analytics/v2/registry/dimensions.ts @@ -40,6 +40,13 @@ export const V2_DIMENSIONS: V2DimensionDef[] = [ types: GEO_TYPES, description: 'Page path', }, + { + api: 'title', + column: 'title', + types: ['traffic'], + excludeNull: true, + description: 'Page title', + }, { api: 'host', column: 'host', diff --git a/backend/apps/cloud/src/common/constants.ts b/backend/apps/cloud/src/common/constants.ts index 1849c8f3a..199d34ece 100644 --- a/backend/apps/cloud/src/common/constants.ts +++ b/backend/apps/cloud/src/common/constants.ts @@ -88,6 +88,7 @@ const TRAFFIC_COLUMNS = [ 'ctp', 'host', 'pg', + 'title', 'lc', 'br', 'brv', @@ -111,6 +112,7 @@ const V2_VIEW_FILTER_DIMENSIONS = [ 'region', 'city', 'page', + 'title', 'host', 'locale', 'browser', diff --git a/backend/apps/cloud/src/demo-data/demo-data.service.ts b/backend/apps/cloud/src/demo-data/demo-data.service.ts index 64b362708..03b353f57 100644 --- a/backend/apps/cloud/src/demo-data/demo-data.service.ts +++ b/backend/apps/cloud/src/demo-data/demo-data.service.ts @@ -44,6 +44,25 @@ const BACKFILL_DAYS = 90 const BASE_SESSIONS_PER_HOUR = 16 const DEMO_HOST = 'swetrix.com' +const DEMO_PAGE_TITLES: Record = { + '/': 'Privacy-friendly web analytics | Swetrix', + '/pricing': 'Pricing | Swetrix', + '/docs': 'Documentation | Swetrix', + '/open-source': 'Open-source analytics | Swetrix', + '/alternatives/google-analytics': 'Google Analytics alternative | Swetrix', + '/features/errors': 'Error tracking | Swetrix', + '/features/session-replays': 'Session replays | Swetrix', + '/blog/privacy-friendly-analytics': + 'Privacy-friendly analytics | Swetrix Blog', + '/blog/cookieless-tracking': 'Cookieless tracking | Swetrix Blog', + '/blog/session-replay-privacy': 'Session replay privacy | Swetrix Blog', + '/signup': 'Create an account | Swetrix', + '/dashboard': 'Dashboard | Swetrix', + '/checkout': 'Checkout | Swetrix', + '/thank-you': 'Thank you | Swetrix', + '/settings/billing': 'Billing settings | Swetrix', +} + type DemoRandom = () => number interface Weighted { @@ -1822,6 +1841,7 @@ export class DemoDataService implements OnModuleInit { ...eventTransformer({ type: 'pageview', ...this.commonEvent(session, page), + title: DEMO_PAGE_TITLES[page], }), created: this.format(created), } diff --git a/backend/apps/community/src/analytics/analytics.controller.ts b/backend/apps/community/src/analytics/analytics.controller.ts index 823c40a9f..4d3d63dd2 100644 --- a/backend/apps/community/src/analytics/analytics.controller.ts +++ b/backend/apps/community/src/analytics/analytics.controller.ts @@ -1638,6 +1638,7 @@ export class AnalyticsController { pid: logDTO.pid, host: this.analyticsService.getHostFromOrigin(headers.origin), pg: logDTO.pg, + title: logDTO.title, dv: deviceType, br: browserName, brv: browserVersion, diff --git a/backend/apps/community/src/analytics/dto/pageviews.dto.ts b/backend/apps/community/src/analytics/dto/pageviews.dto.ts index e63d1afc4..9ba8c0836 100644 --- a/backend/apps/community/src/analytics/dto/pageviews.dto.ts +++ b/backend/apps/community/src/analytics/dto/pageviews.dto.ts @@ -56,6 +56,18 @@ export class PageviewsDto { @IsString() pg?: string + @ApiProperty({ + example: 'Pricing | Swetrix', + required: false, + nullable: true, + description: 'Page title at the time of the pageview', + maxLength: 2048, + }) + @IsOptional() + @IsString() + @MaxLength(2048) + title?: string | null + @ApiProperty({ example: 'en-GB', description: "User's locale", diff --git a/backend/apps/community/src/analytics/utils/transformers.ts b/backend/apps/community/src/analytics/utils/transformers.ts index e192424b0..bd1ef1e99 100644 --- a/backend/apps/community/src/analytics/utils/transformers.ts +++ b/backend/apps/community/src/analytics/utils/transformers.ts @@ -55,6 +55,7 @@ interface CommonOptions { interface PageviewOptions extends CommonOptions { type: 'pageview' + title?: string | null } interface CustomEventOptions extends CommonOptions { @@ -147,6 +148,7 @@ export const eventTransformer = (opts: EventTransformerOptions) => { return { type: 'pageview' as const, ...buildCommon(opts), + title: opts.title || null, created, } } diff --git a/backend/apps/community/src/analytics/v2/__tests__/page-titles.spec.ts b/backend/apps/community/src/analytics/v2/__tests__/page-titles.spec.ts new file mode 100644 index 000000000..00d593d3e --- /dev/null +++ b/backend/apps/community/src/analytics/v2/__tests__/page-titles.spec.ts @@ -0,0 +1,86 @@ +import { plainToInstance } from 'class-transformer' +import { validate } from 'class-validator' + +import { AnalyticsService, DataType } from '../../analytics.service' +import { PageviewsDto } from '../../dto/pageviews.dto' +import { eventTransformer } from '../../utils/transformers' +import { buildBreakdownQuery } from '../query/breakdown-query.builder' +import { parseV2Filters, toV1FiltersJson } from '../query/filters.translator' +import { getBreakdownDimension, parseMetricsParam } from '../registry' +import { V2_VIEW_FILTER_DIMENSIONS } from '../../../common/constants' + +const pid = 'testproject1' + +describe('Page titles', () => { + it.each([undefined, null, '', 'Pricing — 日本語', 'a'.repeat(2048)])( + 'accepts an optional title and preserves it for storage (case %#)', + async (title) => { + const dto = plainToInstance(PageviewsDto, { pid, pg: '/pricing', title }) + expect(await validate(dto, { whitelist: true })).toEqual([]) + expect( + eventTransformer({ + type: 'pageview', + pid: dto.pid, + pg: dto.pg, + title: dto.title, + }), + ).toMatchObject({ + pg: '/pricing', + title: title || null, + }) + }, + ) + + it.each([42, {}, ['Pricing'], 'a'.repeat(2049)])( + 'rejects invalid titles (case %#)', + async (title) => { + const errors = await validate( + plainToInstance(PageviewsDto, { pid, title }), + ) + expect(errors.some((error) => error.property === 'title')).toBe(true) + }, + ) + + it('groups traffic by title while excluding historical rows without titles', () => { + const query = buildBreakdownQuery({ + dataType: 'traffic', + dimension: getBreakdownDimension('title', 'traffic'), + metrics: parseMetricsParam('visitors,pageviews', 'traffic'), + subQuery: + "FROM events WHERE pid = {pid:FixedString(12)} AND type = 'pageview'", + ctx: { customEVFilterApplied: false }, + sort: { field: 'visitors', direction: 'desc' }, + }) + expect(query).toContain('title AS value') + expect(query).toContain('AND title IS NOT NULL') + expect(query).toContain('GROUP BY value') + expect(query).toContain('count(DISTINCT psid) AS visitors') + expect(query).toContain('count(*) AS pageviews') + }) + + it.each([ + ['is', 'title ='], + ['is_not', 'NOT title ='], + ['contains', 'title ILIKE'], + ['contains_not', 'NOT title ILIKE'], + ])('compiles the %s title filter with a bound value', (operator, sql) => { + const value = "Pricing's — 日本語" + const filters = parseV2Filters( + JSON.stringify([{ dimension: 'title', operator, value }]), + ) + const service = Object.create( + AnalyticsService.prototype, + ) as AnalyticsService + const [query, params] = service.getFiltersQuery( + toV1FiltersJson(filters, 'traffic'), + DataType.ANALYTICS, + ) + expect(query).toContain(sql) + expect(query).not.toContain(value) + expect(Object.values(params)).toContain(value) + }) + + it('allows title filters in saved views', () => { + expect(V2_VIEW_FILTER_DIMENSIONS).toContain('title') + }) +}) diff --git a/backend/apps/community/src/analytics/v2/registry/dimensions.ts b/backend/apps/community/src/analytics/v2/registry/dimensions.ts index 22858d453..75bd85cab 100644 --- a/backend/apps/community/src/analytics/v2/registry/dimensions.ts +++ b/backend/apps/community/src/analytics/v2/registry/dimensions.ts @@ -40,6 +40,13 @@ export const V2_DIMENSIONS: V2DimensionDef[] = [ types: GEO_TYPES, description: 'Page path', }, + { + api: 'title', + column: 'title', + types: ['traffic'], + excludeNull: true, + description: 'Page title', + }, { api: 'host', column: 'host', diff --git a/backend/apps/community/src/common/constants.ts b/backend/apps/community/src/common/constants.ts index 0214e5005..b2a677cb6 100644 --- a/backend/apps/community/src/common/constants.ts +++ b/backend/apps/community/src/common/constants.ts @@ -97,6 +97,7 @@ const TRAFFIC_COLUMNS = [ 'ctp', 'host', 'pg', + 'title', 'lc', 'br', 'brv', @@ -138,6 +139,7 @@ const V2_VIEW_FILTER_DIMENSIONS = [ 'region', 'city', 'page', + 'title', 'host', 'locale', 'browser', diff --git a/backend/migrations/clickhouse/2026_09_26_page_titles.js b/backend/migrations/clickhouse/2026_09_26_page_titles.js new file mode 100644 index 000000000..839962a52 --- /dev/null +++ b/backend/migrations/clickhouse/2026_09_26_page_titles.js @@ -0,0 +1,5 @@ +const { queriesRunner, dbName } = require('./setup') + +queriesRunner([ + `ALTER TABLE ${dbName}.events ADD COLUMN IF NOT EXISTS title Nullable(String) CODEC(ZSTD(3)) AFTER pg`, +]) diff --git a/backend/migrations/clickhouse/2026_09_26_page_titles.md b/backend/migrations/clickhouse/2026_09_26_page_titles.md new file mode 100644 index 000000000..7a83a6dfc --- /dev/null +++ b/backend/migrations/clickhouse/2026_09_26_page_titles.md @@ -0,0 +1,10 @@ +# Page titles + +Apply the schema change before deploying the API and dashboard that use titles. From `backend`, with the target ClickHouse environment configured: + +- Cloud: `node migrations/clickhouse/2026_09_26_page_titles.js` +- Community: `node migrations/clickhouse/selfhosted_2026_09_26_page_titles.js` + +The shared database initialiser also creates and upgrades the `events.title` column, including Community startup via `npm run clickhouse:initialise`. Both standalone migrations are safe to rerun. + +Deploy the updated browser tracker to start collecting titles. Existing pageviews remain untitled; no historical titles are inferred. Server-side callers can supply `title` through the Events API or `TrackPageViewOptions` in the Node tracker. diff --git a/backend/migrations/clickhouse/initialise_database.js b/backend/migrations/clickhouse/initialise_database.js index aabea4ccc..a13a0aa16 100644 --- a/backend/migrations/clickhouse/initialise_database.js +++ b/backend/migrations/clickhouse/initialise_database.js @@ -4,7 +4,6 @@ const { queriesRunner, dbName, databaselessQueriesRunner } = require('./setup') const CLICKHOUSE_DB_INIT_QUERIES = [`CREATE DATABASE IF NOT EXISTS ${dbName}`] const CLICKHOUSE_INIT_QUERIES = [ - `CREATE TABLE IF NOT EXISTS ${dbName}.events ( type LowCardinality(String), @@ -14,6 +13,7 @@ const CLICKHOUSE_INIT_QUERIES = [ profileId Nullable(String) CODEC(ZSTD(3)), host Nullable(String) CODEC(ZSTD(3)), pg Nullable(String) CODEC(ZSTD(3)), + title Nullable(String) CODEC(ZSTD(3)), dv LowCardinality(Nullable(String)), br LowCardinality(Nullable(String)), brv Nullable(String) CODEC(ZSTD(3)), @@ -59,6 +59,8 @@ const CLICKHOUSE_INIT_QUERIES = [ PARTITION BY toYYYYMM(created) ORDER BY (pid, type, created);`, + `ALTER TABLE ${dbName}.events ADD COLUMN IF NOT EXISTS title Nullable(String) CODEC(ZSTD(3)) AFTER pg`, + // Error events status table `CREATE TABLE IF NOT EXISTS ${dbName}.error_statuses ( eid FixedString(32), diff --git a/backend/migrations/clickhouse/selfhosted_2026_09_26_page_titles.js b/backend/migrations/clickhouse/selfhosted_2026_09_26_page_titles.js new file mode 100644 index 000000000..839962a52 --- /dev/null +++ b/backend/migrations/clickhouse/selfhosted_2026_09_26_page_titles.js @@ -0,0 +1,5 @@ +const { queriesRunner, dbName } = require('./setup') + +queriesRunner([ + `ALTER TABLE ${dbName}.events ADD COLUMN IF NOT EXISTS title Nullable(String) CODEC(ZSTD(3)) AFTER pg`, +]) diff --git a/docs/content/docs/analytics-dashboard/traffic.mdx b/docs/content/docs/analytics-dashboard/traffic.mdx index 25c5011cb..8ddaca2de 100644 --- a/docs/content/docs/analytics-dashboard/traffic.mdx +++ b/docs/content/docs/analytics-dashboard/traffic.mdx @@ -60,6 +60,7 @@ Understand the technology your visitors are using: See which parts of your website are most popular: - **Page Paths**: The specific URLs visited. +- **Titles**: Page titles captured with pageviews. Click a title to filter traffic, or use the filter menu for exact, contains, and exclusion filters. Pages sharing a title are grouped together; combine title and page-path filters to narrow them down. - **Entry Pages**: The first page visitors land on. - **Exit Pages**: The last page visitors view before leaving. - **Host**: Useful for cross-domain tracking, showing which specific domain or subdomain the traffic originated from. @@ -68,6 +69,8 @@ See which parts of your website are most popular: Looking for the paths visitors take through your site? Check out the dedicated **[Journeys](/analytics-dashboard/journeys)** tab. ::: +Titles are collected by the browser tracker and can be [overridden or disabled](/swetrix-js-reference#page-titles). The Titles breakdown excludes pageviews without titles, including older data. This feature is available in Cloud and Community editions. + ### Traffic Sources Identify how visitors find your website: diff --git a/docs/content/docs/api/events.mdx b/docs/content/docs/api/events.mdx index 481b3554c..17d9df7f1 100644 --- a/docs/content/docs/api/events.mdx +++ b/docs/content/docs/api/events.mdx @@ -63,6 +63,7 @@ Must be set to `application/json` for all requests. | `pid` | `string` | `true` | A project ID to record the pageview event for | | `tz` | `string` | `false` | Visitor's timezone (used as a backup in case IP geolocation fails). I.e. if it's set to `Europe/Kiev` and IP geolocation fails, we will set the country of this entry to `Ukraine`) | | `pg` | `string` | `false` | A page to record the pageview event for (e.g. `/home`). All our scripts send the `pg` string with a slash (`/`) at the beginning, it's not a requirement but it's best to do the same so the data would be consistent when used together with our official scripts | +| `title` | `string` or `null` | `false` | Page title, up to 2,048 characters. Omit it or send `null` to leave the title unset. | | `lc` | `string` | `false` | A locale of the user (e.g. `en-US` or `uk-UA`) | | `ref` | `string` | `false` | A referrer URL (e.g. `https://example.com/`) | | `so` | `string` | `false` | A source of the pageview (e.g. `ref`, `source` or `utm_source` GET parameter) | diff --git a/docs/content/docs/script-reference.mdx b/docs/content/docs/script-reference.mdx index ef3bbcc30..feb2c47f2 100644 --- a/docs/content/docs/script-reference.mdx +++ b/docs/content/docs/script-reference.mdx @@ -108,7 +108,7 @@ swetrix.trackViews({ | Name | Description | Default value | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | | unique | If true, only unique events will be saved. This param is useful when tracking single-page landing websites. | `false` | -| callback | A callback used to edit / prevent sending pageviews.
It accepts an object with pageview event data as a parameter which has the following structure:
lc: string \| undefined
tz: string \| undefined
ref: string \| undefined
so: string \| undefined
me: string \| undefined
ca: string \| undefined
te: string \| undefined
co: string \| undefined
qs: string \| undefined
meta: object \| undefined ("object" values must be string, number, boolean, null, or undefined)
pg: string \| null \| undefined
profileId: string \| undefined

The callback is supposed to return the edited payload or `false` to prevent sending the pageview. If `true` is returned, the payload will be sent as-is. | `undefined` | +| callback | A callback used to edit / prevent sending pageviews.
It accepts an object with pageview event data as a parameter which has the following structure:
lc: string \| undefined
tz: string \| undefined
ref: string \| undefined
so: string \| undefined
me: string \| undefined
ca: string \| undefined
te: string \| undefined
co: string \| undefined
qs: string \| undefined
meta: object \| undefined ("object" values must be string, number, boolean, null, or undefined)
pg: string \| null \| undefined
title: string \| null \| undefined
profileId: string \| undefined

The callback is supposed to return the edited payload or `false` to prevent sending the pageview. If `true` is returned, the payload will be sent as-is. | `undefined` | | heartbeatOnBackground | Send Heartbeat requests when the website tab is not active in the browser.
Setting this to `true` means users who keep your website open in an inactive browser tab or window can still be counted in realtime statistics.
Setting this to true is usually useful for services like Spotify or Youtube. | `false` | | hash | Set to `true` to enable hash-based routing for every hash, or provide an array of exact hash values to include. For example, `['pricing', 'features']` includes `/path#pricing` and `/path#features` while ignoring other hashes. A leading `#` is optional in array values. | `false` | | search | Set to `true` to enable search-based routing for every query parameter, or provide an array of parameter names to include. For example, `['search', 'page']` includes those parameters while ignoring unrelated ones. You can enable both `hash` and `search`; a pageview is fired only when an included part of the URL changes. | `false` | @@ -122,6 +122,30 @@ The `trackViews` function returns a `Promise` with an object with some methods a } ``` +### Page titles + +The tracker reads `document.title` for each pageview, including automatic SPA navigation and manual `pageview()` calls. Titles are captured when the pageview is sent; changing only the title does not send another pageview. For routers that update the title asynchronously, send a manual pageview after the title is ready instead of enabling automatic tracking. + +The `trackViews` callback receives the title as `payload.title`. Return a replacement to override it, or `null` to skip title collection: + +```typescript +swetrix.trackViews({ + callback: (payload) => ({ ...payload, title: 'Product documentation' }), +}); +``` + +For manual tracking, pass `title` alongside `pg`: + +```typescript +swetrix.pageview({ + payload: { pg: '/pricing', title: 'Pricing' }, +}); +``` + +The exported `IPageViewPayload` type includes `title?: string | null`. Omit it to use `document.title`; use `null` or an empty string to leave the title unset. The browser tracker limits titles to 2,048 characters. Choose a generic title or disable title collection on pages with personal information in their titles. + +Titles are available in **Traffic → Pages → Titles** in both Cloud and Community editions. Older pageviews, noscript visits, and requests without a title remain untitled. + ## Noscript fallback The `/log/noscript` image pixel records pageviews for visitors with JavaScript disabled. diff --git a/packages/tracker-js/README.md b/packages/tracker-js/README.md index 1cfe8f69f..6df4d1824 100644 --- a/packages/tracker-js/README.md +++ b/packages/tracker-js/README.md @@ -151,6 +151,18 @@ trackError({ }) ``` +### Page titles + +Pageviews include the current `document.title`, including SPA path changes. Read or override `payload.title` in the `trackViews` callback, or set `payload.title` when calling `pageview`. The exported `IPageViewPayload` type accepts `title?: string | null`. + +```typescript +trackViews({ + callback: (payload) => ({ ...payload, title: 'Documentation' }), +}) +``` + +Omitting `title` uses the document title. Set it to `null` or `''` to skip title collection. Titles are limited to 2,048 characters. Only a pageview captures a title; title changes alone do not send pageviews. + ### `pageview(options)` Manually track a single page view (useful for custom routing). diff --git a/packages/tracker-js/src/Lib.ts b/packages/tracker-js/src/Lib.ts index e5bcd6687..2cb44c59e 100644 --- a/packages/tracker-js/src/Lib.ts +++ b/packages/tracker-js/src/Lib.ts @@ -119,6 +119,7 @@ export interface IPageViewPayload { te?: string co?: string pg?: string | null + title?: string | null /** * Raw URL query string of the landing page (without the leading `?`). @@ -1266,6 +1267,7 @@ export class Lib { } const pvPayload = { + title: isInBrowser() ? document.title : undefined, lc: getLocale(), tz: getTimezone(), ref: getReferrer(), @@ -1295,6 +1297,10 @@ export class Lib { this.activePage = pvPayload.pg || null } + if (typeof pvPayload.title === 'string') { + pvPayload.title = pvPayload.title.slice(0, 2048) + } + Object.assign(pvPayload, privateData) this.sendRequest('', pvPayload) diff --git a/packages/tracker-js/tests/pageTitle.test.ts b/packages/tracker-js/tests/pageTitle.test.ts new file mode 100644 index 000000000..4eec098df --- /dev/null +++ b/packages/tracker-js/tests/pageTitle.test.ts @@ -0,0 +1,91 @@ +import { Lib } from '../src/Lib' +import { setLocation } from './testUtils' + +describe('Page titles', () => { + let lib: Lib + let sendRequest: jest.SpyInstance + + beforeEach(() => { + jest.useFakeTimers() + setLocation({ pathname: '/home' }) + document.title = 'Home | Example' + lib = new Lib('test-project', { devMode: true }) + sendRequest = jest.spyOn(lib as any, 'sendRequest').mockResolvedValue(undefined) + }) + + afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() + }) + + it('captures the document title alongside the initial page path', () => { + lib.trackPageViews() + + expect(sendRequest).toHaveBeenCalledWith( + '', + expect.objectContaining({ + pg: '/home', + title: 'Home | Example', + }), + ) + }) + + it('reads the new title on SPA navigation without firing for title-only changes', () => { + lib.trackPageViews() + document.title = 'Home (1)' + jest.advanceTimersByTime(2000) + expect(sendRequest.mock.calls.filter(([path]) => path === '')).toHaveLength(1) + + setLocation({ pathname: '/pricing' }) + document.title = 'Pricing | Example' + jest.advanceTimersByTime(2000) + + expect(sendRequest).toHaveBeenCalledWith( + '', + expect.objectContaining({ + pg: '/pricing', + title: 'Pricing | Example', + }), + ) + }) + + it('lets the callback inspect and replace the title', () => { + const callback = jest.fn((payload) => { + expect(payload.title).toBe('Home | Example') + return { ...payload, title: 'Public title' } + }) + lib.trackPageViews({ callback }) + + expect(callback).toHaveBeenCalledTimes(1) + expect(sendRequest).toHaveBeenCalledWith('', expect.objectContaining({ title: 'Public title' })) + }) + + it.each([null, ''])('preserves a callback title opt-out of %p', (title) => { + lib.trackPageViews({ callback: () => ({ title }) }) + expect(sendRequest).toHaveBeenCalledWith('', expect.objectContaining({ title })) + }) + + it('still lets callbacks cancel the pageview', () => { + lib.trackPageViews({ callback: () => false }) + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('defaults manual pageviews to the current title', () => { + lib.submitPageView({ pg: '/manual' }, false, {}) + expect(sendRequest).toHaveBeenCalledWith('', expect.objectContaining({ pg: '/manual', title: 'Home | Example' })) + }) + + it.each(['Manual title', null, ''])('preserves a manual title of %p', (title) => { + lib.submitPageView({ pg: '/manual', title }, false, {}) + expect(sendRequest).toHaveBeenCalledWith('', expect.objectContaining({ title })) + }) + + it('limits document and callback titles without dropping the pageview', () => { + document.title = 'a'.repeat(3000) + lib.submitPageView({ pg: '/manual' }, false, {}) + expect(sendRequest).toHaveBeenLastCalledWith('', expect.objectContaining({ title: 'a'.repeat(2048) })) + + lib.trackPageViews({ callback: () => ({ title: 'b'.repeat(3000) }) }) + expect(sendRequest).toHaveBeenLastCalledWith('', expect.objectContaining({ title: 'b'.repeat(2048) })) + }) +}) diff --git a/packages/tracker-node/src/index.ts b/packages/tracker-node/src/index.ts index 4cf01d2ad..e06753918 100644 --- a/packages/tracker-node/src/index.ts +++ b/packages/tracker-node/src/index.ts @@ -112,6 +112,7 @@ export interface TrackPageViewOptions { /** A page to record the pageview event for (e.g. /home). All our scripts send the pg string with a slash (/) at the beginning, it's not a requirement but it's best to do the same so the data would be consistent when used together with our official scripts */ pg?: string + title?: string | null /** A locale of the user (e.g. en-US or uk-UA) */ lc?: string diff --git a/web/app/lib/v2Dimensions.ts b/web/app/lib/v2Dimensions.ts index 797759589..ca1995482 100644 --- a/web/app/lib/v2Dimensions.ts +++ b/web/app/lib/v2Dimensions.ts @@ -43,6 +43,7 @@ const TRAFFIC_DIMENSIONS = [ 'region', 'city', 'page', + 'title', 'host', 'locale', 'browser', diff --git a/web/app/pages/Project/View/Panels.tsx b/web/app/pages/Project/View/Panels.tsx index 2a7c00cd7..7ac788e6b 100644 --- a/web/app/pages/Project/View/Panels.tsx +++ b/web/app/pages/Project/View/Panels.tsx @@ -2093,6 +2093,10 @@ const DetailsTable = ({ } } + if (activeTabId === 'title') { + return entry.name || '' + } + try { return decodeURIComponent(entry.name || '') } catch { diff --git a/web/app/pages/Project/View/ViewProject.helpers.tsx b/web/app/pages/Project/View/ViewProject.helpers.tsx index cba0d3b98..c61f4f84d 100644 --- a/web/app/pages/Project/View/ViewProject.helpers.tsx +++ b/web/app/pages/Project/View/ViewProject.helpers.tsx @@ -2329,6 +2329,7 @@ const typeNameMapping = (t: typeof i18next.t) => ({ region: t('project.mapping.rg'), city: t('project.mapping.ct'), page: t('project.mapping.pg'), + title: t('project.mapping.title'), query: t('project.seo.query'), entry_page: t('project.entryPages'), exit_page: t('project.exitPages'), diff --git a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx index dcc66c7ce..213fafc6b 100644 --- a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx +++ b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx @@ -731,6 +731,7 @@ const TrafficViewInner = ({ const pagesSubTabs = useMemo( () => [ { id: 'page', label: t('project.mapping.pg'), dimension: 'page' }, + { id: 'title', label: t('project.mapping.title'), dimension: 'title' }, { id: 'entry_page', label: t('project.entryPages'), @@ -875,6 +876,10 @@ const TrafficViewInner = ({ ) } + if (subTabId === 'title') { + return entryName + } + let decodedUri = entryName try { @@ -969,6 +974,7 @@ const TrafficViewInner = ({ 'city', 'locale', 'page', + 'title', 'entry_page', 'exit_page', 'host', diff --git a/web/app/ui/FilterValueInput.tsx b/web/app/ui/FilterValueInput.tsx index 107121674..0c3bfb3d1 100644 --- a/web/app/ui/FilterValueInput.tsx +++ b/web/app/ui/FilterValueInput.tsx @@ -61,6 +61,7 @@ export const filterCategoryIcons: Record = { region: , city: , page: , + title: , entry_page: , exit_page: , host: , diff --git a/web/public/locales/en.json b/web/public/locales/en.json index f9dea084c..91c43955d 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -2079,6 +2079,7 @@ "rg": "Region", "ct": "City", "pg": "Page", + "title": "Titles", "host": "Host", "lc": "Locale", "map": "Map", From bca7af75f92ab09b666598222886c4fc0422b9c4 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 26 Sep 2026 18:11:43 +0100 Subject: [PATCH 2/2] CodeRabbit PR fixes --- .../Project/View/ViewProject.helpers.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/web/app/pages/Project/View/ViewProject.helpers.tsx b/web/app/pages/Project/View/ViewProject.helpers.tsx index c61f4f84d..087d76397 100644 --- a/web/app/pages/Project/View/ViewProject.helpers.tsx +++ b/web/app/pages/Project/View/ViewProject.helpers.tsx @@ -206,14 +206,9 @@ const convertToCSV = (array: any[]) => { let str = 'name,value,percentage\r\n' for (let i = 0; i < _size(array); ++i) { - let lines = '' + const [title, value, percentage] = array[i] - _forEach(array[i], (index) => { - if (lines !== '') lines += ',' - lines += index - }) - - str += `${lines}\r\n` + str += `"${title.replace(/"/g, '""')}",${value},${percentage}\r\n` } return str @@ -241,12 +236,17 @@ const onCSVExportClick = ( const csvData = _map(rowData[item], (entry: Entry) => { const perc = _round((entry.count / total) * 100 || 0, 2) - if (item === 'cc') { - const name = countries.getName(entry.name || '', language) - return [`"${name}"`, entry.count, `${perc}%`] - } + const title = _toString( + item === 'cc' || item === 'country' + ? countries.getName(entry.name || '', language) + : entry.name, + ) - return [`"${entry.name}"`, entry.count, `${perc}%`] + return [ + /^[=+\-@]/.test(title) ? `'${title}` : title, + entry.count, + `${perc}%`, + ] }) zip.file(`${tnMapping[item]}.csv`, convertToCSV(csvData))