Skip to content
Open
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
2 changes: 1 addition & 1 deletion developer-extension/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@flashcatcloud/browser-sdk-developer-extension",
"version": "0.0.2",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "rm -rf dist && webpack --mode production",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@flashcatcloud/browser-core",
"version": "0.0.2",
"version": "0.1.0",
"license": "Apache-2.0",
"main": "cjs/index.js",
"module": "esm/index.js",
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/domain/session/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export interface SessionContext<TrackingType extends string> extends Context {
id: string
trackingType: TrackingType
isReplayForced: boolean
/**
* Whether an error has already been reported during this session. Persisted in the session store
* so it survives page navigation: an error session must not go back to withholding its replay
* just because the user moved to another page.
*/
hasError: boolean
anonymousId: string | undefined
}

Expand Down Expand Up @@ -92,6 +98,7 @@ export function startSessionManager<TrackingType extends string>(
id: sessionStore.getSession().id!,
trackingType: sessionStore.getSession()[productKey] as TrackingType,
isReplayForced: !!sessionStore.getSession().forcedReplay,
hasError: !!sessionStore.getSession().hasError,
anonymousId: sessionStore.getSession().anonymousId,
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/flagging/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@flashcatcloud/browser-flagging",
"version": "0.0.2",
"version": "0.1.0",
"license": "Apache-2.0",
"private": true,
"main": "cjs/entries/main.js",
Expand Down
4 changes: 2 additions & 2 deletions packages/logs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@flashcatcloud/browser-logs",
"version": "0.0.2",
"version": "0.1.0",
"license": "Apache-2.0",
"main": "cjs/entries/main.js",
"module": "esm/entries/main.js",
Expand All @@ -14,7 +14,7 @@
"replace-build-env": "node ../../scripts/build/replace-build-env.js"
},
"dependencies": {
"@flashcatcloud/browser-core": "0.0.2"
"@flashcatcloud/browser-core": "0.1.0"
},
"peerDependencies": {
"@flashcatcloud/browser-rum": "0.0.2"
Expand Down
2 changes: 1 addition & 1 deletion packages/rum-core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@flashcatcloud/browser-rum-core",
"version": "0.0.2",
"version": "0.1.0",
"license": "Apache-2.0",
"main": "cjs/index.js",
"module": "esm/index.js",
Expand Down
4 changes: 4 additions & 0 deletions packages/rum-core/src/boot/startRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { startErrorCollection } from '../domain/error/errorCollection'
import { startResourceCollection } from '../domain/resource/resourceCollection'
import { startViewCollection } from '../domain/view/viewCollection'
import { startRumSessionManager, startRumSessionManagerStub } from '../domain/rumSessionManager'
import { startSessionErrorTracking } from '../domain/trackSessionError'
import { startRumBatch } from '../transport/startRumBatch'
import { startRumEventBridge } from '../transport/startRumEventBridge'
import { startUrlContexts } from '../domain/contexts/urlContexts'
Expand Down Expand Up @@ -110,6 +111,9 @@ export function startRum(
? startRumSessionManager(configuration, lifeCycle, trackingConsentState)
: startRumSessionManagerStub()

const sessionErrorTracking = startSessionErrorTracking(lifeCycle, session)
cleanupTasks.push(() => sessionErrorTracking.stop())

if (!canUseEventBridge()) {
const batch = startRumBatch(
configuration,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { InitConfiguration } from '@flashcatcloud/browser-core'
import { DefaultPrivacyLevel, display, TraceContextInjection } from '@flashcatcloud/browser-core'
import { EXHAUSTIVE_INIT_CONFIGURATION, SERIALIZED_EXHAUSTIVE_INIT_CONFIGURATION } from '@flashcatcloud/browser-core/test'
import {
EXHAUSTIVE_INIT_CONFIGURATION,
SERIALIZED_EXHAUSTIVE_INIT_CONFIGURATION,
} from '@flashcatcloud/browser-core/test'
import type {
ExtractTelemetryConfiguration,
CamelToSnakeCase,
Expand Down Expand Up @@ -529,6 +532,7 @@ describe('serializeRumConfiguration', () => {
enablePrivacyForActionName: false,
subdomain: 'foo',
sessionReplaySampleRate: 60,
sessionReplayOnErrorSampleRate: 40,
startSessionReplayRecordingManually: true,
trackUserInteractions: true,
actionNameAttribute: 'test-id',
Expand All @@ -554,6 +558,8 @@ describe('serializeRumConfiguration', () => {
| 'remoteConfigurationId'
| 'profilingSampleRate'
| 'propagateTraceBaggage'
// not reported yet: needs a rum-events-format schema change first
| 'sessionReplayOnErrorSampleRate'
? never
: CamelToSnakeCase<Key>
// By specifying the type here, we can ensure that serializeConfiguration is returning an
Expand Down
21 changes: 20 additions & 1 deletion packages/rum-core/src/domain/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ export interface RumInitConfiguration extends InitConfiguration {
* See [Configure Your Setup For Browser RUM and Browser RUM & Session Replay Sampling](https://docs.datadoghq.com/real_user_monitoring/guide/sampling-browser-plans) for further information.
*/
sessionReplaySampleRate?: number | undefined
/**
* The percentage of tracked sessions that record a replay but only upload it if the session
* reports an error: 100 for all, 0 for none. Drawn only for sessions that the plain
* `sessionReplaySampleRate` draw missed, so a session is never counted by both rates.
*
* Such a session records from the start and keeps at most the last minute of it in memory. If it
* never reports an error, nothing is uploaded and the session is not billed. On the first error,
* the withheld minute is uploaded and recording continues normally for the rest of the session.
*/
sessionReplayOnErrorSampleRate?: number | undefined
/**
* If the session is sampled for Session Replay, only start the recording when `startSessionReplayRecording()` is called, instead of at the beginning of the session. Default: if startSessionReplayRecording is 0, true; otherwise, false.
* See [Session Replay Usage](https://docs.datadoghq.com/real_user_monitoring/session_replay/browser/#usage) for further information.
Expand Down Expand Up @@ -175,6 +185,7 @@ export interface RumConfiguration extends Configuration {
defaultPrivacyLevel: DefaultPrivacyLevel
enablePrivacyForActionName: boolean
sessionReplaySampleRate: number
sessionReplayOnErrorSampleRate: number
startSessionReplayRecordingManually: boolean
trackUserInteractions: boolean
trackViewsManually: boolean
Expand Down Expand Up @@ -207,6 +218,7 @@ export function validateAndBuildRumConfiguration(

if (
!isSampleRate(initConfiguration.sessionReplaySampleRate, 'Session Replay') ||
!isSampleRate(initConfiguration.sessionReplayOnErrorSampleRate, 'Session Replay on Error') ||
!isSampleRate(initConfiguration.traceSampleRate, 'Trace')
) {
return
Expand All @@ -230,16 +242,20 @@ export function validateAndBuildRumConfiguration(
const profilingEnabled = isExperimentalFeatureEnabled(ExperimentalFeature.PROFILING)

const sessionReplaySampleRate = initConfiguration.sessionReplaySampleRate ?? 0
const sessionReplayOnErrorSampleRate = initConfiguration.sessionReplayOnErrorSampleRate ?? 0

return {
applicationId: initConfiguration.applicationId,
version: initConfiguration.version || undefined,
actionNameAttribute: initConfiguration.actionNameAttribute,
sessionReplaySampleRate,
sessionReplayOnErrorSampleRate,
startSessionReplayRecordingManually:
initConfiguration.startSessionReplayRecordingManually !== undefined
? !!initConfiguration.startSessionReplayRecordingManually
: sessionReplaySampleRate === 0,
: // An error-sampled session has to be recording before the error happens, otherwise there is
// nothing to withhold and release. So it must auto-start just like a plain sampled one.
sessionReplaySampleRate === 0 && sessionReplayOnErrorSampleRate === 0,
traceSampleRate: initConfiguration.traceSampleRate ?? 100,
rulePsr: isNumber(initConfiguration.traceSampleRate) ? initConfiguration.traceSampleRate / 100 : undefined,
allowedTracingUrls,
Expand Down Expand Up @@ -325,6 +341,9 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) {

return {
session_replay_sample_rate: configuration.sessionReplaySampleRate,
// `session_replay_on_error_sample_rate` is deliberately not reported yet: the telemetry
// configuration type is generated from the rum-events-format schema, so adding it needs a schema
// change first, and that is a separate repository.
start_session_replay_recording_manually: configuration.startSessionReplayRecordingManually,
trace_sample_rate: configuration.traceSampleRate,
trace_context_injection: configuration.traceContextInjection,
Expand Down
13 changes: 11 additions & 2 deletions packages/rum-core/src/domain/contexts/sessionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,23 @@ export function startSessionContext(
return DISCARDED
}

// A session withholding its replay is recording, but nothing has been uploaded and nothing may
// ever be. Reporting `has_replay` here would offer a replay that does not exist.
const isReplayWithheld = session.sessionReplay === SessionReplayState.BUFFERED_ON_ERROR

let hasReplay
let sampledForReplay
let sampledForErrorReplay
let isActive
if (eventType === RumEventType.VIEW) {
hasReplay = recorderApi.getReplayStats(view.id) ? true : undefined
hasReplay = !isReplayWithheld && recorderApi.getReplayStats(view.id) ? true : undefined
sampledForReplay = session.sessionReplay === SessionReplayState.SAMPLED
// Tells a replay collected only because the session errored apart from one collected
// unconditionally - the two cost differently and are answered by different questions.
sampledForErrorReplay = session.sampledOnErrorReplay || undefined
isActive = view.sessionIsActive ? undefined : false
} else {
hasReplay = recorderApi.isRecording() ? true : undefined
hasReplay = !isReplayWithheld && recorderApi.isRecording() ? true : undefined
}

return {
Expand All @@ -38,6 +46,7 @@ export function startSessionContext(
type: SessionType.USER,
has_replay: hasReplay,
sampled_for_replay: sampledForReplay,
sampled_for_error_replay: sampledForErrorReplay,
is_active: isActive,
},
}
Expand Down
81 changes: 81 additions & 0 deletions packages/rum-core/src/domain/rumSessionManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,87 @@ describe('rum session manager', () => {
)
})

describe('error session replay sampling', () => {
it('draws the error-replay type only when the plain replay draw missed', () => {
startRumSessionManagerWithDefaults({
configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100, sessionReplayOnErrorSampleRate: 100 },
})

expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY)
})

it('stores the error-replay type when only that rate is hit', () => {
startRumSessionManagerWithDefaults({
configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 },
})

expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(
RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY
)
})

it('withholds the replay until the session reports an error', () => {
const sessionManager = startRumSessionManagerWithDefaults({
configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 },
})

expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.BUFFERED_ON_ERROR)

sessionManager.setSessionHasError()

expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED)
})

it('keeps the released state across a page load, since it is persisted in the session store', () => {
setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=3&hasError=1', DURATION)

const sessionManager = startRumSessionManagerWithDefaults()

expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED)
})

it('marks the session so a replay kept only because it errored can be told apart', () => {
const sessionManager = startRumSessionManagerWithDefaults({
configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 },
})

expect(sessionManager.findTrackedSession()!.sampledOnErrorReplay).toBeTrue()

// still true once released, so what was stored can be told apart afterwards
sessionManager.setSessionHasError()

expect(sessionManager.findTrackedSession()!.sampledOnErrorReplay).toBeTrue()
})

it('does not mark a session whose replay is collected unconditionally', () => {
const sessionManager = startRumSessionManagerWithDefaults({
configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 },
})

expect(sessionManager.findTrackedSession()!.sampledOnErrorReplay).toBeFalse()
})

it('releases the replay when it is forced, rather than waiting for an error that may never come', () => {
const sessionManager = startRumSessionManagerWithDefaults({
configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 },
})
expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.BUFFERED_ON_ERROR)

sessionManager.setForcedReplay()

expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.FORCED)
})

it('tracks the session even when no replay rate is hit at all', () => {
const sessionManager = startRumSessionManagerWithDefaults({
configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 0 },
})

expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY)
expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.OFF)
})
})

function startRumSessionManagerWithDefaults({ configuration }: { configuration?: Partial<RumConfiguration> } = {}) {
return startRumSessionManager(
mockRumConfiguration({
Expand Down
Loading