diff --git a/backend/apps/cloud/src/analytics/analytics.controller.ts b/backend/apps/cloud/src/analytics/analytics.controller.ts index 2818c83c2..d4faf5480 100644 --- a/backend/apps/cloud/src/analytics/analytics.controller.ts +++ b/backend/apps/cloud/src/analytics/analytics.controller.ts @@ -2109,6 +2109,13 @@ export class AnalyticsController { @Headers() headers: { 'x-password'?: string }, ) { const { pid, period, from, to, filters, timezone = DEFAULT_TIMEZONE } = data + const sessionEvent = data.sessionEvent || 'traffic' + const sessionDataType = + sessionEvent === 'performance' + ? DataType.PERFORMANCE + : sessionEvent === 'error' + ? DataType.ERRORS + : DataType.ANALYTICS await this.analyticsService.checkProjectAccess( pid, @@ -2133,7 +2140,11 @@ export class AnalyticsController { ) const [filtersQuery, filtersParams, appliedFilters, customEVFilterApplied] = - this.analyticsService.getFiltersQuery(filters, DataType.ANALYTICS) + this.analyticsService.getFiltersQuery( + filters, + sessionDataType, + sessionEvent !== 'traffic', + ) let timeBucket let diff @@ -2141,7 +2152,9 @@ export class AnalyticsController { if (period === 'all') { const res = await this.analyticsService.calculateTimeBucketForAllTime( pid, - ['pageview', 'custom_event', 'error'], + sessionEvent === 'traffic' + ? (['pageview', 'custom_event', 'error'] as const) + : sessionEvent, ) timeBucket = res.timeBucket[0] @@ -2176,6 +2189,7 @@ export class AnalyticsController { take, skip, customEVFilterApplied, + sessionEvent, ) return { sessions, appliedFilters, take, skip } @@ -2438,7 +2452,16 @@ export class AnalyticsController { @CurrentUserId() uid: string, @Headers() headers: { 'x-password'?: string }, ) { - const { pid, eid, period, from, to, timeBucket } = data + const { + pid, + eid, + period, + from, + to, + timeBucket, + filters, + timezone = DEFAULT_TIMEZONE, + } = data await this.analyticsService.checkProjectAccess( pid, @@ -2462,6 +2485,12 @@ export class AnalyticsController { 'GET /analytics/error-sessions', ) + const [filtersQuery, filtersParams] = this.analyticsService.getFiltersQuery( + filters, + DataType.ERRORS, + true, + ) + let newTimeBucket = timeBucket let diff @@ -2475,7 +2504,7 @@ export class AnalyticsController { diff = res.diff } - const safeTimezone = this.analyticsService.getSafeTimezone(DEFAULT_TIMEZONE) + const safeTimezone = this.analyticsService.getSafeTimezone(timezone) const { groupFromUTC, groupToUTC } = this.analyticsService.getGroupFromTo( from, to, @@ -2492,6 +2521,8 @@ export class AnalyticsController { groupToUTC, take, skip, + filtersQuery, + filtersParams, ) } diff --git a/backend/apps/cloud/src/analytics/analytics.service.ts b/backend/apps/cloud/src/analytics/analytics.service.ts index 2757ace60..2f03d8286 100644 --- a/backend/apps/cloud/src/analytics/analytics.service.ts +++ b/backend/apps/cloud/src/analytics/analytics.service.ts @@ -355,6 +355,12 @@ type EventsAllTimeType = | 'performance' | 'error' | 'captcha' +type SessionsListEventType = + | 'traffic' + | 'pageview' + | 'custom_event' + | 'error' + | 'performance' const isValidOrigin = (origins: string[], origin: string) => { const escapeRegex = (str: string) => @@ -5141,10 +5147,14 @@ export class AnalyticsService { take = 30, skip = 0, customEVFilterApplied = false, + sessionEvent: SessionsListEventType = 'traffic', + primaryEventFilterQuery = '', ): Promise { const primaryEventsSubquery = this.buildSessionsListPrimaryEventsSubquery( filtersQuery, customEVFilterApplied, + sessionEvent, + primaryEventFilterQuery, ) const query = ` @@ -5278,8 +5288,10 @@ export class AnalyticsService { private buildSessionsListPrimaryEventsSubquery( filtersQuery: string, customEVFilterApplied: boolean, + sessionEvent: SessionsListEventType = 'traffic', + primaryEventFilterQuery = '', ): string { - if (customEVFilterApplied) { + if (customEVFilterApplied || sessionEvent === 'custom_event') { return ` SELECT CAST(psid, 'String') AS psidCasted, @@ -5296,6 +5308,7 @@ export class AnalyticsService { AND psid != 0 AND created BETWEEN {groupFrom:String} AND {groupTo:String} ${filtersQuery} + ${primaryEventFilterQuery} UNION ALL SELECT CAST(s.psid, 'String') AS psidCasted, @@ -5316,6 +5329,7 @@ export class AnalyticsService { AND profileId != '' AND created BETWEEN {groupFrom:String} AND {groupTo:String} ${filtersQuery} + ${primaryEventFilterQuery} ) AS matching_custom_events ON s.pid = matching_custom_events.pid AND s.profileId = matching_custom_events.profileId @@ -5323,6 +5337,27 @@ export class AnalyticsService { ` } + if (sessionEvent !== 'traffic') { + return ` + SELECT + CAST(psid, 'String') AS psidCasted, + pid, + cc, + os, + br, + toTimeZone(created, {timezone:String}) AS created_for_grouping + FROM events + WHERE + pid = {pid:FixedString(12)} + AND type = '${sessionEvent}' + AND psid IS NOT NULL + AND psid != 0 + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + ${filtersQuery} + ${primaryEventFilterQuery} + ` + } + return ` SELECT CAST(psid, 'String') AS psidCasted, @@ -6376,6 +6411,8 @@ export class AnalyticsService { groupTo: string, take: number = 10, skip: number = 0, + filtersQuery = '', + filtersParams: Record = {}, ): Promise<{ sessions: any[]; total: number }> { const queryCount = ` SELECT count(DISTINCT psid) as total @@ -6384,6 +6421,7 @@ export class AnalyticsService { AND type = 'error' AND eid = {eid:FixedString(32)} AND created BETWEEN {groupFrom:String} AND {groupTo:String} + ${filtersQuery} ` const querySessions = ` @@ -6409,6 +6447,7 @@ export class AnalyticsService { AND type = 'error' AND eid = {eid:FixedString(32)} AND created BETWEEN {groupFrom:String} AND {groupTo:String} + ${filtersQuery} GROUP BY pid, psid ) AS errors LEFT JOIN ( @@ -6439,7 +6478,15 @@ export class AnalyticsService { OFFSET {skip:UInt32} ` - const params = { pid, eid, groupFrom, groupTo, take, skip } + const params = { + ...filtersParams, + pid, + eid, + groupFrom, + groupTo, + take, + skip, + } const [countResult, sessionsResult] = await Promise.all([ clickhouse diff --git a/backend/apps/cloud/src/analytics/dto/get-error.dto.ts b/backend/apps/cloud/src/analytics/dto/get-error.dto.ts index 102475697..373640bfd 100644 --- a/backend/apps/cloud/src/analytics/dto/get-error.dto.ts +++ b/backend/apps/cloud/src/analytics/dto/get-error.dto.ts @@ -8,6 +8,7 @@ export class GetErrorDto extends PickType(GetDataDto, [ 'timeBucket', 'from', 'to', + 'filters', 'timezone', ] as const) { @ApiProperty() diff --git a/backend/apps/cloud/src/analytics/dto/get-sessions.dto.ts b/backend/apps/cloud/src/analytics/dto/get-sessions.dto.ts index 75fa27950..3cbac85dd 100644 --- a/backend/apps/cloud/src/analytics/dto/get-sessions.dto.ts +++ b/backend/apps/cloud/src/analytics/dto/get-sessions.dto.ts @@ -1,6 +1,6 @@ import { PickType } from '@nestjs/swagger' import { Type } from 'class-transformer' -import { IsInt, Min, Max } from 'class-validator' +import { IsIn, IsInt, IsOptional, Min, Max } from 'class-validator' import { GetDataDto } from './getData.dto' export class GetSessionsDto extends PickType(GetDataDto, [ @@ -21,4 +21,8 @@ export class GetSessionsDto extends PickType(GetDataDto, [ @IsInt() @Min(0) skip: number + + @IsOptional() + @IsIn(['traffic', 'performance', 'error']) + sessionEvent?: 'traffic' | 'performance' | 'error' } diff --git a/backend/apps/cloud/src/goal/goal.controller.ts b/backend/apps/cloud/src/goal/goal.controller.ts index ac4cde6de..f01993ebd 100644 --- a/backend/apps/cloud/src/goal/goal.controller.ts +++ b/backend/apps/cloud/src/goal/goal.controller.ts @@ -532,6 +532,74 @@ export class GoalController { } } + @ApiBearerAuth() + @Get('/:id/sessions') + @Auth(true, true) + async getGoalSessions( + @CurrentUserId() userId: string, + @Param('id') id: string, + @Query('period') period = '7d', + @Query('from') from?: string, + @Query('to') to?: string, + @Query('timezone') timezone?: string, + @Query('take') take?: string, + @Query('skip') skip?: string, + ) { + this.logger.log({ userId, id, period, from, to }, 'GET /goal/:id/sessions') + + const goal = await this.goalService.findOneWithRelations(id) + + if (_isEmpty(goal)) { + throw new NotFoundException('Goal not found') + } + + const project = await this.projectService.getFullProject(goal.project.id) + this.projectService.allowedToView(project, userId) + + const safeTimezone = this.analyticsService.getSafeTimezone(timezone) + const timeBucket = getLowestPossibleTimeBucket(period, from, to) + + const { groupFromUTC, groupToUTC } = this.analyticsService.getGroupFromTo( + from, + to, + timeBucket, + period, + safeTimezone, + ) + + const goalType = + goal.type === GoalType.CUSTOM_EVENT ? 'custom_event' : 'pageview' + const { condition: matchCondition, params: matchParams } = + this.buildGoalMatchCondition(goal) + const { condition: metaCondition, params: metaParams } = + this.buildMetadataCondition(goal) + const { take: safeTake, skip: safeSkip } = clampPagination( + Number(take) || 30, + Number(skip) || 0, + ) + const projectId = goal.project.id + const sessions = await this.analyticsService.getSessionsList( + '', + { + params: { + pid: projectId, + groupFrom: groupFromUTC, + groupTo: groupToUTC, + ...matchParams, + ...metaParams, + }, + }, + safeTimezone, + Math.min(safeTake, 150), + safeSkip, + false, + goalType, + `AND ${matchCondition} ${metaCondition}`, + ) + + return { sessions, take: Math.min(safeTake, 150), skip: safeSkip } + } + private getGroupSubquery( timeBucket: string, ): [selector: string, groupBy: string] { diff --git a/backend/apps/cloud/src/tools/tools.controller.ts b/backend/apps/cloud/src/tools/tools.controller.ts index a0372f6f0..c74363288 100644 --- a/backend/apps/cloud/src/tools/tools.controller.ts +++ b/backend/apps/cloud/src/tools/tools.controller.ts @@ -12,7 +12,6 @@ import net from 'net' import { ToolsService } from './tools.service' import { IpLookupQueryDto, IpLookupResponseDto } from './dto/ip-lookup.dto' import { getIPFromHeaders, checkRateLimit } from '../common/utils' -import { trackCustom } from '../common/analytics' import { Public } from '../auth/decorators' const IP_LOOKUP_RL_REQUESTS = 30 @@ -45,10 +44,6 @@ export class ToolsController { @Ip() reqIP: string, ): Promise { const clientIp = getIPFromHeaders(headers) || reqIP || '' - const userAgent = - typeof headers === 'object' && headers !== null - ? (headers as Record)['user-agent'] || '' - : '' await checkRateLimit( clientIp, @@ -71,13 +66,6 @@ export class ToolsController { const result = this.toolsService.lookupIP(ip) - trackCustom(clientIp, userAgent, { - ev: 'IP_LOOKUP', - meta: { - ipVersion: String(result.ipVersion), - }, - }) - return result } } diff --git a/backend/apps/community/src/analytics/analytics.controller.ts b/backend/apps/community/src/analytics/analytics.controller.ts index 278038d98..da5d31d97 100644 --- a/backend/apps/community/src/analytics/analytics.controller.ts +++ b/backend/apps/community/src/analytics/analytics.controller.ts @@ -1607,6 +1607,13 @@ export class AnalyticsController { @Headers() headers: { 'x-password'?: string }, ) { const { pid, period, from, to, filters, timezone = DEFAULT_TIMEZONE } = data + const sessionEvent = data.sessionEvent || 'traffic' + const sessionDataType = + sessionEvent === 'performance' + ? DataType.PERFORMANCE + : sessionEvent === 'error' + ? DataType.ERRORS + : DataType.ANALYTICS await this.analyticsService.checkProjectAccess( pid, @@ -1629,7 +1636,11 @@ export class AnalyticsController { ) const [filtersQuery, filtersParams, appliedFilters, customEVFilterApplied] = - this.analyticsService.getFiltersQuery(filters, DataType.ANALYTICS) + this.analyticsService.getFiltersQuery( + filters, + sessionDataType, + sessionEvent !== 'traffic', + ) let timeBucket let diff @@ -1637,7 +1648,9 @@ export class AnalyticsController { if (period === 'all') { const res = await this.analyticsService.calculateTimeBucketForAllTime( pid, - this.analyticsService.getAnalyticsEventType(customEVFilterApplied), + sessionEvent === 'traffic' + ? this.analyticsService.getAnalyticsEventType(customEVFilterApplied) + : sessionEvent, ) timeBucket = res.timeBucket[0] @@ -1672,6 +1685,7 @@ export class AnalyticsController { take, skip, customEVFilterApplied, + sessionEvent, ) return { sessions, appliedFilters, take, skip } @@ -2261,7 +2275,16 @@ export class AnalyticsController { @CurrentUserId() uid: string, @Headers() headers: { 'x-password'?: string }, ) { - const { pid, eid, period, from, to, timeBucket } = data + const { + pid, + eid, + period, + from, + to, + timeBucket, + filters, + timezone = DEFAULT_TIMEZONE, + } = data await this.analyticsService.checkProjectAccess( pid, @@ -2283,6 +2306,12 @@ export class AnalyticsController { 'GET /analytics/error-sessions', ) + const [filtersQuery, filtersParams] = this.analyticsService.getFiltersQuery( + filters, + DataType.ERRORS, + true, + ) + let newTimeBucket = timeBucket let diff @@ -2296,7 +2325,7 @@ export class AnalyticsController { diff = res.diff } - const safeTimezone = this.analyticsService.getSafeTimezone(DEFAULT_TIMEZONE) + const safeTimezone = this.analyticsService.getSafeTimezone(timezone) const { groupFromUTC, groupToUTC } = this.analyticsService.getGroupFromTo( from, to, @@ -2313,6 +2342,8 @@ export class AnalyticsController { groupToUTC, take, skip, + filtersQuery, + filtersParams, ) } diff --git a/backend/apps/community/src/analytics/analytics.service.ts b/backend/apps/community/src/analytics/analytics.service.ts index 982f8d294..dedecfffc 100644 --- a/backend/apps/community/src/analytics/analytics.service.ts +++ b/backend/apps/community/src/analytics/analytics.service.ts @@ -347,6 +347,12 @@ type EventsAllTimeType = | 'performance' | 'error' | 'captcha' +type SessionsListEventType = + | 'traffic' + | 'pageview' + | 'custom_event' + | 'error' + | 'performance' const isValidOrigin = (origins: string[], origin: string) => { const escapeRegex = (str: string) => @@ -4716,10 +4722,14 @@ export class AnalyticsService { take = 30, skip = 0, customEVFilterApplied = false, + sessionEvent: SessionsListEventType = 'traffic', + primaryEventFilterQuery = '', ): Promise { const primaryEventsSubquery = this.buildSessionsListPrimaryEventsSubquery( filtersQuery, customEVFilterApplied, + sessionEvent, + primaryEventFilterQuery, ) const query = ` @@ -4830,8 +4840,10 @@ export class AnalyticsService { private buildSessionsListPrimaryEventsSubquery( filtersQuery: string, customEVFilterApplied: boolean, + sessionEvent: SessionsListEventType = 'traffic', + primaryEventFilterQuery = '', ): string { - if (customEVFilterApplied) { + if (customEVFilterApplied || sessionEvent === 'custom_event') { return ` SELECT CAST(psid, 'String') AS psidCasted, @@ -4848,6 +4860,7 @@ export class AnalyticsService { AND psid != 0 AND created BETWEEN {groupFrom:String} AND {groupTo:String} ${filtersQuery} + ${primaryEventFilterQuery} UNION ALL SELECT CAST(s.psid, 'String') AS psidCasted, @@ -4868,6 +4881,7 @@ export class AnalyticsService { AND profileId != '' AND created BETWEEN {groupFrom:String} AND {groupTo:String} ${filtersQuery} + ${primaryEventFilterQuery} ) AS matching_custom_events ON s.pid = matching_custom_events.pid AND s.profileId = matching_custom_events.profileId @@ -4875,6 +4889,27 @@ export class AnalyticsService { ` } + if (sessionEvent !== 'traffic') { + return ` + SELECT + CAST(psid, 'String') AS psidCasted, + pid, + cc, + os, + br, + toTimeZone(created, {timezone:String}) AS created_for_grouping + FROM events + WHERE + pid = {pid:FixedString(12)} + AND type = '${sessionEvent}' + AND psid IS NOT NULL + AND psid != 0 + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + ${filtersQuery} + ${primaryEventFilterQuery} + ` + } + return ` SELECT CAST(psid, 'String') AS psidCasted, @@ -5319,6 +5354,8 @@ export class AnalyticsService { groupTo: string, take: number = 10, skip: number = 0, + filtersQuery = '', + filtersParams: Record = {}, ): Promise<{ sessions: any[]; total: number }> { const queryCount = ` SELECT count(DISTINCT psid) as total @@ -5328,6 +5365,7 @@ export class AnalyticsService { AND eid = {eid:FixedString(32)} AND psid IS NOT NULL AND created BETWEEN {groupFrom:String} AND {groupTo:String} + ${filtersQuery} ` const querySessions = ` @@ -5346,13 +5384,22 @@ export class AnalyticsService { AND errors.eid = {eid:FixedString(32)} AND errors.psid IS NOT NULL AND errors.created BETWEEN {groupFrom:String} AND {groupTo:String} + ${filtersQuery} GROUP BY errors.psid ORDER BY lastErrorAt DESC LIMIT {take:UInt32} OFFSET {skip:UInt32} ` - const params = { pid, eid, groupFrom, groupTo, take, skip } + const params = { + pid, + eid, + groupFrom, + groupTo, + take, + skip, + ...filtersParams, + } const [countResult, sessionsResult] = await Promise.all([ clickhouse diff --git a/backend/apps/community/src/analytics/dto/get-error.dto.ts b/backend/apps/community/src/analytics/dto/get-error.dto.ts index 102475697..373640bfd 100644 --- a/backend/apps/community/src/analytics/dto/get-error.dto.ts +++ b/backend/apps/community/src/analytics/dto/get-error.dto.ts @@ -8,6 +8,7 @@ export class GetErrorDto extends PickType(GetDataDto, [ 'timeBucket', 'from', 'to', + 'filters', 'timezone', ] as const) { @ApiProperty() diff --git a/backend/apps/community/src/analytics/dto/get-sessions.dto.ts b/backend/apps/community/src/analytics/dto/get-sessions.dto.ts index 75fa27950..3cbac85dd 100644 --- a/backend/apps/community/src/analytics/dto/get-sessions.dto.ts +++ b/backend/apps/community/src/analytics/dto/get-sessions.dto.ts @@ -1,6 +1,6 @@ import { PickType } from '@nestjs/swagger' import { Type } from 'class-transformer' -import { IsInt, Min, Max } from 'class-validator' +import { IsIn, IsInt, IsOptional, Min, Max } from 'class-validator' import { GetDataDto } from './getData.dto' export class GetSessionsDto extends PickType(GetDataDto, [ @@ -21,4 +21,8 @@ export class GetSessionsDto extends PickType(GetDataDto, [ @IsInt() @Min(0) skip: number + + @IsOptional() + @IsIn(['traffic', 'performance', 'error']) + sessionEvent?: 'traffic' | 'performance' | 'error' } diff --git a/backend/apps/community/src/goal/goal.controller.ts b/backend/apps/community/src/goal/goal.controller.ts index 4a022b2e8..a4bfb21fb 100644 --- a/backend/apps/community/src/goal/goal.controller.ts +++ b/backend/apps/community/src/goal/goal.controller.ts @@ -450,6 +450,73 @@ export class GoalController { } } + @Get('/:id/sessions') + @Auth() + async getGoalSessions( + @CurrentUserId() userId: string, + @Param('id') id: string, + @Query('period') period = '7d', + @Query('from') from?: string, + @Query('to') to?: string, + @Query('timezone') timezone?: string, + @Query('take') take?: string, + @Query('skip') skip?: string, + ) { + this.logger.log({ userId, id, period, from, to }, 'GET /goal/:id/sessions') + + const goal = await this.goalService.findOne(id) + + if (_isEmpty(goal)) { + throw new NotFoundException('Goal not found') + } + + const project = await this.projectService.getFullProject(goal.projectId) + this.projectService.allowedToView(project, userId) + + const safeTimezone = this.analyticsService.getSafeTimezone(timezone) + const timeBucket = getLowestPossibleTimeBucket(period, from, to) + + const { groupFromUTC, groupToUTC } = this.analyticsService.getGroupFromTo( + from, + to, + timeBucket, + period, + safeTimezone, + ) + + const goalType = + goal.type === GoalType.CUSTOM_EVENT ? 'custom_event' : 'pageview' + const { condition: matchCondition, params: matchParams } = + this.buildGoalMatchCondition(goal) + const { condition: metaCondition, params: metaParams } = + this.buildMetadataCondition(goal) + const { take: safeTake, skip: safeSkip } = clampPagination( + Number(take) || 30, + Number(skip) || 0, + ) + + const sessions = await this.analyticsService.getSessionsList( + '', + { + params: { + pid: goal.projectId, + groupFrom: groupFromUTC, + groupTo: groupToUTC, + ...matchParams, + ...metaParams, + }, + }, + safeTimezone, + Math.min(safeTake, 150), + safeSkip, + false, + goalType, + `AND ${matchCondition} ${metaCondition}`, + ) + + return { sessions, take: Math.min(safeTake, 150), skip: safeSkip } + } + private getGroupSubquery( timeBucket: string, ): [selector: string, groupBy: string] { diff --git a/docs/content/docs/analytics-dashboard/error-tracking.mdx b/docs/content/docs/analytics-dashboard/error-tracking.mdx index b6d51afc7..eb3448204 100644 --- a/docs/content/docs/analytics-dashboard/error-tracking.mdx +++ b/docs/content/docs/analytics-dashboard/error-tracking.mdx @@ -34,6 +34,8 @@ At the top of the page, you will find key statistics for the selected time perio A chart visualises the trend of error occurrences and affected users over time. This helps you spot spikes caused by new deployments or specific incidents. +You can click on any data point on the error chart to open the affected sessions for that specific timeframe. This lets you move from a spike in errors to the individual sessions that experienced errors during that day or hour. + ### Recent Errors List Below the chart is a list of all error groups. Errors are grouped by their name and signature so you can see which issues are most prevalent. diff --git a/docs/content/docs/analytics-dashboard/goals.mdx b/docs/content/docs/analytics-dashboard/goals.mdx index e8e07e279..dc78bc4b5 100644 --- a/docs/content/docs/analytics-dashboard/goals.mdx +++ b/docs/content/docs/analytics-dashboard/goals.mdx @@ -64,3 +64,5 @@ The chart visualises two metrics over time: 2. **Sessions** (Blue): The number of unique sessions that contributed to these conversions. This visualisation helps you identify trends, such as which days or times generate the most conversions. + +You can click on any data point on a goal chart to open the sessions that matched that goal during the selected timeframe. This lets you inspect the visitors and session journeys behind a conversion spike or drop. diff --git a/docs/content/docs/analytics-dashboard/performance.mdx b/docs/content/docs/analytics-dashboard/performance.mdx index a3947b651..bba47c1e4 100644 --- a/docs/content/docs/analytics-dashboard/performance.mdx +++ b/docs/content/docs/analytics-dashboard/performance.mdx @@ -38,6 +38,10 @@ You can analyze these metrics using different statistical aggregations to unders - **Median (p50)**: The middle value. Half of your users experience a faster load time, and half experience a slower one. - **Average**: The arithmetic mean of all recorded values. +### Viewing Sessions + +You can click on any data point on the performance chart to open a list of sessions that recorded performance data during that specific timeframe. This helps you move from an aggregate slowdown to the actual sessions behind it, so you can inspect the affected users, devices, browsers, pages, and session journeys for that day or hour. + ## Breakdown Panels Below the main chart, the data is broken down by various dimensions to help you pinpoint where performance issues might be occurring. diff --git a/docs/content/docs/api/stats.mdx b/docs/content/docs/api/stats.mdx index 92a7e70b3..0c3e5a3d5 100644 --- a/docs/content/docs/api/stats.mdx +++ b/docs/content/docs/api/stats.mdx @@ -867,6 +867,12 @@ An array of [filter objects](#filters).
+**sessionEvent** + +Optional event scope for the returned sessions. Supported values are `traffic` (default), `performance`, and `error`. Use `performance` to return sessions that recorded performance events in the selected time range, or `error` to return sessions affected by errors in the selected time range. + +
+ **take** The number of sessions to return. The default is `30`, max is `150`. @@ -1418,6 +1424,54 @@ curl 'https://api.swetrix.com/goal/GOAL_ID/chart?period=7d'\ Time range parameters. +### GET /goal/:id/sessions + +Returns paginated sessions that matched a specific goal during the selected time range. + +```bash +curl 'https://api.swetrix.com/goal/GOAL_ID/sessions?period=7d&take=30&skip=0'\ + -H "X-Api-Key: ${SWETRIX_API_KEY}" +``` + +```json title="Response" +{ + "sessions": [ + { + "psid": "123456789", + "cc": "US", + "os": "Mac OS", + "br": "Chrome", + "pageviews": 3, + "customEvents": 1, + "errors": 0, + "sessionStart": "2025-01-15 10:30:00", + "lastActivity": "2025-01-15 10:35:00", + "isLive": 0, + "sdur": 300, + "profileId": null, + "isIdentified": 0, + "isFirstSession": 1 + } + ], + "take": 30, + "skip": 0 +} +``` + +#### Parameters + +**period**, **from**, **to**, **timezone** + +Time range parameters. + +**take** (optional, default: `30`, max: `150`) + +The number of sessions to return per page. + +**skip** (optional, default: `0`) + +The number of sessions to skip (for pagination). + ### GET /v1/feature-flag/:id/stats Returns statistics for a specific feature flag (evaluations, true/false counts). diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index 780469c0a..9fa611e04 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -963,10 +963,16 @@ export interface SessionsResponse { appliedFilters: AnalyticsFilter[] } +export type SessionEventType = 'traffic' | 'performance' | 'error' + export async function getSessionsServer( request: Request, pid: string, - params: AnalyticsParams & { take?: number; skip?: number }, + params: AnalyticsParams & { + take?: number + skip?: number + sessionEvent?: SessionEventType + }, ): Promise> { const queryParams = new URLSearchParams() queryParams.append('pid', pid) @@ -974,6 +980,9 @@ export async function getSessionsServer( queryParams.append('filters', serializeFiltersForUrl(params.filters)) queryParams.append('take', String(params.take || 30)) queryParams.append('skip', String(params.skip || 0)) + if (params.sessionEvent) { + queryParams.append('sessionEvent', params.sessionEvent) + } if (params.from) queryParams.append('from', params.from) if (params.to) queryParams.append('to', params.to) if (params.timezone) queryParams.append('timezone', params.timezone) @@ -996,6 +1005,12 @@ export interface FunnelSessionsResponse { skip: number } +export interface GoalSessionsResponse { + sessions: Session[] + take: number + skip: number +} + export async function getFunnelSessionsServer( request: Request, pid: string, @@ -1038,6 +1053,35 @@ export async function getFunnelSessionsServer( ) } +export async function getGoalSessionsServer( + request: Request, + goalId: string, + params: { + period: string + from?: string + to?: string + timezone?: string + take?: number + skip?: number + }, +): Promise> { + const take = Math.min(150, Math.max(1, Math.floor(Number(params.take) || 30))) + const skip = Math.max(0, Math.floor(Number(params.skip) || 0)) + + const queryParams = new URLSearchParams() + queryParams.append('period', params.period) + queryParams.append('take', String(take)) + queryParams.append('skip', String(skip)) + if (params.from) queryParams.append('from', params.from) + if (params.to) queryParams.append('to', params.to) + if (params.timezone) queryParams.append('timezone', params.timezone) + + return serverFetch( + request, + `goal/${goalId}/sessions?${queryParams.toString()}`, + ) +} + interface PageflowItem { type: 'pageview' | 'event' | 'error' | 'sale' | 'refund' value: string @@ -2029,6 +2073,8 @@ export async function getErrorSessionsServer( period?: string from?: string to?: string + filters?: AnalyticsFilter[] + timezone?: string take?: number skip?: number password?: string @@ -2041,8 +2087,10 @@ export async function getErrorSessionsServer( queryParams.append('period', params.period || '7d') queryParams.append('take', String(params.take || 10)) queryParams.append('skip', String(params.skip || 0)) + queryParams.append('filters', serializeFiltersForUrl(params.filters || [])) if (params.from) queryParams.append('from', params.from) if (params.to) queryParams.append('to', params.to) + if (params.timezone) queryParams.append('timezone', params.timezone) const headers: Record = {} if (params.password) { diff --git a/web/app/pages/Project/View/ViewProject.helpers.tsx b/web/app/pages/Project/View/ViewProject.helpers.tsx index 996080ad1..4cee3e133 100644 --- a/web/app/pages/Project/View/ViewProject.helpers.tsx +++ b/web/app/pages/Project/View/ViewProject.helpers.tsx @@ -69,6 +69,10 @@ import countries from '~/utils/isoCountries' import { downloadBlob } from '~/utils/download' import { TrafficLogResponse } from './interfaces/traffic' +import { + attachDataPointClickHandlers, + type ChartDataPointClick, +} from './utils/chartPoint' dayjs.extend(utc) dayjs.extend(timezonePlugin) @@ -598,7 +602,7 @@ const getSettings = ( annotations?: Annotation[], period?: string, timezone?: string, - onDataPointClick?: (d: { x: Date; index: number }) => void, + onDataPointClick?: ChartDataPointClick, dataPointClickLabel?: string, ): ChartOptions => { const xAxisSize = _size(chart.x) @@ -1085,90 +1089,11 @@ const getSettings = ( }) } - if (onDataPointClick) { - const svg = chartInstance.$.svg?.node() - if (!svg) return - - const eventRectsGroup = svg.querySelector('.bb-event-rects') - if (!eventRectsGroup || eventRectsGroup.__clickAttached) return - eventRectsGroup.__clickAttached = true - - eventRectsGroup.addEventListener( - 'mousemove', - (e: MouseEvent) => { - const groupRect = eventRectsGroup.getBoundingClientRect() - const mouseX = e.clientX - groupRect.left - const mouseY = e.clientY - groupRect.top - - const circles = svg.querySelectorAll('.bb-circle') - let closestCircle: Element | null = null - let minDistance = 25 // 25px sensitivity for direct hover - - circles.forEach((c: Element) => { - const cx = parseFloat(c.getAttribute('cx') || '0') - const cy = parseFloat(c.getAttribute('cy') || '0') - const distance = Math.hypot(cx - mouseX, cy - mouseY) - - if (distance < minDistance) { - minDistance = distance - closestCircle = c - } - }) - - circles.forEach((c: Element) => { - if (c === closestCircle) { - c.classList.add('is-direct-hover') - } else { - c.classList.remove('is-direct-hover') - } - }) - }, - ) - - eventRectsGroup.addEventListener('mouseleave', () => { - const circles = svg.querySelectorAll('.bb-circle') - circles.forEach((c: Element) => { - c.classList.remove('is-direct-hover') - }) - }) - - eventRectsGroup.addEventListener('click', (e: MouseEvent) => { - const target = e.target as SVGRectElement - if (!target?.classList?.contains('bb-event-rect')) return - - const groupRect = eventRectsGroup.getBoundingClientRect() - const mouseX = e.clientX - groupRect.left - const mouseY = e.clientY - groupRect.top - - const circles = svg.querySelectorAll('.bb-circle') - let closestCircle: Element | null = null - let minDistance = Infinity - - circles.forEach((c: Element) => { - const cx = parseFloat(c.getAttribute('cx') || '0') - const cy = parseFloat(c.getAttribute('cy') || '0') - const distance = Math.hypot(cx - mouseX, cy - mouseY) - - if (distance < minDistance) { - minDistance = distance - closestCircle = c - } - }) - - if (!closestCircle) return - const circle = closestCircle as Element - - const classAttr = circle.getAttribute('class') || '' - const indexMatch = classAttr.match(/bb-circle-(\d+)/) - if (!indexMatch) return - const index = parseInt(indexMatch[1], 10) - - const xValues = columns[0].slice(1) as Date[] - if (index >= xValues.length) return - - onDataPointClick({ x: xValues[index], index }) - }) - } + attachDataPointClickHandlers( + chartInstance, + columns, + onDataPointClick, + ) } catch { // ignore } @@ -1552,6 +1477,8 @@ const getSettingsError = ( chartType: string, annotations?: Annotation[], dataNames?: Record, + onDataPointClick?: ChartDataPointClick, + dataPointClickLabel?: string, ): ChartOptions => { const xAxisSize = _size(chart.x) @@ -1621,6 +1548,13 @@ const getSettingsError = ( data: { x: 'x', columns, + onclick: onDataPointClick + ? (d: any) => { + if (d?.x) { + onDataPointClick({ x: d.x, index: d.index }) + } + } + : undefined, types, colors, names: dataNames, @@ -1706,7 +1640,9 @@ const getSettingsError = ( ${el.value} `, - ).join('')}` + ).join( + '', + )}${onDataPointClick ? `
  • ${dataPointClickLabel}
  • ` : ''}` }, }, point: @@ -1715,9 +1651,11 @@ const getSettingsError = ( : { focus: { only: xAxisSize > 1, + expand: onDataPointClick ? { enabled: true, r: 4 } : undefined, }, pattern: ['circle'], r: 2, + sensitivity: onDataPointClick ? 50 : undefined, }, legend: { item: { @@ -1737,6 +1675,11 @@ const getSettingsError = ( ratio: 0.15, }, }, + onrendered: onDataPointClick + ? function (this: any) { + attachDataPointClickHandlers(this, columns, onDataPointClick) + } + : undefined, } } @@ -2025,8 +1968,11 @@ const getSettingsPerf = ( onZoom?: (domain: [Date, Date] | null) => void, enableZoom?: boolean, annotations?: Annotation[], + onDataPointClick?: ChartDataPointClick, + dataPointClickLabel?: string, ): ChartOptions => { const xAxisSize = _size(chart.x) + const columns = getColumnsPerf(chart, activeChartMetrics, compareChart) // Convert annotations to grid lines // Each annotation gets a unique class identifier for DOM-based lookup @@ -2047,7 +1993,14 @@ const getSettingsPerf = ( data: { x: 'x', xFormat: tbsFormatMapper[timeBucket], - columns: getColumnsPerf(chart, activeChartMetrics, compareChart), + columns, + onclick: onDataPointClick + ? (d: any) => { + if (d?.x) { + onDataPointClick({ x: d.x, index: d.index }) + } + } + : undefined, types: { dns: chartType === chartTypes.line ? areaSpline() : bar(), tls: chartType === chartTypes.line ? areaSpline() : bar(), @@ -2205,7 +2158,9 @@ const getSettingsPerf = ( ` }, - ).join('')}` + ).join( + '', + )}${onDataPointClick ? `
  • ${dataPointClickLabel}
  • ` : ''}` } // Get dates from first item @@ -2285,6 +2240,7 @@ const getSettingsPerf = (
      ${currentSection} ${compareSection} + ${onDataPointClick ? `
    • ${dataPointClickLabel}
    • ` : ''}
    ` }, }, @@ -2294,9 +2250,11 @@ const getSettingsPerf = ( : { focus: { only: xAxisSize > 1, + expand: onDataPointClick ? { enabled: true, r: 4 } : undefined, }, pattern: ['circle'], r: 2, + sensitivity: onDataPointClick ? 50 : undefined, }, legend: { item: { @@ -2317,6 +2275,11 @@ const getSettingsPerf = ( ratio: 0.15, }, }, + onrendered: onDataPointClick + ? function (this: any) { + attachDataPointClickHandlers(this, columns, onDataPointClick) + } + : undefined, zoom: onZoom && enableZoom !== false ? { diff --git a/web/app/pages/Project/View/utils/chartPoint.ts b/web/app/pages/Project/View/utils/chartPoint.ts new file mode 100644 index 000000000..302fcb9f9 --- /dev/null +++ b/web/app/pages/Project/View/utils/chartPoint.ts @@ -0,0 +1,182 @@ +import dayjs from 'dayjs' +import timezonePlugin from 'dayjs/plugin/timezone' +import utc from 'dayjs/plugin/utc' + +dayjs.extend(utc) +dayjs.extend(timezonePlugin) + +export type ChartDataPointClick = (d: { x: Date; index: number }) => void + +type EventRectsGroup = SVGElement & { + __clickHandlers?: { + mousemove: (e: MouseEvent) => void + mouseleave: () => void + click: (e: MouseEvent) => void + } +} + +const getClosestCircle = ( + circles: NodeListOf, + mouseX: number, + mouseY: number, + maxDistance = Infinity, +): SVGElement | null => { + let closestCircle: SVGElement | null = null + let minDistance = maxDistance + + circles.forEach((c) => { + const cx = parseFloat(c.getAttribute('cx') || '0') + const cy = parseFloat(c.getAttribute('cy') || '0') + const distance = Math.hypot(cx - mouseX, cy - mouseY) + + if (distance < minDistance) { + minDistance = distance + closestCircle = c + } + }) + + return closestCircle +} + +export const attachDataPointClickHandlers = ( + chartInstance: any, + columns: any[], + onDataPointClick?: ChartDataPointClick, +) => { + if (!chartInstance?.$) return + + const svg = chartInstance.$.svg?.node() as SVGSVGElement | null | undefined + if (!svg) return + + const eventRectsGroup = svg.querySelector( + '.bb-event-rects', + ) as EventRectsGroup | null + if (!eventRectsGroup) return + + if (eventRectsGroup.__clickHandlers) { + eventRectsGroup.removeEventListener( + 'mousemove', + eventRectsGroup.__clickHandlers.mousemove, + ) + eventRectsGroup.removeEventListener( + 'mouseleave', + eventRectsGroup.__clickHandlers.mouseleave, + ) + eventRectsGroup.removeEventListener( + 'click', + eventRectsGroup.__clickHandlers.click, + ) + delete eventRectsGroup.__clickHandlers + } + + if (!onDataPointClick) return + + const handleMouseMove = (e: MouseEvent) => { + const groupRect = eventRectsGroup.getBoundingClientRect() + const mouseX = e.clientX - groupRect.left + const mouseY = e.clientY - groupRect.top + + const circles = svg.querySelectorAll('.bb-circle') + const closestCircle = getClosestCircle(circles, mouseX, mouseY, 25) + + circles.forEach((c) => { + if (c === closestCircle) { + c.classList.add('is-direct-hover') + } else { + c.classList.remove('is-direct-hover') + } + }) + } + + const handleMouseLeave = () => { + const circles = svg.querySelectorAll('.bb-circle') + circles.forEach((c) => { + c.classList.remove('is-direct-hover') + }) + } + + const handleClick = (e: MouseEvent) => { + const target = e.target as SVGRectElement + if (!target?.classList?.contains('bb-event-rect')) return + + const groupRect = eventRectsGroup.getBoundingClientRect() + const mouseX = e.clientX - groupRect.left + const mouseY = e.clientY - groupRect.top + + const circles = svg.querySelectorAll('.bb-circle') + const closestCircle = getClosestCircle(circles, mouseX, mouseY) + + if (!closestCircle) return + + const classAttr = closestCircle.getAttribute('class') || '' + const indexMatch = classAttr.match(/bb-circle-(\d+)/) + if (!indexMatch) return + + const index = parseInt(indexMatch[1], 10) + const xValues = columns[0]?.slice(1) as Date[] | undefined + if (!xValues || index >= xValues.length) return + + onDataPointClick({ x: xValues[index], index }) + } + + eventRectsGroup.addEventListener('mousemove', handleMouseMove) + eventRectsGroup.addEventListener('mouseleave', handleMouseLeave) + eventRectsGroup.addEventListener('click', handleClick) + eventRectsGroup.__clickHandlers = { + mousemove: handleMouseMove, + mouseleave: handleMouseLeave, + click: handleClick, + } +} + +export const getChartPointWindow = ({ + x, + timeBucket, + timezone, + timeFormat, +}: { + x: Date + timeBucket: string + timezone: string + timeFormat: string +}) => { + const date = dayjs(x).tz(timezone) + + switch (timeBucket) { + case 'minute': + return { + from: date.startOf('minute').toISOString(), + to: date.endOf('minute').toISOString(), + label: date.format('MMM D, YYYY HH:mm'), + } + case 'hour': + return { + from: date.startOf('hour').toISOString(), + to: date.endOf('hour').toISOString(), + label: date.format( + timeFormat === '24-hour' + ? 'MMM D, YYYY HH:00 - HH:59' + : 'MMM D, YYYY h:00 - h:59 A', + ), + } + case 'month': + return { + from: date.startOf('month').toISOString(), + to: date.endOf('month').toISOString(), + label: date.format('MMMM YYYY'), + } + case 'year': + return { + from: date.startOf('year').toISOString(), + to: date.endOf('year').toISOString(), + label: date.format('YYYY'), + } + case 'day': + default: + return { + from: date.startOf('day').toISOString(), + to: date.endOf('day').toISOString(), + label: date.format('dddd, MMM D, YYYY'), + } + } +} diff --git a/web/app/pages/Project/tabs/Errors/ErrorChart.tsx b/web/app/pages/Project/tabs/Errors/ErrorChart.tsx index bf3080eb3..8a26dbdd9 100644 --- a/web/app/pages/Project/tabs/Errors/ErrorChart.tsx +++ b/web/app/pages/Project/tabs/Errors/ErrorChart.tsx @@ -4,6 +4,7 @@ import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Annotation } from '~/lib/models/Project' +import type { ChartDataPointClick } from '~/pages/Project/View/utils/chartPoint' import { MetricCard } from '~/pages/Project/tabs/Traffic/MetricCards' import { MainChart } from '../../View/components/MainChart' @@ -30,6 +31,7 @@ interface ErrorChartProps { className?: string annotations?: Annotation[] stats?: ErrorChartStat[] + onDataPointClick?: ChartDataPointClick } export const ErrorChart = ({ @@ -42,9 +44,15 @@ export const ErrorChart = ({ className, annotations, stats, + onDataPointClick, }: ErrorChartProps) => { const { t } = useTranslation('common') + const dataPointClickLabel = useMemo( + () => (onDataPointClick ? t('project.exploreSessions') : undefined), + [onDataPointClick, t], + ) + const dataNames = useMemo(() => { return ( customDataNames || { @@ -64,6 +72,8 @@ export const ErrorChart = ({ chartType, annotations, dataNames, + onDataPointClick, + dataPointClickLabel, ) }, [ chart, @@ -73,6 +83,8 @@ export const ErrorChart = ({ chartType, annotations, dataNames, + onDataPointClick, + dataPointClickLabel, ]) const deps = useMemo( @@ -84,6 +96,8 @@ export const ErrorChart = ({ chartType, dataNames, annotations, + onDataPointClick, + dataPointClickLabel, ], [ chart, @@ -93,6 +107,8 @@ export const ErrorChart = ({ chartType, dataNames, annotations, + onDataPointClick, + dataPointClickLabel, ], ) diff --git a/web/app/pages/Project/tabs/Errors/ErrorsView.tsx b/web/app/pages/Project/tabs/Errors/ErrorsView.tsx index 07d736d21..897f7a397 100644 --- a/web/app/pages/Project/tabs/Errors/ErrorsView.tsx +++ b/web/app/pages/Project/tabs/Errors/ErrorsView.tsx @@ -62,6 +62,7 @@ import { ErrorChart } from '~/pages/Project/tabs/Errors/ErrorChart' import { ErrorDetails } from '~/pages/Project/tabs/Errors/ErrorDetails' import NoErrorDetails from '~/pages/Project/tabs/Errors/NoErrorDetails' import WaitingForAnError from '~/pages/Project/tabs/Errors/WaitingForAnError' +import { SessionsDrawer } from '~/pages/Project/tabs/Traffic/SessionsDrawer' import CCRow from '~/pages/Project/View/components/CCRow' import DashboardHeader from '~/pages/Project/View/components/DashboardHeader' import Filters from '~/pages/Project/View/components/Filters' @@ -81,6 +82,11 @@ import { getUsageTypeLabel, getConnectionTypeLabel, } from '~/pages/Project/View/ViewProject.helpers' +import { + attachDataPointClickHandlers, + getChartPointWindow, + type ChartDataPointClick, +} from '~/pages/Project/View/utils/chartPoint' import { useCurrentProject, useProjectPassword, @@ -162,6 +168,8 @@ const getErrorTrendsChartSettings = ( timeFormat: string, chartType: string, dataNames: Record, + onDataPointClick?: ChartDataPointClick, + dataPointClickLabel?: string, ): ChartOptions => { const xAxisSize = _size(chartData.x) @@ -183,6 +191,13 @@ const getErrorTrendsChartSettings = ( data: { x: 'x', columns, + onclick: onDataPointClick + ? (d: any) => { + if (d?.x) { + onDataPointClick({ x: d.x, index: d.index }) + } + } + : undefined, types: { occurrences: chartType === chartTypes.line ? area() : bar(), affectedUsers: chartType === chartTypes.line ? area() : bar(), @@ -256,7 +271,9 @@ const getErrorTrendsChartSettings = ( ${el.value} ` - }).join('')}` + }).join( + '', + )}${onDataPointClick ? `
  • ${dataPointClickLabel}
  • ` : ''}` }, }, point: @@ -265,9 +282,11 @@ const getErrorTrendsChartSettings = ( : { focus: { only: xAxisSize > 1, + expand: onDataPointClick ? { enabled: true, r: 4 } : undefined, }, pattern: ['circle'], r: 2, + sensitivity: onDataPointClick ? 50 : undefined, }, legend: { item: { @@ -284,6 +303,11 @@ const getErrorTrendsChartSettings = ( bar: { linearGradient: true, }, + onrendered: onDataPointClick + ? function (this: any) { + attachDataPointClickHandlers(this, columns, onDataPointClick) + } + : undefined, } } @@ -577,6 +601,12 @@ const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => { const [canLoadMoreErrors, setCanLoadMoreErrors] = useState( () => (deferredData.errorsData?.errors?.length || 0) >= ERRORS_TAKE, ) + const [sessionsDrawer, setSessionsDrawer] = useState<{ + from: string + to: string + label: string + errorId?: string + } | null>(null) const activeEID = useMemo(() => searchParams.get('eid'), [searchParams]) @@ -787,6 +817,43 @@ const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => { ] }, [t, errorOptions]) + const handleOverviewDataPointClick = useCallback( + (d: { x: Date; index: number }) => { + setSessionsDrawer( + getChartPointWindow({ + x: d.x, + timeBucket, + timezone, + timeFormat, + }), + ) + }, + [timeBucket, timeFormat, timezone], + ) + + const handleActiveErrorDataPointClick = useCallback( + (d: { x: Date; index: number }) => { + if (!activeError?.details.eid) return + + setSessionsDrawer({ + ...getChartPointWindow({ + x: d.x, + timeBucket: activeError.timeBucket || timeBucket, + timezone, + timeFormat, + }), + errorId: activeError.details.eid, + }) + }, + [ + activeError?.details.eid, + activeError?.timeBucket, + timeBucket, + timeFormat, + timezone, + ], + ) + // Handle refresh trigger - use revalidator for URL-based data useEffect(() => { if (errorsRefreshTrigger > 0) { @@ -807,8 +874,10 @@ const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => { occurrences: t('project.totalErrors'), affectedUsers: t('project.affectedUsers'), }, + handleOverviewDataPointClick, + t('project.exploreSessions'), ) - }, [overview?.chart, timeBucket, timeFormat, t]) + }, [overview?.chart, timeBucket, timeFormat, handleOverviewDataPointClick, t]) const hasErrorsRaw = !_isEmpty(errors) || overview?.stats?.totalErrors @@ -978,6 +1047,7 @@ const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => { rotateXAxis={rotateXAxis} chartType={chartTypes.line} dataNames={dataNames} + onDataPointClick={handleActiveErrorDataPointClick} stats={[ { key: 'occurrences', @@ -1268,6 +1338,20 @@ const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => { {_isEmpty(activeError) && errorLoading ? : null} {!errorLoading && _isEmpty(activeError) ? : null} + setSessionsDrawer(null)} + from={sessionsDrawer?.from || ''} + to={sessionsDrawer?.to || ''} + label={sessionsDrawer?.label || ''} + projectId={id} + timezone={timezone} + timeFormat={timeFormat as '12-hour' | '24-hour'} + filters={filters} + sessionEvent={sessionsDrawer?.errorId ? undefined : 'error'} + errorId={sessionsDrawer?.errorId} + title={t('project.affectedSessions')} + /> ) } @@ -1414,6 +1498,20 @@ const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => { ) : null} + setSessionsDrawer(null)} + from={sessionsDrawer?.from || ''} + to={sessionsDrawer?.to || ''} + label={sessionsDrawer?.label || ''} + projectId={id} + timezone={timezone} + timeFormat={timeFormat as '12-hour' | '24-hour'} + filters={filters} + sessionEvent={sessionsDrawer?.errorId ? undefined : 'error'} + errorId={sessionsDrawer?.errorId} + title={t('project.affectedSessions')} + /> ) } diff --git a/web/app/pages/Project/tabs/Goals/GoalsView.tsx b/web/app/pages/Project/tabs/Goals/GoalsView.tsx index 504766176..2f9c65ace 100644 --- a/web/app/pages/Project/tabs/Goals/GoalsView.tsx +++ b/web/app/pages/Project/tabs/Goals/GoalsView.tsx @@ -45,12 +45,19 @@ import { tbsFormatMapperTooltip, tbsFormatMapperTooltip24h, chartTypes, + DEFAULT_TIMEZONE, } from '~/lib/constants' import DashboardHeader from '~/pages/Project/View/components/DashboardHeader' import { useViewProjectContext, useRefreshTriggers, } from '~/pages/Project/View/ViewProject' +import { SessionsDrawer } from '~/pages/Project/tabs/Traffic/SessionsDrawer' +import { + attachDataPointClickHandlers, + getChartPointWindow, + type ChartDataPointClick, +} from '~/pages/Project/View/utils/chartPoint' import { useCurrentProject } from '~/providers/CurrentProjectProvider' import type { ProjectLoaderData, @@ -132,6 +139,8 @@ const getGoalChartSettings = ( timeFormat: string, chartType: string, dataNames: Record, + onDataPointClick?: ChartDataPointClick, + dataPointClickLabel?: string, ): ChartOptions => { const xAxisSize = _size(chartData.x) @@ -154,6 +163,13 @@ const getGoalChartSettings = ( data: { x: 'x', columns, + onclick: onDataPointClick + ? (d: any) => { + if (d?.x) { + onDataPointClick({ x: d.x, index: d.index }) + } + } + : undefined, types: { conversions: chartType === chartTypes.line ? area() : bar(), sessions: chartType === chartTypes.line ? area() : bar(), @@ -227,7 +243,9 @@ const getGoalChartSettings = ( ${el.value} ` - }).join('')}` + }).join( + '', + )}${onDataPointClick ? `
  • ${dataPointClickLabel}
  • ` : ''}` }, }, point: @@ -236,9 +254,11 @@ const getGoalChartSettings = ( : { focus: { only: xAxisSize > 1, + expand: onDataPointClick ? { enabled: true, r: 4 } : undefined, }, pattern: ['circle'], r: 2, + sensitivity: onDataPointClick ? 50 : undefined, }, legend: { item: { @@ -255,6 +275,11 @@ const getGoalChartSettings = ( bar: { linearGradient: true, }, + onrendered: onDataPointClick + ? function (this: any) { + attachDataPointClickHandlers(this, columns, onDataPointClick) + } + : undefined, } } @@ -270,6 +295,7 @@ interface GoalRowProps { onDelete: (id: string) => void onEdit: (id: string) => void onToggleExpand: (id: string) => void + onChartDataPointClick: (goalId: string, d: { x: Date; index: number }) => void } const GoalRow = ({ @@ -284,6 +310,7 @@ const GoalRow = ({ onDelete, onEdit, onToggleExpand, + onChartDataPointClick, }: GoalRowProps) => { const { t } = useTranslation() const [showDeleteModal, setShowDeleteModal] = useState(false) @@ -310,8 +337,10 @@ const GoalRow = ({ conversions: t('goals.conversions'), sessions: t('project.sessions'), }, + (d) => onChartDataPointClick(goal.id, d), + t('project.exploreSessions'), ) - }, [chartData, timeBucket, timeFormat, t]) + }, [chartData, timeBucket, timeFormat, goal.id, onChartDataPointClick, t]) return ( <> @@ -536,7 +565,7 @@ const GoalsViewInner = ({ period, from = '', to = '', - timezone, + timezone = DEFAULT_TIMEZONE, deferredData, }: GoalsViewInnerProps) => { const { id } = useCurrentProject() @@ -568,6 +597,12 @@ const GoalsViewInner = ({ Record >({}) const [chartLoading, setChartLoading] = useState>({}) + const [sessionsDrawer, setSessionsDrawer] = useState<{ + from: string + to: string + label: string + goalId: string + } | null>(null) // Modal state const [isModalOpen, setIsModalOpen] = useState(false) @@ -722,6 +757,21 @@ const GoalsViewInner = ({ } } + const handleChartDataPointClick = useCallback( + (goalId: string, d: { x: Date; index: number }) => { + setSessionsDrawer({ + ...getChartPointWindow({ + x: d.x, + timeBucket, + timezone, + timeFormat, + }), + goalId, + }) + }, + [timeBucket, timeFormat, timezone], + ) + // Handle page/search changes - use fetcher for pagination useEffect(() => { if (page > 1 || debouncedSearch || isSearchMode) { @@ -911,6 +961,7 @@ const GoalsViewInner = ({ onDelete={handleDeleteGoal} onEdit={handleEditGoal} onToggleExpand={handleToggleExpand} + onChartDataPointClick={handleChartDataPointClick} /> ))} @@ -945,6 +996,17 @@ const GoalsViewInner = ({ projectId={id} goalId={editingGoalId} /> + setSessionsDrawer(null)} + from={sessionsDrawer?.from || ''} + to={sessionsDrawer?.to || ''} + label={sessionsDrawer?.label || ''} + projectId={id} + timezone={timezone} + timeFormat={timeFormat as '12-hour' | '24-hour'} + goalId={sessionsDrawer?.goalId} + /> ) diff --git a/web/app/pages/Project/tabs/Performance/PerformanceChart.tsx b/web/app/pages/Project/tabs/Performance/PerformanceChart.tsx index 3effbbe57..afe8df779 100644 --- a/web/app/pages/Project/tabs/Performance/PerformanceChart.tsx +++ b/web/app/pages/Project/tabs/Performance/PerformanceChart.tsx @@ -1,7 +1,9 @@ import { ChartOptions } from 'billboard.js' import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' import { Annotation } from '~/lib/models/Project' +import type { ChartDataPointClick } from '~/pages/Project/View/utils/chartPoint' import { MainChart } from '../../View/components/MainChart' import { getSettingsPerf } from '../../View/ViewProject.helpers' @@ -19,6 +21,7 @@ interface PerformanceChartProps { dataNames: Record className?: string annotations?: Annotation[] + onDataPointClick?: ChartDataPointClick } export const PerformanceChart = ({ @@ -34,7 +37,15 @@ export const PerformanceChart = ({ dataNames, className, annotations, + onDataPointClick, }: PerformanceChartProps) => { + const { t } = useTranslation('common') + + const dataPointClickLabel = useMemo( + () => (onDataPointClick ? t('project.exploreSessions') : undefined), + [onDataPointClick, t], + ) + const options: ChartOptions = useMemo(() => { return getSettingsPerf( chart, @@ -47,6 +58,8 @@ export const PerformanceChart = ({ onZoom, enableZoom, annotations, + onDataPointClick, + dataPointClickLabel, ) }, [ chart, @@ -59,6 +72,8 @@ export const PerformanceChart = ({ onZoom, enableZoom, annotations, + onDataPointClick, + dataPointClickLabel, ]) const deps = useMemo( @@ -73,6 +88,8 @@ export const PerformanceChart = ({ onZoom, enableZoom, annotations, + onDataPointClick, + dataPointClickLabel, ], [ chart, @@ -85,6 +102,8 @@ export const PerformanceChart = ({ onZoom, enableZoom, annotations, + onDataPointClick, + dataPointClickLabel, ], ) diff --git a/web/app/pages/Project/tabs/Performance/PerformanceView.tsx b/web/app/pages/Project/tabs/Performance/PerformanceView.tsx index e39c96373..46b052c75 100644 --- a/web/app/pages/Project/tabs/Performance/PerformanceView.tsx +++ b/web/app/pages/Project/tabs/Performance/PerformanceView.tsx @@ -9,6 +9,7 @@ import { useEffect, useMemo, useRef, + useCallback, lazy, Suspense, use, @@ -33,6 +34,7 @@ import { OverallPerformanceObject } from '~/lib/models/Project' import AnnotationModal from '~/modals/AnnotationModal' import { PerformanceChart } from '~/pages/Project/tabs/Performance/PerformanceChart' import { PerformanceMetricCards } from '~/pages/Project/tabs/Traffic/MetricCards' +import { SessionsDrawer } from '~/pages/Project/tabs/Traffic/SessionsDrawer' import PageLinkRow from '~/pages/Project/tabs/Traffic/PageLinkRow' import CCRow from '~/pages/Project/View/components/CCRow' import { ChartContextMenu } from '~/pages/Project/View/components/ChartContextMenu' @@ -54,6 +56,7 @@ import { getUsageTypeLabel, getConnectionTypeLabel, } from '~/pages/Project/View/ViewProject.helpers' +import { getChartPointWindow } from '~/pages/Project/View/utils/chartPoint' import { useCurrentProject } from '~/providers/CurrentProjectProvider' import { useTheme } from '~/providers/ThemeProvider' import type { ProjectLoaderData } from '~/routes/projects.$id' @@ -138,6 +141,7 @@ const PerformanceViewInner = ({ const revalidator = useRevalidator() const { performanceRefreshTrigger } = useRefreshTriggers() const { + timezone, filters, timeFormat, timeBucket, @@ -202,6 +206,11 @@ const PerformanceViewInner = ({ return {} }, ) + const [sessionsDrawer, setSessionsDrawer] = useState<{ + from: string + to: string + label: string + } | null>(null) // Track if we've ever shown actual content to prevent NoEvents flash during exit animation const hasShownContentRef = useRef(false) @@ -260,6 +269,20 @@ const PerformanceViewInner = ({ return {} }, [deferredData.perfOverallCompareStats, id]) + const handleDataPointClick = useCallback( + (d: { x: Date; index: number }) => { + setSessionsDrawer( + getChartPointWindow({ + x: d.x, + timeBucket, + timezone, + timeFormat, + }), + ) + }, + [timeBucket, timeFormat, timezone], + ) + const chartDataCompare: any = useMemo( () => deferredData.perfCompareData?.chart || {}, [deferredData.perfCompareData], @@ -546,6 +569,7 @@ const PerformanceViewInner = ({ dataNames={dataNames} className='mt-5 h-80 md:mt-0 [&_svg]:overflow-visible!' annotations={filteredAnnotations} + onDataPointClick={handleDataPointClick} /> ) : null} @@ -837,6 +861,18 @@ const PerformanceViewInner = ({ existingAnnotation={contextMenu.annotation} allowedToManage={allowedToManage} /> + setSessionsDrawer(null)} + from={sessionsDrawer?.from || ''} + to={sessionsDrawer?.to || ''} + label={sessionsDrawer?.label || ''} + projectId={id} + timezone={timezone} + timeFormat={timeFormat as '12-hour' | '24-hour'} + filters={filters} + sessionEvent='performance' + /> ) diff --git a/web/app/pages/Project/tabs/Traffic/SessionsDrawer.tsx b/web/app/pages/Project/tabs/Traffic/SessionsDrawer.tsx index 98c345dc6..880e38cb2 100644 --- a/web/app/pages/Project/tabs/Traffic/SessionsDrawer.tsx +++ b/web/app/pages/Project/tabs/Traffic/SessionsDrawer.tsx @@ -1,9 +1,22 @@ -import { XIcon, UsersIcon, WarningCircleIcon } from '@phosphor-icons/react' +import { + XIcon, + UsersIcon, + WarningCircleIcon, + CaretRightIcon, +} from '@phosphor-icons/react' import _map from 'lodash/map' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useLocation } from 'react-router' -import type { SessionsResponse, FunnelSessionsResponse } from '~/api/api.server' +import type { + SessionsResponse, + FunnelSessionsResponse, + GoalSessionsResponse, + ErrorSessionsResponse, + ErrorAffectedSession, + SessionEventType, +} from '~/api/api.server' import type { Session as SessionType } from '~/lib/models/Project' import { Drawer, @@ -15,18 +28,31 @@ import { } from '~/ui/Drawer' import Loader from '~/ui/Loader' import Spin from '~/ui/icons/Spin' +import { Link } from '~/ui/Link' +import { Text } from '~/ui/Text' +import Flag from '~/ui/Flag' +import { useTheme } from '~/providers/ThemeProvider' +import { getRelativeDateIfPossible } from '~/utils/date' import { Session } from '../Sessions/Sessions' +import { BrowserIcon, OSIcon } from '../SharedIcons' const SESSIONS_TAKE = 30 -type SessionsPageResult = SessionsResponse | FunnelSessionsResponse +type SessionsPageResult = + | SessionsResponse + | FunnelSessionsResponse + | GoalSessionsResponse + | ErrorSessionsResponse + +type DrawerSession = SessionType | ErrorAffectedSession async function fetchSessionsPage( action: string, projectId: string, params: Record, signal?: AbortSignal, + rootParams?: Record, ): Promise { const response = await fetch('/api/analytics', { method: 'POST', @@ -34,6 +60,7 @@ async function fetchSessionsPage( body: JSON.stringify({ action, projectId, + ...rootParams, params, }), signal, @@ -55,6 +82,106 @@ async function fetchSessionsPage( return result.data } +const mapErrorSession = (session: ErrorAffectedSession): ErrorAffectedSession => + session + +const getResultSessions = (result: SessionsPageResult): DrawerSession[] => { + if ('total' in result) { + return result.sessions.map(mapErrorSession) + } + + return result.sessions +} + +const isErrorAffectedSession = ( + session: DrawerSession, +): session is ErrorAffectedSession => 'errorCount' in session + +const ErrorSession = ({ session }: { session: ErrorAffectedSession }) => { + const { + t, + i18n: { language }, + } = useTranslation('common') + const { theme } = useTheme() + const location = useLocation() + + const lastErrorAt = useMemo(() => { + return getRelativeDateIfPossible(session.lastErrorAt, language) + }, [session.lastErrorAt, language]) + + const params = new URLSearchParams(location.search) + params.delete('eid') + params.set('psid', session.psid) + params.set('tab', 'sessions') + + return ( +
  • + +
    +
    +
    + + ? + +
    +
    +
    + + {session.profileId || t('project.unknownUser')} + +
    +
    +
    +
    + {session.cc ? ( +
    +
    + +
    +
    + +
    +
    +
    + + {lastErrorAt || t('project.unknown')} ยท {session.errorCount}{' '} + {t('project.occurrences').toLowerCase()} + +
    +
    +
    +
    + +
  • + ) +} + interface SessionsDrawerProps { isOpen: boolean onClose: () => void @@ -68,6 +195,10 @@ interface SessionsDrawerProps { period?: string funnelId?: string funnelStep?: number + goalId?: string + errorId?: string + sessionEvent?: SessionEventType + title?: string totalCount?: number } @@ -84,14 +215,21 @@ export const SessionsDrawer = ({ period = 'custom', funnelId, funnelStep, + goalId, + errorId, + sessionEvent, + title, totalCount, }: SessionsDrawerProps) => { const { t } = useTranslation('common') const stableFilters = useMemo(() => filters ?? [], [filters]) const isFunnelMode = !!(funnelId && funnelStep) - const [sessions, setSessions] = useState([]) + const isGoalMode = !!goalId + const isErrorMode = !!errorId + const [sessions, setSessions] = useState([]) const [skip, setSkip] = useState(0) const [hasMore, setHasMore] = useState(true) + const [resultTotalCount, setResultTotalCount] = useState(null) const [initialLoading, setInitialLoading] = useState(true) const [loadingMore, setLoadingMore] = useState(false) const [error, setError] = useState(null) @@ -125,6 +263,37 @@ export const SessionsDrawer = ({ }, signal, ) + } else if (isGoalMode) { + result = await fetchSessionsPage( + 'getGoalSessions', + projectId, + { + period, + from, + to, + timezone, + goalId, + take: SESSIONS_TAKE, + skip: currentSkip, + }, + signal, + ) + } else if (isErrorMode) { + result = await fetchSessionsPage( + 'getErrorSessions', + projectId, + { + period, + from, + to, + timezone, + filters: stableFilters, + take: SESSIONS_TAKE, + skip: currentSkip, + }, + signal, + { errorId }, + ) } else { result = await fetchSessionsPage( 'getSessions', @@ -135,6 +304,7 @@ export const SessionsDrawer = ({ to, timezone, filters: stableFilters, + sessionEvent, take: SESSIONS_TAKE, skip: currentSkip, }, @@ -156,13 +326,21 @@ export const SessionsDrawer = ({ setError(null) if (result) { - const newSessions = result.sessions ?? [] + const newSessions = getResultSessions(result) + const nextTotalCount = 'total' in result ? result.total : null + if (append) { setSessions((prev) => [...prev, ...newSessions]) } else { setSessions(newSessions) } - setHasMore(newSessions.length >= SESSIONS_TAKE) + + setResultTotalCount(nextTotalCount) + setHasMore( + nextTotalCount != null + ? currentSkip + newSessions.length < nextTotalCount + : newSessions.length >= SESSIONS_TAKE, + ) } return true @@ -174,8 +352,13 @@ export const SessionsDrawer = ({ to, timezone, isFunnelMode, + isGoalMode, + isErrorMode, funnelId, funnelStep, + goalId, + errorId, + sessionEvent, stableFilters, t, ], @@ -192,9 +375,9 @@ export const SessionsDrawer = ({ setSessions([]) setSkip(0) setHasMore(true) + setResultTotalCount(null) setError(null) - // Delay fetching slightly to allow the drawer animation to run smoothly without layout shifts const timer = setTimeout(() => { loadSessions(0, false, controller.signal).finally(() => { if (!controller.signal.aborted) { @@ -209,6 +392,8 @@ export const SessionsDrawer = ({ } }, [isOpen, period, from, to, loadSessions]) + const displayTotalCount = totalCount ?? resultTotalCount + const loadMore = useCallback(async () => { if (loadingRef.current || !hasMore) return loadingRef.current = true @@ -264,16 +449,16 @@ export const SessionsDrawer = ({
    - {t('project.sessions')} + {title || t('project.sessions')} {label}
    {!initialLoading && - (totalCount != null || sessions.length > 0) ? ( + (displayTotalCount != null || sessions.length > 0) ? ( - {totalCount != null ? ( - totalCount + {displayTotalCount != null ? ( + displayTotalCount ) : ( <> {sessions.length} @@ -326,13 +511,17 @@ export const SessionsDrawer = ({ ) : ( <>
      - {_map(sessions, (session) => ( - - ))} + {_map(sessions, (session) => + isErrorAffectedSession(session) ? ( + + ) : ( + + ), + )}
    {hasMore ? (
    { - const date = dayjs(d.x).tz(timezone) - let from: string - let to: string - let label: string - - switch (timeBucket) { - case 'minute': - from = date.startOf('minute').toISOString() - to = date.endOf('minute').toISOString() - label = date.format('MMM D, YYYY HH:mm') - break - case 'hour': - from = date.startOf('hour').toISOString() - to = date.endOf('hour').toISOString() - label = date.format( - timeFormat === '24-hour' - ? 'MMM D, YYYY HH:00 - HH:59' - : 'MMM D, YYYY h:00 - h:59 A', - ) - break - case 'month': - from = date.startOf('month').toISOString() - to = date.endOf('month').toISOString() - label = date.format('MMMM YYYY') - break - case 'year': - from = date.startOf('year').toISOString() - to = date.endOf('year').toISOString() - label = date.format('YYYY') - break - case 'day': - default: - from = date.startOf('day').toISOString() - to = date.endOf('day').toISOString() - label = date.format('dddd, MMM D, YYYY') - break - } - - setSessionsDrawer({ from, to, label }) + setSessionsDrawer( + getChartPointWindow({ + x: d.x, + timeBucket, + timezone, + timeFormat, + }), + ) }, [timeBucket, timeFormat, timezone], ) diff --git a/web/app/routes/api.analytics.ts b/web/app/routes/api.analytics.ts index c47b17605..e93cef568 100644 --- a/web/app/routes/api.analytics.ts +++ b/web/app/routes/api.analytics.ts @@ -7,6 +7,7 @@ import { import { getSessionsServer, getFunnelSessionsServer, + getGoalSessionsServer, getErrorsServer, getFeatureFlagStatsServer, getFeatureFlagProfilesServer, @@ -43,6 +44,7 @@ import { type AnalyticsFilter, type SessionsResponse, type FunnelSessionsResponse, + type GoalSessionsResponse, type ErrorsResponse, type FeatureFlagStats, type FeatureFlagProfilesResponse, @@ -128,6 +130,7 @@ interface ProxyRequest { | 'getRevenueData' | 'getOverallStats' | 'getFunnelSessions' + | 'getGoalSessions' projectId: string pids?: string[] flagId?: string @@ -158,6 +161,8 @@ interface ProxyRequest { query?: string funnelId?: string step?: number + goalId?: string + sessionEvent?: 'traffic' | 'performance' | 'error' } } @@ -177,6 +182,7 @@ export async function action({ request }: ActionFunctionArgs) { take?: number skip?: number options?: Record + sessionEvent?: 'traffic' | 'performance' | 'error' } = { timeBucket: params.timeBucket || 'day', period: params.period || '7d', @@ -187,6 +193,7 @@ export async function action({ request }: ActionFunctionArgs) { password: password || undefined, take: params.take, skip: params.skip, + sessionEvent: params.sessionEvent, } try { @@ -229,6 +236,33 @@ export async function action({ request }: ActionFunctionArgs) { }) } + case 'getGoalSessions': { + const goalId = body.goalId || params.goalId + if (!goalId) { + return data>( + { data: null, error: 'goalId is required' }, + { status: 400 }, + ) + } + + const result = await getGoalSessionsServer(request, goalId, { + period: analyticsParams.period, + from: analyticsParams.from, + to: analyticsParams.to, + timezone: analyticsParams.timezone, + take: params.take, + skip: params.skip, + }) + return data>({ + data: result.data, + error: result.error + ? Array.isArray(result.error) + ? result.error.join(', ') + : result.error + : null, + }) + } + case 'getErrors': { const errorsParams = { ...analyticsParams, options: params.options } const result = await getErrorsServer(request, projectId, errorsParams) @@ -710,6 +744,8 @@ export async function action({ request }: ActionFunctionArgs) { period: params.period, from: formatDateForBackend(params.from), to: formatDateForBackend(params.to), + filters: params.filters, + timezone: params.timezone, take: params.take, skip: params.skip, password: password || undefined,