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 backend/apps/cloud/src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions backend/apps/cloud/src/analytics/dto/pageviews.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions backend/apps/cloud/src/analytics/utils/transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface CommonOptions {

interface PageviewOptions extends CommonOptions {
type: 'pageview'
title?: string | null
}

interface CustomEventOptions extends CommonOptions {
Expand Down Expand Up @@ -147,6 +148,7 @@ export const eventTransformer = (opts: EventTransformerOptions) => {
return {
type: 'pageview' as const,
...buildCommon(opts),
title: opts.title || null,
created,
}
}
Expand Down
86 changes: 86 additions & 0 deletions backend/apps/cloud/src/analytics/v2/__tests__/page-titles.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <Plans> — 日本語"
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')
})
})
7 changes: 7 additions & 0 deletions backend/apps/cloud/src/analytics/v2/registry/dimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions backend/apps/cloud/src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const TRAFFIC_COLUMNS = [
'ctp',
'host',
'pg',
'title',
'lc',
'br',
'brv',
Expand All @@ -111,6 +112,7 @@ const V2_VIEW_FILTER_DIMENSIONS = [
'region',
'city',
'page',
'title',
'host',
'locale',
'browser',
Expand Down
20 changes: 20 additions & 0 deletions backend/apps/cloud/src/demo-data/demo-data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ const BACKFILL_DAYS = 90
const BASE_SESSIONS_PER_HOUR = 16
const DEMO_HOST = 'swetrix.com'

const DEMO_PAGE_TITLES: Record<string, string> = {
'/': '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<T> {
Expand Down Expand Up @@ -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),
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions backend/apps/community/src/analytics/dto/pageviews.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions backend/apps/community/src/analytics/utils/transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface CommonOptions {

interface PageviewOptions extends CommonOptions {
type: 'pageview'
title?: string | null
}

interface CustomEventOptions extends CommonOptions {
Expand Down Expand Up @@ -147,6 +148,7 @@ export const eventTransformer = (opts: EventTransformerOptions) => {
return {
type: 'pageview' as const,
...buildCommon(opts),
title: opts.title || null,
created,
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <Plans> — 日本語"
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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions backend/apps/community/src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const TRAFFIC_COLUMNS = [
'ctp',
'host',
'pg',
'title',
'lc',
'br',
'brv',
Expand Down Expand Up @@ -138,6 +139,7 @@ const V2_VIEW_FILTER_DIMENSIONS = [
'region',
'city',
'page',
'title',
'host',
'locale',
'browser',
Expand Down
5 changes: 5 additions & 0 deletions backend/migrations/clickhouse/2026_09_26_page_titles.js
Original file line number Diff line number Diff line change
@@ -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`,
])
10 changes: 10 additions & 0 deletions backend/migrations/clickhouse/2026_09_26_page_titles.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion backend/migrations/clickhouse/initialise_database.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)),
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading