From 710826404660975b597ea2323237b6b8a48662c6 Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 10 Jul 2026 00:40:48 -0700 Subject: [PATCH 1/8] chore(release): v0.1.7 --- packages/core/package.json | 2 +- packages/core/src/domain/configuration/sdkVersion.ts | 2 +- packages/miniprogram-platform/package.json | 4 ++-- packages/miniprogram-rum/package.json | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 656de6a..46a05ef 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/miniprogram-core", - "version": "0.1.6", + "version": "0.1.7", "main": "dist/index.js", "types": "dist/index.d.ts", "miniprogram": "dist", diff --git a/packages/core/src/domain/configuration/sdkVersion.ts b/packages/core/src/domain/configuration/sdkVersion.ts index e225c7b..8bacd69 100644 --- a/packages/core/src/domain/configuration/sdkVersion.ts +++ b/packages/core/src/domain/configuration/sdkVersion.ts @@ -2,4 +2,4 @@ * SDK 版本号 * 每次发布新版本时同步更新此处 */ -export const SDK_VERSION = '0.1.0' +export const SDK_VERSION = '0.1.7' diff --git a/packages/miniprogram-platform/package.json b/packages/miniprogram-platform/package.json index a271092..980829d 100644 --- a/packages/miniprogram-platform/package.json +++ b/packages/miniprogram-platform/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/miniprogram-platform", - "version": "0.1.6", + "version": "0.1.7", "main": "dist/index.js", "types": "dist/index.d.ts", "miniprogram": "dist", @@ -14,6 +14,6 @@ "build": "tsc -b" }, "dependencies": { - "@flashcatcloud/miniprogram-core": "0.1.6" + "@flashcatcloud/miniprogram-core": "0.1.7" } } diff --git a/packages/miniprogram-rum/package.json b/packages/miniprogram-rum/package.json index 047c5fa..8bbca71 100644 --- a/packages/miniprogram-rum/package.json +++ b/packages/miniprogram-rum/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/miniprogram-rum", - "version": "0.1.6", + "version": "0.1.7", "main": "dist/index.js", "types": "dist/index.d.ts", "miniprogram": "dist", @@ -14,7 +14,7 @@ "build": "tsc -b" }, "dependencies": { - "@flashcatcloud/miniprogram-core": "0.1.6", - "@flashcatcloud/miniprogram-platform": "0.1.6" + "@flashcatcloud/miniprogram-core": "0.1.7", + "@flashcatcloud/miniprogram-platform": "0.1.7" } } From 443d019962904a4b6143141aa318823dff2f04ae Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 21 Aug 2026 07:33:01 -0700 Subject: [PATCH 2/8] ci: also send release success notifications to a second DingTalk group Duplicate each success notification step to post to a second DingTalk robot via the DING_TALK_TOKEN_2 / DING_TALK_SECRET_2 secrets. Failure notifications are unchanged and still go to the original robot only. --- .github/workflows/publish-npm.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 7ecf938..46091a1 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -90,3 +90,17 @@ jobs: "text": "### ✅ 小程序 SDK NPM 发布成功\n\n---\n\n 🔖 分支/标签: ${{ github.ref }}\n\n 📝 提交信息: [${{ env.COMMIT_MESSAGE }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }})\n\n 👨‍💻 触发人: ${{ github.actor }}\n\n 🚀 [查看发布详情](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" } } + + - name: Notify secondary DingTalk group on success + uses: zcong1993/actions-ding@master + with: + dingToken: ${{ secrets.DING_TALK_TOKEN_2 }} + secret: ${{ secrets.DING_TALK_SECRET_2 }} + body: | + { + "msgtype": "markdown", + "markdown": { + "title": "小程序 SDK NPM 发布通知", + "text": "### ✅ 小程序 SDK NPM 发布成功\n\n---\n\n 🔖 分支/标签: ${{ github.ref }}\n\n 📝 提交信息: [${{ env.COMMIT_MESSAGE }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }})\n\n 👨‍💻 触发人: ${{ github.actor }}\n\n 🚀 [查看发布详情](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" + } + } From e8ea811c5a1591620c43fdf0bab1bfd09a5b0007 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 01:53:14 -0700 Subject: [PATCH 3/8] ci: do not fail a release when the DingTalk notification fails The release workflow treated the DingTalk notification as a build gate: when the robot API rejects a message -- for example once its per-minute rate limit is exhausted -- the notification step failed and marked an otherwise successful publish as failed. Set `ignoreError` on every DingTalk step so a rejected notification is reported as a warning instead of failing the job. The publish outcome no longer depends on the notification channel. --- .github/workflows/publish-npm.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 46091a1..7857e45 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -80,6 +80,7 @@ jobs: - name: Notify publish success uses: zcong1993/actions-ding@master with: + ignoreError: "true" dingToken: ${{ secrets.DING_TALK_TOKEN }} secret: ${{ secrets.DING_TALK_SECRET }} body: | @@ -94,6 +95,7 @@ jobs: - name: Notify secondary DingTalk group on success uses: zcong1993/actions-ding@master with: + ignoreError: "true" dingToken: ${{ secrets.DING_TALK_TOKEN_2 }} secret: ${{ secrets.DING_TALK_SECRET_2 }} body: | From a4ccb3919757ec3a004720e126827e1fe4182720 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 02:07:13 -0700 Subject: [PATCH 4/8] ci: quote the ignoreError input the way Prettier expects The repository formats YAML with `singleQuote`, so the double-quoted value failed the format check. --- .github/workflows/publish-npm.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 7857e45..1a05b15 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -80,7 +80,7 @@ jobs: - name: Notify publish success uses: zcong1993/actions-ding@master with: - ignoreError: "true" + ignoreError: 'true' dingToken: ${{ secrets.DING_TALK_TOKEN }} secret: ${{ secrets.DING_TALK_SECRET }} body: | @@ -95,7 +95,7 @@ jobs: - name: Notify secondary DingTalk group on success uses: zcong1993/actions-ding@master with: - ignoreError: "true" + ignoreError: 'true' dingToken: ${{ secrets.DING_TALK_TOKEN_2 }} secret: ${{ secrets.DING_TALK_SECRET_2 }} body: | From dee905127406a3a07aca672cbb16557dd27788af Mon Sep 17 00:00:00 2001 From: guguji5 <604821528@qq.com> Date: Tue, 25 Aug 2026 15:13:06 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=E5=8A=A8=E6=80=81=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=20config=E9=85=8D=E7=BD=AE=EF=BC=88=E5=8F=AA=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E8=BF=9B=E5=8E=BB=E4=BA=86sessionSampleRate=20?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E5=80=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 19 +- .../src/domain/configuration/configuration.ts | 9 + .../src/domain/configuration/sdkVersion.ts | 2 +- .../core/src/domain/session/sessionManager.ts | 75 ++- packages/core/src/index.ts | 1 + .../src/platform/index.ts | 1 + .../src/platform/types.ts | 2 +- packages/miniprogram-rum/src/boot/startRum.ts | 16 +- .../miniprogram-rum/src/domain/assembly.ts | 23 +- .../src/domain/configuration/configuration.ts | 10 +- .../configuration/remoteConfiguration.ts | 441 ++++++++++++++++++ .../src/domain/rumSessionManager.ts | 7 +- .../miniprogram-rum/src/rawRumEvent.types.ts | 1 + test/assemblyTemporalContext.test.ts | 98 +++- test/configuration.test.ts | 18 + test/remoteConfiguration.test.ts | 424 +++++++++++++++++ test/sessionManager.test.ts | 86 ++++ test/startRum.test.ts | 92 +++- yarn.lock | 39 +- 19 files changed, 1306 insertions(+), 58 deletions(-) create mode 100644 packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts create mode 100644 test/remoteConfiguration.test.ts diff --git a/README.md b/README.md index 0f62f25..7e9bde2 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ flashcatRum.init({ service: "my-miniprogram", env: "production", version: "1.0.0", + // 可选:启用 RUM 远程会话采样配置 + remoteConfiguration: true, }); App({ @@ -141,11 +143,12 @@ SDK 通过以下机制实现自动追踪,**无需手动关联 APP 事件**: | `clientToken` | string | ✅ | - | 客户端 Token | | `applicationId` | string | ✅ | - | 应用 ID | | `site` | string | ❌ | `browser.flashcat.cloud` | FlashCat 站点域名,自动拼接为 `https://{site}/api/v2/rum` | -| `proxy` | string | ❌ | - | 代理地址,SDK 拼接为 `{proxy}?ddforward={encodedPath}`(优先级高于 site) | +| `proxy` | string / function | ❌ | - | 代理地址或 URL 构建函数(优先级高于 site) | | `service` | string | ❌ | - | 服务名称 | | `env` | string | ❌ | - | 环境(dev/test/prod) | | `version` | string | ❌ | - | 应用版本号 | | `sessionSampleRate` | number | ❌ | 100 | 会话采样率(0-100) | +| `remoteConfiguration` | boolean | ❌ | false | 是否启用远程会话采样配置 | | `flushInterval` | number | ❌ | 15000 | 上报间隔(毫秒) | | `trackPages` | boolean | ❌ | true | 是否追踪页面 | | `trackActions` | boolean | ❌ | true | 是否追踪用户交互 | @@ -155,6 +158,20 @@ SDK 通过以下机制实现自动追踪,**无需手动关联 APP 事件**: | `debug` | boolean | ❌ | false | 是否开启调试模式 | | `beforeSend` | function | ❌ | - | 数据过滤钩子 | +### 远程会话采样配置 + +设置 `remoteConfiguration: true` 后,SDK 会在初始化时同步读取上次缓存的有效配置,并在初始化完成后异步请求一次 `/api/v2/rum/config`。配置请求不阻塞初始化和事件采集,也不会被记录为 RUM resource 或 error 事件。 + +会话采样只在创建 Session 时执行一次: + +- 冷启动已有有效缓存时,首个新 Session 直接使用缓存中的采样率。 +- 没有缓存时,首个 Session 使用初始化的 `sessionSampleRate`;随后拉取到的新值只影响之后创建的 Session。 +- 当前 Session 不会因配置拉取成功而重新抽签。调用 `flashcatRum.stopSession()` 后,下一次事件创建的新 Session 会使用最新配置。 +- 配置接口不可用、响应非法或缓存不可读时,SDK 安全回退到初始化采样率,不影响正常采集。 +- 本期远程配置只支持 `sessionSampleRate`;追踪采样率、回放采样率和隐私等级等字段会被忽略。 + +远程配置沿用现有 `site` 或 `proxy`。因此直连模式无需额外添加小程序合法域名;代理模式需确保现有代理同时转发 `/api/v2/rum/config`,并建议透传 ETag 以使用 `304 Not Modified`。SDK 只在初始化时拉取(失败时会进行有限重试),不会定时轮询,也不会在创建新 Session 时额外请求。 + ## API 文档 ### 初始化 diff --git a/packages/core/src/domain/configuration/configuration.ts b/packages/core/src/domain/configuration/configuration.ts index 9d4acdb..9ea8c0a 100644 --- a/packages/core/src/domain/configuration/configuration.ts +++ b/packages/core/src/domain/configuration/configuration.ts @@ -28,6 +28,11 @@ export interface InitConfiguration { version?: string debug?: boolean // 是否开启调试模式 trackAnonymousUser?: boolean + /** + * 是否启用远程配置。默认关闭;关闭时不会读取缓存或发起配置请求。 + * @default false + */ + remoteConfiguration?: boolean } export interface Configuration { @@ -42,6 +47,7 @@ export interface Configuration { version?: string debug: boolean trackAnonymousUser: boolean + remoteConfiguration: boolean } export function validateAndBuildConfiguration(initConfiguration: InitConfiguration): Configuration | undefined { @@ -58,6 +64,7 @@ export function validateAndBuildConfiguration(initConfiguration: InitConfigurati const flushInterval = initConfiguration.flushInterval ?? 15000 const debug = initConfiguration.debug ?? false const trackAnonymousUser = initConfiguration.trackAnonymousUser ?? true + const remoteConfiguration = initConfiguration.remoteConfiguration ?? false const configurationTags = buildTags(initConfiguration) const endpointBuilder = createEndpointBuilder(initConfiguration, 'rum', configurationTags) @@ -74,6 +81,7 @@ export function validateAndBuildConfiguration(initConfiguration: InitConfigurati version: initConfiguration.version, debug, trackAnonymousUser, + remoteConfiguration, } if (debug) { @@ -89,6 +97,7 @@ export function validateAndBuildConfiguration(initConfiguration: InitConfigurati sessionSampleRate: config.sessionSampleRate, flushInterval: `${config.flushInterval}ms`, trackAnonymousUser: config.trackAnonymousUser, + remoteConfiguration: config.remoteConfiguration, }) } diff --git a/packages/core/src/domain/configuration/sdkVersion.ts b/packages/core/src/domain/configuration/sdkVersion.ts index 8bacd69..7884fab 100644 --- a/packages/core/src/domain/configuration/sdkVersion.ts +++ b/packages/core/src/domain/configuration/sdkVersion.ts @@ -2,4 +2,4 @@ * SDK 版本号 * 每次发布新版本时同步更新此处 */ -export const SDK_VERSION = '0.1.7' +export const SDK_VERSION = '0.1.8' diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index 23d61d0..fad3c34 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -13,6 +13,10 @@ export interface SessionState { expireAt: number anonymousId?: string isTracked?: boolean + /** The sampling rate used for this session's single draw. */ + sessionSampleRate?: number + /** The remote configuration version applied when this session was created. */ + rcVersion?: number } export interface SessionStore { @@ -22,6 +26,7 @@ export interface SessionStore { } export interface SessionManager { + findSession: (time?: number) => SessionState | undefined findTrackedSession: (time?: number) => SessionState | undefined renew: () => SessionState expand: () => void @@ -30,7 +35,15 @@ export interface SessionManager { export function startSessionManager( store: SessionStore, - { trackAnonymousUser = true, sessionSampleRate = 100 }: { trackAnonymousUser?: boolean; sessionSampleRate?: number } = {}, + { + trackAnonymousUser = true, + sessionSampleRate = 100, + getSessionConfiguration, + }: { + trackAnonymousUser?: boolean + sessionSampleRate?: number + getSessionConfiguration?: () => { sessionSampleRate: number; rcVersion: number } + } = {}, ): SessionManager { let lastExpand = 0 const sessionHistory = createValueHistory(() => now(), { @@ -40,6 +53,24 @@ export function startSessionManager( const initialSession = store.get() if (initialSession) { + let wasMigrated = false + // Sessions written by older SDK versions did not persist these fields. Lock + // them to the init value so an SDK upgrade cannot change an active draw. + if (initialSession.sessionSampleRate === undefined) { + initialSession.sessionSampleRate = sessionSampleRate + wasMigrated = true + } + if (initialSession.rcVersion === undefined) { + initialSession.rcVersion = 0 + wasMigrated = true + } + if (wasMigrated) { + try { + store.set(initialSession) + } catch { + // A storage failure must not prevent the in-memory session from being used. + } + } sessionHistory.add(cloneSessionState(initialSession), initialSession.created) } @@ -49,29 +80,45 @@ export function startSessionManager( function createSession(): SessionState { const time = now() + let currentConfiguration = { sessionSampleRate, rcVersion: 0 } + if (getSessionConfiguration) { + try { + currentConfiguration = getSessionConfiguration() + } catch { + // Keep initialization values when a dynamic provider fails. + } + } return { id: generateUUID(), created: time, expireAt: time + SESSION_EXPIRATION_DELAY, anonymousId: trackAnonymousUser ? store.get()?.anonymousId || generateUUID() : undefined, - isTracked: performDraw(sessionSampleRate), + isTracked: performDraw(currentConfiguration.sessionSampleRate), + sessionSampleRate: currentConfiguration.sessionSampleRate, + rcVersion: currentConfiguration.rcVersion, + } + } + + function findSession(time?: number): SessionState | undefined { + if (time !== undefined) { + const historicalSession = sessionHistory.find(time)?.value + if (historicalSession && !isExpiredAt(historicalSession, time)) { + return historicalSession + } + return undefined } + const state = store.get() + if (!state || isExpiredAt(state, now())) { + return undefined + } + return state } return { + findSession, findTrackedSession: (time) => { - if (time !== undefined) { - const historicalSession = sessionHistory.find(time)?.value - if (historicalSession && historicalSession.isTracked !== false && !isExpiredAt(historicalSession, time)) { - return historicalSession - } - return undefined - } - const state = store.get() - if (!state) { - return undefined - } - if (state.isTracked === false || isExpiredAt(state, now())) { + const state = findSession(time) + if (!state || state.isTracked === false) { return undefined } return state diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b2511f8..7619ed0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,7 @@ export type { InitConfiguration, Configuration } from './domain/configuration/co export { validateAndBuildConfiguration } from './domain/configuration/configuration' export type { EndpointBuilder, ProxyFn } from './domain/configuration/endpointBuilder' export { createEndpointBuilder, isIntakeUrl } from './domain/configuration/endpointBuilder' +export { SDK_VERSION } from './domain/configuration/sdkVersion' export type { Telemetry, TelemetryEvent } from './domain/telemetry/telemetry' export { startTelemetry } from './domain/telemetry/telemetry' diff --git a/packages/miniprogram-platform/src/platform/index.ts b/packages/miniprogram-platform/src/platform/index.ts index 7393200..64773ba 100644 --- a/packages/miniprogram-platform/src/platform/index.ts +++ b/packages/miniprogram-platform/src/platform/index.ts @@ -7,3 +7,4 @@ export function getDefaultAdapter(): PlatformAdapter { export type { PlatformAdapter } from './types' export type { RequestOptions, RequestTask, StorageOptions } from './types' +export { markInternalRequest } from './internalRequest' diff --git a/packages/miniprogram-platform/src/platform/types.ts b/packages/miniprogram-platform/src/platform/types.ts index 1e4a408..dc5edca 100644 --- a/packages/miniprogram-platform/src/platform/types.ts +++ b/packages/miniprogram-platform/src/platform/types.ts @@ -4,7 +4,7 @@ export interface RequestOptions { data?: unknown header?: Record timeout?: number - success?: (res: { statusCode: number; data?: unknown }) => void + success?: (res: { statusCode: number; data?: unknown; header?: Record }) => void fail?: (error: { errMsg: string }) => void complete?: () => void } diff --git a/packages/miniprogram-rum/src/boot/startRum.ts b/packages/miniprogram-rum/src/boot/startRum.ts index e3e10e4..9147de6 100644 --- a/packages/miniprogram-rum/src/boot/startRum.ts +++ b/packages/miniprogram-rum/src/boot/startRum.ts @@ -15,6 +15,7 @@ import { startRumBatch } from '../transport/startRumBatch' import { LifeCycleEventType } from '../domain/lifeCycle' import { generateUUID } from '@flashcatcloud/miniprogram-core' import type { PageCollection } from '../domain/page/pageCollection' +import { createRemoteConfigurationController } from '../domain/configuration/remoteConfiguration' const noopPageCollection: PageCollection = { stop: () => undefined, @@ -26,8 +27,13 @@ const noopPageCollection: PageCollection = { export function startRum(configuration: RumConfiguration, adapter: PlatformAdapter) { const lifeCycle = new LifeCycle() - const sessionManager = startRumSessionManager(adapter, configuration) - if (!sessionManager.findTrackedSession()) { + const remoteConfigurationController = createRemoteConfigurationController(adapter, configuration) + const sessionManager = startRumSessionManager( + adapter, + configuration, + remoteConfigurationController.getSessionConfiguration, + ) + if (!sessionManager.findSession()) { sessionManager.renew() } @@ -131,6 +137,11 @@ export function startRum(configuration: RumConfiguration, adapter: PlatformAdapt const rumBatch = startRumBatch(configuration, lifeCycle, adapter, appObservable) + // Fetch on the next microtask so public initialization can complete first. + // The request is marked as internal and never blocks event collection. + const appliedVersion = sessionManager.findSession()?.rcVersion + void Promise.resolve().then(() => remoteConfigurationController.fetch(appliedVersion)) + return { lifeCycle, sessionManager, @@ -170,6 +181,7 @@ export function startRum(configuration: RumConfiguration, adapter: PlatformAdapt stopRequestObservable() rumBatch.stop() rumAssembly.stop() + remoteConfigurationController.stop() requestCollection?.stop() actionCollection?.stop() performanceCollection?.stop() diff --git a/packages/miniprogram-rum/src/domain/assembly.ts b/packages/miniprogram-rum/src/domain/assembly.ts index 748647e..6044218 100644 --- a/packages/miniprogram-rum/src/domain/assembly.ts +++ b/packages/miniprogram-rum/src/domain/assembly.ts @@ -125,7 +125,7 @@ export function startRumAssembly({ // Current view events keep the existing renewal boundary behavior: a missing current session creates a new view. // Historical view updates and non-view events use event time so delayed work stays aligned with its original context. const shouldUseEventTimeForSession = rawEvent.type !== 'view' || (rawView?.id && rawView.id !== currentPage?.id) - let session = sessionManager.findTrackedSession(shouldUseEventTimeForSession ? eventTime : undefined) + let session = sessionManager.findSession(shouldUseEventTimeForSession ? eventTime : undefined) if (!session) { session = sessionManager.renew() if (session.isTracked === false) { @@ -137,6 +137,11 @@ export function startRumAssembly({ } } sessionManager.expand() + // A sampled-out session is still a valid session. Keep it alive until it + // expires, but never emit its events or perform another sampling draw. + if (session.isTracked === false) { + return + } const page = findPage?.(eventTime) || currentPage const pageName = page?.name || 'unknown' const usr = userContext.getContext() @@ -147,8 +152,22 @@ export function startRumAssembly({ usr.anonymous_id = session.anonymousId } + const lockedRawEvent: RawRumEvent = rawEvent.type === 'view' + ? { + ...rawEvent, + _dd: { + ...rawEvent._dd, + configuration: { + ...rawEvent._dd.configuration, + session_sample_rate: session.sessionSampleRate ?? configuration.sessionSampleRate, + rc_version: session.rcVersion ?? 0, + }, + }, + } + : rawEvent + const rumEvent: RumEvent = { - ...rawEvent, + ...lockedRawEvent, service: configuration.service, version: configuration.version, application: { id: configuration.applicationId }, diff --git a/packages/miniprogram-rum/src/domain/configuration/configuration.ts b/packages/miniprogram-rum/src/domain/configuration/configuration.ts index 03a664d..d4e6a2c 100644 --- a/packages/miniprogram-rum/src/domain/configuration/configuration.ts +++ b/packages/miniprogram-rum/src/domain/configuration/configuration.ts @@ -1,4 +1,4 @@ -import type { Configuration, InitConfiguration, TraceContext } from '@flashcatcloud/miniprogram-core' +import type { Configuration, InitConfiguration, ProxyFn, TraceContext } from '@flashcatcloud/miniprogram-core' import { validateAndBuildConfiguration } from '@flashcatcloud/miniprogram-core' export interface RumInitConfiguration extends InitConfiguration { @@ -58,6 +58,10 @@ export interface RumConfiguration extends Configuration { rootTraceContext?: TraceContext headerName: string } + remoteConfigurationSource: { + proxy?: string | ProxyFn + site?: string + } } export function validateAndBuildRumConfiguration( @@ -86,6 +90,10 @@ export function validateAndBuildRumConfiguration( rootTraceContext: initConfiguration.tracing?.rootTraceContext, headerName: initConfiguration.tracing?.headerName ?? 'traceparent', }, + remoteConfigurationSource: { + proxy: initConfiguration.proxy, + site: initConfiguration.site, + }, } } diff --git a/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts b/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts new file mode 100644 index 0000000..f577940 --- /dev/null +++ b/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts @@ -0,0 +1,441 @@ +import { SDK_VERSION } from '@flashcatcloud/miniprogram-core' +import type { PlatformAdapter, RequestOptions } from '@flashcatcloud/miniprogram-platform' +import { markInternalRequest } from '@flashcatcloud/miniprogram-platform' +import type { RumConfiguration } from './configuration' + +const CONFIG_PATH = '/api/v2/rum/config' +const CACHE_FORMAT_VERSION = 1 +const REQUEST_TIMEOUT = 10_000 +const RETRY_DELAYS = [5_000, 60_000] + +export const REMOTE_CONFIGURATION_STORAGE_KEY_PREFIX = '_fc_rum_remote_config_v1_' +const REMOTE_CONFIGURATION_INDEX_KEY_PREFIX = '_fc_rum_remote_config_index_v1_' + +export interface SessionConfigurationSnapshot { + sessionSampleRate: number + rcVersion: number +} + +interface CachedRemoteConfiguration { + formatVersion: 1 + snapshot: SessionConfigurationSnapshot + etag?: string +} + +interface RemoteConfigurationDependencies { + random?: () => number + setTimeout?: (callback: () => void, delay: number) => unknown + clearTimeout?: (timer: unknown) => void +} + +export interface RemoteConfigurationController { + getSessionConfiguration: () => SessionConfigurationSnapshot + fetch: (appliedVersion?: number) => void + stop: () => void +} + +/** + * Owns the complete remote configuration lifecycle. Every public operation is + * exception-isolated so configuration delivery can never stop RUM collection. + */ +export function createRemoteConfigurationController( + adapter: PlatformAdapter, + configuration: RumConfiguration, + dependencies: RemoteConfigurationDependencies = {}, +): RemoteConfigurationController { + const initialSnapshot: SessionConfigurationSnapshot = { + sessionSampleRate: configuration.sessionSampleRate, + rcVersion: 0, + } + + if (!configuration.remoteConfiguration) { + return { + getSessionConfiguration: () => initialSnapshot, + fetch: () => undefined, + stop: () => undefined, + } + } + + const random = dependencies.random || Math.random + const scheduleTimeout = dependencies.setTimeout || ((callback, delay) => setTimeout(callback, delay)) + const cancelTimeout = dependencies.clearTimeout || ((timer) => clearTimeout(timer as ReturnType)) + let currentSnapshot = initialSnapshot + let etag: string | undefined + let hasRemoteSnapshot = false + let stopped = false + const retryTimers = new Set() + + const endpoint = safelyCreateEndpoint(configuration) + if (!endpoint) { + return { + getSessionConfiguration: () => currentSnapshot, + fetch: () => undefined, + stop: () => { + stopped = true + }, + } + } + const activeEndpoint = endpoint + + const cacheKey = buildCacheKey(activeEndpoint.identity, configuration) + const cacheIndexKey = buildCacheIndexKey(activeEndpoint.identity, configuration) + registerCacheKey() + readCache() + + function registerCacheKey() { + try { + const previousCacheKey = adapter.getStorageSync(cacheIndexKey) + if ( + typeof previousCacheKey === 'string' && + previousCacheKey !== cacheKey && + previousCacheKey.startsWith(REMOTE_CONFIGURATION_STORAGE_KEY_PREFIX) + ) { + adapter.removeStorageSync(previousCacheKey) + } + adapter.setStorageSync(cacheIndexKey, cacheKey) + } catch { + // Cache cleanup is best effort and must not affect configuration loading. + } + } + + function clearCache() { + try { + adapter.removeStorageSync(cacheKey) + } catch { + // Storage is an optimization only. + } + } + + function readCache() { + try { + const stored = adapter.getStorageSync(cacheKey) + if (stored === undefined || stored === null || stored === '') { + return + } + const parsed = typeof stored === 'string' ? JSON.parse(stored) : stored + if (!isCachedRemoteConfiguration(parsed)) { + clearCache() + return + } + currentSnapshot = parsed.snapshot + etag = parsed.etag + hasRemoteSnapshot = true + } catch { + clearCache() + } + } + + function persist(snapshot: SessionConfigurationSnapshot, nextEtag?: string) { + const cached: CachedRemoteConfiguration = { + formatVersion: CACHE_FORMAT_VERSION, + snapshot, + ...(nextEtag ? { etag: nextEtag } : {}), + } + try { + // Snapshot and ETag are written together so readers never observe a + // configuration paired with an ETag from another response. + adapter.setStorageSync(cacheKey, JSON.stringify(cached)) + } catch { + // The in-memory snapshot remains active even if persistence fails. + } + } + + function resetToInitialization() { + currentSnapshot = initialSnapshot + etag = undefined + hasRemoteSnapshot = false + clearCache() + try { + adapter.removeStorageSync(cacheIndexKey) + } catch { + // Storage is an optimization only. + } + } + + function scheduleRetry(appliedVersion: number | undefined, retryIndex: number) { + if (stopped || retryIndex >= RETRY_DELAYS.length) { + return + } + const jitter = 0.8 + random() * 0.4 + let timer: unknown + try { + timer = scheduleTimeout(() => { + retryTimers.delete(timer) + request(appliedVersion, retryIndex + 1) + }, Math.round(RETRY_DELAYS[retryIndex] * jitter)) + retryTimers.add(timer) + } catch { + // Timer failures are isolated like request and storage failures. + } + } + + function request(appliedVersion: number | undefined, retryIndex: number) { + if (stopped) { + return + } + + let settled = false + const settle = (callback: () => void) => { + if (settled || stopped) { + return + } + settled = true + try { + callback() + } catch { + scheduleRetry(appliedVersion, retryIndex) + } + } + + let url: string + try { + url = activeEndpoint.build(appliedVersion) + } catch { + scheduleRetry(appliedVersion, retryIndex) + return + } + + const options = markInternalRequest({ + url, + method: 'GET', + timeout: REQUEST_TIMEOUT, + ...(etag ? { header: { 'If-None-Match': etag } } : {}), + success: (response) => settle(() => { + const { statusCode } = response + if (statusCode === 304) { + if (!hasRemoteSnapshot) { + scheduleRetry(appliedVersion, retryIndex) + } + return + } + if (statusCode === 200) { + const parsed = parseResponse(response.data, configuration.sessionSampleRate) + if (!parsed) { + scheduleRetry(appliedVersion, retryIndex) + return + } + const nextEtag = findHeader(response.header, 'etag') + if (configuration.debug) { + try { + console.log('[FlashCat RUM][Debug] Remote configuration fetched', { + response: response.data, + applied: parsed.enabled === false ? initialSnapshot : parsed.snapshot, + etag: nextEtag, + }) + } catch { + // Console implementations are host code and must not affect config activation. + } + } + if (parsed.enabled === false) { + resetToInitialization() + return + } + currentSnapshot = parsed.snapshot + etag = nextEtag + hasRemoteSnapshot = true + persist(parsed.snapshot, nextEtag) + return + } + if (statusCode === 0 || statusCode === 429 || statusCode >= 500) { + scheduleRetry(appliedVersion, retryIndex) + } + }), + fail: () => settle(() => scheduleRetry(appliedVersion, retryIndex)), + }) + + try { + adapter.request(options) + } catch { + settle(() => scheduleRetry(appliedVersion, retryIndex)) + } + } + + return { + getSessionConfiguration: () => currentSnapshot, + fetch: (appliedVersion) => { + try { + request(appliedVersion, 0) + } catch { + // URL builders and platform adapters are host code and may throw. + } + }, + stop: () => { + stopped = true + retryTimers.forEach((timer) => { + try { + cancelTimeout(timer) + } catch { + // Ignore timer implementation failures. + } + }) + retryTimers.clear() + }, + } +} + +function parseResponse( + data: unknown, + initialSessionSampleRate: number, +): { enabled: false } | { enabled: true; snapshot: SessionConfigurationSnapshot } | undefined { + let value: unknown = data + if (typeof value === 'string') { + try { + value = JSON.parse(value) + } catch { + return undefined + } + } + if (!isRecord(value)) { + return undefined + } + if ('schema_version' in value && value.schema_version !== 1) { + return undefined + } + if ('enabled' in value && typeof value.enabled !== 'boolean') { + return undefined + } + if (value.enabled === false) { + return { enabled: false } + } + if ('version' in value && !isRemoteVersion(value.version)) { + return undefined + } + if ('rum' in value && !isRecord(value.rum)) { + return undefined + } + + let sessionSampleRate = initialSessionSampleRate + if (isRecord(value.rum) && 'sessionSampleRate' in value.rum) { + if (!isSampleRate(value.rum.sessionSampleRate)) { + return undefined + } + sessionSampleRate = value.rum.sessionSampleRate + } + + return { + enabled: true, + snapshot: { + sessionSampleRate, + rcVersion: isRemoteVersion(value.version) ? value.version : 0, + }, + } +} + +function isCachedRemoteConfiguration(value: unknown): value is CachedRemoteConfiguration { + if (!isRecord(value) || value.formatVersion !== CACHE_FORMAT_VERSION || !isRecord(value.snapshot)) { + return false + } + return ( + isSampleRate(value.snapshot.sessionSampleRate) && + isRemoteVersion(value.snapshot.rcVersion) && + (value.etag === undefined || typeof value.etag === 'string') + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isSampleRate(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100 +} + +function isRemoteVersion(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value >= 0 +} + +function findHeader(headers: Record | undefined, name: string): string | undefined { + if (!headers) { + return undefined + } + const matchedName = Object.keys(headers).find((header) => header.toLowerCase() === name.toLowerCase()) + return matchedName ? headers[matchedName] : undefined +} + +function safelyCreateEndpoint(configuration: RumConfiguration): { + identity: string + build: (appliedVersion?: number) => string +} | undefined { + try { + const { proxy, site } = configuration.remoteConfigurationSource + let identity: string + if (typeof proxy === 'string') { + identity = `proxy:${normalizeUrl(proxy)}` + } else if (typeof proxy === 'function') { + const resolved = proxy({ path: CONFIG_PATH, parameters: '' }) + if (typeof resolved !== 'string' || !resolved) { + return undefined + } + identity = `proxy-function:${removeUrlParameters(resolved)}` + } else { + identity = `site:${site || 'browser.flashcat.cloud'}` + } + + return { + identity, + build: (appliedVersion) => { + const parameters = buildParameters(configuration, appliedVersion) + if (typeof proxy === 'string') { + return `${normalizeUrl(proxy)}?ddforward=${encodeURIComponent(`${CONFIG_PATH}?${parameters}`)}` + } + if (typeof proxy === 'function') { + return proxy({ path: CONFIG_PATH, parameters }) + } + return `https://${site || 'browser.flashcat.cloud'}${CONFIG_PATH}?${parameters}` + }, + } + } catch { + return undefined + } +} + +function buildParameters(configuration: RumConfiguration, appliedVersion?: number): string { + const parameters = [ + `client_token=${encodeURIComponent(configuration.clientToken)}`, + 'sdk=miniprogram', + `sdk_version=${encodeURIComponent(SDK_VERSION)}`, + `env=${encodeURIComponent(configuration.env || '')}`, + `app_version=${encodeURIComponent(configuration.version || '')}`, + ] + if (isRemoteVersion(appliedVersion) && appliedVersion > 0) { + parameters.push(`applied_version=${appliedVersion}`) + } + return parameters.join('&') +} + +function buildCacheKey(endpointIdentity: string, configuration: RumConfiguration): string { + const dimensions = JSON.stringify([ + endpointIdentity, + configuration.applicationId, + configuration.env || '', + configuration.version || '', + ]) + return `${REMOTE_CONFIGURATION_STORAGE_KEY_PREFIX}${hash(dimensions)}` +} + +function buildCacheIndexKey(endpointIdentity: string, configuration: RumConfiguration): string { + const scope = JSON.stringify([ + endpointIdentity, + configuration.applicationId, + configuration.env || '', + ]) + return `${REMOTE_CONFIGURATION_INDEX_KEY_PREFIX}${hash(scope)}` +} + +function hash(value: string): string { + let result = 2166136261 + for (let index = 0; index < value.length; index += 1) { + result ^= value.charCodeAt(index) + result = Math.imul(result, 16777619) + } + return (result >>> 0).toString(36) +} + +function normalizeUrl(url: string): string { + return url.replace(/\/+$/, '') +} + +function removeUrlParameters(url: string): string { + const separatorIndex = url.search(/[?#]/) + return normalizeUrl(separatorIndex === -1 ? url : url.slice(0, separatorIndex)) +} diff --git a/packages/miniprogram-rum/src/domain/rumSessionManager.ts b/packages/miniprogram-rum/src/domain/rumSessionManager.ts index f49213e..7dfed02 100644 --- a/packages/miniprogram-rum/src/domain/rumSessionManager.ts +++ b/packages/miniprogram-rum/src/domain/rumSessionManager.ts @@ -23,9 +23,14 @@ export function createSessionStore(adapter: PlatformAdapter): SessionStore { } } -export function startRumSessionManager(adapter: PlatformAdapter, configuration: RumConfiguration): SessionManager { +export function startRumSessionManager( + adapter: PlatformAdapter, + configuration: RumConfiguration, + getSessionConfiguration?: () => { sessionSampleRate: number; rcVersion: number }, +): SessionManager { return startSessionManager(createSessionStore(adapter), { trackAnonymousUser: configuration.trackAnonymousUser, sessionSampleRate: configuration.sessionSampleRate, + getSessionConfiguration, }) } diff --git a/packages/miniprogram-rum/src/rawRumEvent.types.ts b/packages/miniprogram-rum/src/rawRumEvent.types.ts index 7193534..65f4437 100644 --- a/packages/miniprogram-rum/src/rawRumEvent.types.ts +++ b/packages/miniprogram-rum/src/rawRumEvent.types.ts @@ -44,6 +44,7 @@ export interface RawRumViewEvent extends RawRumEventBase { page_states?: PageStateServerEntry[] configuration: { session_sample_rate: number + rc_version?: number session_replay_sample_rate: number start_session_replay_recording_manually: boolean } diff --git a/test/assemblyTemporalContext.test.ts b/test/assemblyTemporalContext.test.ts index 5d1a927..b3aa247 100644 --- a/test/assemblyTemporalContext.test.ts +++ b/test/assemblyTemporalContext.test.ts @@ -6,7 +6,7 @@ import { LifeCycle, LifeCycleEventType } from '../packages/miniprogram-rum/src/d import { startPageCollection } from '../packages/miniprogram-rum/src/domain/page/pageCollection' import { startRumAssembly } from '../packages/miniprogram-rum/src/domain/assembly' import type { PageEvent, PlatformAdapter } from '../packages/miniprogram-platform/src' -import type { RawRumErrorEvent, RawRumResourceEvent } from '../packages/miniprogram-rum/src/rawRumEvent.types' +import type { RawRumErrorEvent, RawRumResourceEvent, RawRumViewEvent } from '../packages/miniprogram-rum/src/rawRumEvent.types' import type { RumConfiguration } from '../packages/miniprogram-rum/src/domain/configuration/configuration' import type { RumEvent } from '../packages/miniprogram-rum/src/rumEvent.types' @@ -106,6 +106,102 @@ test('assembly adds configured service and version to rum events', () => { } }) +test('assembly keeps a sampled-out session without notifying renewal or drawing again', () => { + const originalNow = Date.now + Date.now = () => 1_000 + const lifeCycle = new LifeCycle() + let configurationReads = 0 + const sessionManager = startSessionManager(createStore(), { + getSessionConfiguration: () => { + configurationReads += 1 + return { sessionSampleRate: 0, rcVersion: 4 } + }, + }) + let renewals = 0 + const collected: RumEvent[] = [] + const assembly = startRumAssembly({ + lifeCycle, + configuration, + sessionManager, + globalContext: createContextManager(), + userContext: createContextManager(), + getCurrentPage: () => undefined, + adapter, + }) + lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { + renewals += 1 + }) + lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event) => collected.push(event)) + + try { + for (const name of ['first', 'second']) { + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + date: 1_000, + type: 'custom', + event: { id: name, name }, + }) + } + + assert.equal(sessionManager.findSession()?.isTracked, false) + assert.equal(configurationReads, 1) + assert.equal(renewals, 0) + assert.equal(collected.length, 0) + } finally { + assembly.stop() + Date.now = originalNow + } +}) + +test('view configuration uses the sampling rate and remote version locked to its session', () => { + const originalGetCurrentPages = (globalThis as any).getCurrentPages + ;(globalThis as any).getCurrentPages = () => [{ route: 'pages/a/index' }] + + const lifeCycle = new LifeCycle() + const sessionManager = startSessionManager(createStore(), { + getSessionConfiguration: () => ({ sessionSampleRate: 100, rcVersion: 7 }), + }) + const session = sessionManager.renew() + session.sessionSampleRate = 42 + const collected: RumEvent[] = [] + const assembly = startRumAssembly({ + lifeCycle, + configuration, + sessionManager, + globalContext: createContextManager(), + userContext: createContextManager(), + getCurrentPage: () => ({ id: 'view-id', name: 'pages/a/index', startTime: session.created }), + adapter, + }) + lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event) => collected.push(event)) + + try { + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, { + date: session.created, + type: 'view', + _dd: { + document_version: 1, + format_version: 2, + configuration: { + session_sample_rate: 100, + session_replay_sample_rate: 0, + start_session_replay_recording_manually: false, + }, + }, + view: { id: 'view-id', url: 'pages/a/index', name: 'pages/a/index' }, + } as RawRumViewEvent) + + const viewEvent = collected[0] + assert.equal(viewEvent.type, 'view') + if (viewEvent.type === 'view') { + assert.equal(viewEvent._dd.configuration.session_sample_rate, 42) + assert.equal(viewEvent._dd.configuration.rc_version, 7) + } + } finally { + assembly.stop() + ;(globalThis as any).getCurrentPages = originalGetCurrentPages + } +}) + test('assembly assigns async resource to the page active at resource start time', () => { const originalNow = Date.now const originalGetCurrentPages = (globalThis as any).getCurrentPages diff --git a/test/configuration.test.ts b/test/configuration.test.ts index 4da0c5b..045af39 100644 --- a/test/configuration.test.ts +++ b/test/configuration.test.ts @@ -130,3 +130,21 @@ test('validateAndBuildConfiguration creates endpointBuilder', () => { assert.ok(result.endpointBuilder) assert.ok(result.endpointBuilder.urlPrefix) }) + +test('validateAndBuildConfiguration defaults remoteConfiguration to false', () => { + const result = validateAndBuildConfiguration({ clientToken: 'token', applicationId: 'app' }) + + assert.ok(result) + assert.equal(result.remoteConfiguration, false) +}) + +test('validateAndBuildConfiguration enables remoteConfiguration explicitly', () => { + const result = validateAndBuildConfiguration({ + clientToken: 'token', + applicationId: 'app', + remoteConfiguration: true, + }) + + assert.ok(result) + assert.equal(result.remoteConfiguration, true) +}) diff --git a/test/remoteConfiguration.test.ts b/test/remoteConfiguration.test.ts new file mode 100644 index 0000000..e5dc578 --- /dev/null +++ b/test/remoteConfiguration.test.ts @@ -0,0 +1,424 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import type { + DownloadFileOptions, + PlatformAdapter, + RequestOptions, + UploadFileOptions, +} from '../packages/miniprogram-platform/src/platform/types' +import { isInternalRequest } from '../packages/miniprogram-platform/src/platform/internalRequest' +import { validateAndBuildRumConfiguration } from '../packages/miniprogram-rum/src/domain/configuration/configuration' +import { + createRemoteConfigurationController, + REMOTE_CONFIGURATION_STORAGE_KEY_PREFIX, +} from '../packages/miniprogram-rum/src/domain/configuration/remoteConfiguration' + +type TestAdapter = PlatformAdapter & { + requests: RequestOptions[] + storage: Map + storageReads: number +} + +function createAdapter(onRequest?: (options: RequestOptions) => void, storage = new Map()): TestAdapter { + const adapter: TestAdapter = { + requests: [], + storage, + storageReads: 0, + request: (options) => { + adapter.requests.push(options) + onRequest?.(options) + return { abort: () => undefined } + }, + uploadFile: (options: UploadFileOptions) => { + options.success?.({ statusCode: 200, data: 'ok' }) + return { abort: () => undefined } + }, + downloadFile: (options: DownloadFileOptions) => { + options.success?.({ statusCode: 200, tempFilePath: '/tmp/file' }) + return { abort: () => undefined } + }, + setStorageSync: (key, value) => storage.set(key, value), + getStorageSync: (key) => { + adapter.storageReads += 1 + return storage.get(key) + }, + removeStorageSync: (key) => storage.delete(key), + getSystemInfoSync: () => ({}), + getNetworkType: ({ success }) => success({ networkType: 'wifi' }), + onNetworkStatusChange: () => undefined, + onAppShow: () => undefined, + onAppHide: () => undefined, + onError: () => undefined, + onUnhandledRejection: () => undefined, + onPageNotFound: () => undefined, + onLazyLoadError: () => undefined, + } + return adapter +} + +function configuration(overrides: Record = {}) { + return validateAndBuildRumConfiguration({ + clientToken: 'token value', + applicationId: 'app-id', + sessionSampleRate: 73, + remoteConfiguration: true, + env: 'prod cn', + version: '1.2.3', + ...overrides, + })! +} + +test('remote configuration accepts compatible schemas and only consumes sessionSampleRate', () => { + const cases = [ + { + name: 'missing schema', + data: { version: 2, enabled: true, rum: { sessionSampleRate: 21 } }, + expected: { sessionSampleRate: 21, rcVersion: 2 }, + }, + { + name: 'schema v1 with unknown keys', + data: { + schema_version: 1, + version: 3, + enabled: true, + rum: { sessionSampleRate: 22, traceSampleRate: 0, privacyLevel: 'mask' }, + custom: { anything: true }, + }, + expected: { sessionSampleRate: 22, rcVersion: 3 }, + }, + { + name: 'missing sampling field', + data: { schema_version: 1, version: 4, enabled: true, rum: { traceSampleRate: 0 } }, + expected: { sessionSampleRate: 73, rcVersion: 4 }, + }, + ] + + for (const scenario of cases) { + const adapter = createAdapter((options) => options.success?.({ statusCode: 200, data: scenario.data })) + const controller = createRemoteConfigurationController(adapter, configuration()) + controller.fetch() + assert.deepEqual(controller.getSessionConfiguration(), scenario.expected, scenario.name) + controller.stop() + } +}) + +test('remote configuration logs fetched and applied values when debug is enabled', () => { + const data = { + schema_version: 1, + version: 6, + enabled: true, + rum: { sessionSampleRate: 35, traceSampleRate: 10 }, + } + const adapter = createAdapter((options) => options.success?.({ + statusCode: 200, + data, + header: { ETag: '"config-6"' }, + })) + const debugConfiguration = configuration() + debugConfiguration.debug = true + const originalLog = console.log + const logs: unknown[][] = [] + console.log = (...args: unknown[]) => logs.push(args) + + try { + const controller = createRemoteConfigurationController(adapter, debugConfiguration) + controller.fetch() + + assert.deepEqual(logs, [[ + '[FlashCat RUM][Debug] Remote configuration fetched', + { + response: data, + applied: { sessionSampleRate: 35, rcVersion: 6 }, + etag: '"config-6"', + }, + ]]) + controller.stop() + } finally { + console.log = originalLog + } +}) + +test('remote configuration rejects incompatible or malformed snapshots as a whole', () => { + const invalidResponses = [ + { schema_version: 2, version: 2, enabled: true, rum: { sessionSampleRate: 10 } }, + { schema_version: '1', version: 2, enabled: true, rum: { sessionSampleRate: 10 } }, + { version: 2, enabled: true, rum: { sessionSampleRate: -1 } }, + { version: 2, enabled: true, rum: { sessionSampleRate: 101 } }, + { version: 2, enabled: true, rum: { sessionSampleRate: Number.NaN } }, + { version: 2, enabled: true, rum: { sessionSampleRate: Number.POSITIVE_INFINITY } }, + { version: 2, enabled: true, rum: { sessionSampleRate: '10' } }, + { version: '2', enabled: true, rum: { sessionSampleRate: 10 } }, + '{broken json', + ] + + for (const data of invalidResponses) { + const adapter = createAdapter((options) => options.success?.({ statusCode: 200, data })) + const controller = createRemoteConfigurationController(adapter, configuration(), { + setTimeout: () => 1, + }) + controller.fetch() + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0 }) + controller.stop() + } +}) + +test('enabled false clears cache and falls back to the initialization rate', () => { + const storage = new Map() + let response: unknown = { version: 7, enabled: true, rum: { sessionSampleRate: 12 } } + const adapter = createAdapter((options) => options.success?.({ statusCode: 200, data: response }), storage) + const controller = createRemoteConfigurationController(adapter, configuration()) + controller.fetch() + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 12, rcVersion: 7 }) + assert.equal(storage.size, 2) + + response = { version: 8, enabled: false, rum: { sessionSampleRate: 0 } } + controller.fetch(7) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0 }) + assert.equal(storage.size, 0) + controller.stop() +}) + +test('cache is loaded synchronously with ETag and 304 preserves the snapshot', () => { + const storage = new Map() + const firstAdapter = createAdapter((options) => options.success?.({ + statusCode: 200, + data: { version: 9, enabled: true, rum: { sessionSampleRate: 31 } }, + header: { ETag: '"config-9"' }, + }), storage) + const first = createRemoteConfigurationController(firstAdapter, configuration()) + first.fetch() + first.stop() + + const secondAdapter = createAdapter((options) => options.success?.({ statusCode: 304 }), storage) + const second = createRemoteConfigurationController(secondAdapter, configuration()) + assert.deepEqual(second.getSessionConfiguration(), { sessionSampleRate: 31, rcVersion: 9 }) + second.fetch(9) + assert.equal(secondAdapter.requests[0].header?.['If-None-Match'], '"config-9"') + assert.deepEqual(second.getSessionConfiguration(), { sessionSampleRate: 31, rcVersion: 9 }) + second.stop() +}) + +test('cache dimensions isolate endpoint, application, env and app version', () => { + const storage = new Map() + const sourceAdapter = createAdapter((options) => options.success?.({ + statusCode: 200, + data: { version: 5, enabled: true, rum: { sessionSampleRate: 25 } }, + }), storage) + const source = createRemoteConfigurationController(sourceAdapter, configuration()) + source.fetch() + source.stop() + + const variants = [ + { site: 'other.flashcat.cloud' }, + { applicationId: 'other-app' }, + { env: 'staging' }, + { version: '2.0.0' }, + ] + for (const variant of variants) { + const controller = createRemoteConfigurationController(createAdapter(undefined, storage), configuration(variant)) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0 }) + controller.stop() + } +}) + +test('cache cleanup removes the previous app version and ignores dynamic function proxy parameters', () => { + const storage = new Map() + let proxyCall = 0 + const proxy = ({ path, parameters }: { path: string; parameters: string }) => { + proxyCall += 1 + return `https://proxy.example.com${path}?signature=${proxyCall}&${parameters}#runtime` + } + const firstAdapter = createAdapter((options) => options.success?.({ + statusCode: 200, + data: { version: 5, enabled: true, rum: { sessionSampleRate: 25 } }, + }), storage) + const first = createRemoteConfigurationController(firstAdapter, configuration({ proxy })) + first.fetch() + first.stop() + + const firstCacheKey = [...storage.keys()].find((key) => key.startsWith(REMOTE_CONFIGURATION_STORAGE_KEY_PREFIX)) + assert.ok(firstCacheKey) + + const secondAdapter = createAdapter((options) => options.success?.({ + statusCode: 200, + data: { version: 6, enabled: true, rum: { sessionSampleRate: 30 } }, + }), storage) + const second = createRemoteConfigurationController(secondAdapter, configuration({ proxy, version: '2.0.0' })) + + assert.equal(storage.has(firstCacheKey), false) + assert.deepEqual(second.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0 }) + second.fetch() + assert.deepEqual(second.getSessionConfiguration(), { sessionSampleRate: 30, rcVersion: 6 }) + assert.equal( + [...storage.keys()].filter((key) => key.startsWith(REMOTE_CONFIGURATION_STORAGE_KEY_PREFIX)).length, + 1, + ) + second.stop() +}) + +test('corrupt or incompatible cache is removed and storage failures are isolated', () => { + const storage = new Map() + const seedAdapter = createAdapter((options) => options.success?.({ + statusCode: 200, + data: { version: 1, enabled: true, rum: { sessionSampleRate: 20 } }, + }), storage) + const seed = createRemoteConfigurationController(seedAdapter, configuration()) + seed.fetch() + seed.stop() + const cacheKey = [...storage.keys()].find((key) => key.startsWith(REMOTE_CONFIGURATION_STORAGE_KEY_PREFIX))! + assert.ok(cacheKey) + storage.set(cacheKey, '{not json') + + const controller = createRemoteConfigurationController(createAdapter(undefined, storage), configuration()) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0 }) + assert.equal(storage.has(cacheKey), false) + controller.stop() + + const throwingAdapter = createAdapter((options) => options.success?.({ + statusCode: 200, + data: { version: 2, enabled: true, rum: { sessionSampleRate: 19 } }, + })) + throwingAdapter.getStorageSync = () => { throw new Error('read failed') } + throwingAdapter.setStorageSync = () => { throw new Error('write failed') } + throwingAdapter.removeStorageSync = () => { throw new Error('remove failed') } + const storageFailure = createRemoteConfigurationController(throwingAdapter, configuration()) + assert.doesNotThrow(() => storageFailure.fetch()) + assert.deepEqual(storageFailure.getSessionConfiguration(), { sessionSampleRate: 19, rcVersion: 2 }) + storageFailure.stop() +}) + +test('disabled remote configuration performs no cache access and no request', () => { + const adapter = createAdapter() + const controller = createRemoteConfigurationController(adapter, configuration({ remoteConfiguration: false })) + controller.fetch(3) + + assert.equal(adapter.storageReads, 0) + assert.equal(adapter.requests.length, 0) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0 }) +}) + +test('direct request contains complete parameters, applied version and internal marker', () => { + const adapter = createAdapter((options) => options.success?.({ + statusCode: 200, + data: { version: 12, enabled: true, rum: { sessionSampleRate: 40 } }, + })) + const controller = createRemoteConfigurationController(adapter, configuration({ site: 'rum.example.com' })) + controller.fetch(11) + + const request = adapter.requests[0] + const url = new URL(request.url) + assert.equal(url.origin + url.pathname, 'https://rum.example.com/api/v2/rum/config') + assert.equal(url.searchParams.get('client_token'), 'token value') + assert.equal(url.searchParams.get('sdk'), 'miniprogram') + assert.ok(url.searchParams.get('sdk_version')) + assert.equal(url.searchParams.get('env'), 'prod cn') + assert.equal(url.searchParams.get('app_version'), '1.2.3') + assert.equal(url.searchParams.get('applied_version'), '11') + assert.equal(request.method, 'GET') + assert.equal(request.timeout, 10_000) + assert.equal(isInternalRequest(request), true) + controller.stop() +}) + +test('string and function proxies receive the configuration path and parameters', () => { + const stringAdapter = createAdapter((options) => options.success?.({ statusCode: 304 })) + const stringController = createRemoteConfigurationController( + stringAdapter, + configuration({ proxy: 'https://proxy.example.com/rum/' }), + { setTimeout: () => 1 }, + ) + stringController.fetch(6) + const proxyUrl = new URL(stringAdapter.requests[0].url) + const forwarded = decodeURIComponent(proxyUrl.searchParams.get('ddforward')!) + assert.ok(forwarded.startsWith('/api/v2/rum/config?')) + assert.ok(forwarded.includes('sdk=miniprogram')) + assert.ok(forwarded.includes('applied_version=6')) + stringController.stop() + + const calls: Array<{ path: string; parameters: string }> = [] + const functionAdapter = createAdapter((options) => options.success?.({ + statusCode: 200, + data: { version: 7, enabled: true, rum: { sessionSampleRate: 30 } }, + })) + const functionController = createRemoteConfigurationController(functionAdapter, configuration({ + proxy: (options: { path: string; parameters: string }) => { + calls.push(options) + return `https://function-proxy.example.com${options.path}?${options.parameters}` + }, + })) + functionController.fetch() + assert.equal(calls.at(-1)?.path, '/api/v2/rum/config') + assert.ok(calls.at(-1)?.parameters.includes('client_token=token%20value')) + functionController.stop() +}) + +test('retryable failures use 5s and 60s jittered delays and stop after two retries', () => { + const pendingRequests: RequestOptions[] = [] + const scheduled: Array<{ callback: () => void; delay: number }> = [] + const adapter = createAdapter((options) => pendingRequests.push(options)) + const controller = createRemoteConfigurationController(adapter, configuration(), { + random: () => 0.5, + setTimeout: (callback, delay) => { + scheduled.push({ callback, delay }) + return scheduled.length + }, + }) + + controller.fetch() + pendingRequests.shift()!.success?.({ statusCode: 500 }) + assert.equal(scheduled[0].delay, 5_000) + scheduled.shift()!.callback() + pendingRequests.shift()!.success?.({ statusCode: 429 }) + assert.equal(scheduled[0].delay, 60_000) + scheduled.shift()!.callback() + pendingRequests.shift()!.fail?.({ errMsg: 'timeout' }) + + assert.equal(adapter.requests.length, 3) + assert.equal(scheduled.length, 0) + controller.stop() +}) + +test('stop prevents an already queued retry from sending another request', () => { + const scheduled: Array<() => void> = [] + const adapter = createAdapter((options) => options.success?.({ statusCode: 500 })) + const controller = createRemoteConfigurationController(adapter, configuration(), { + setTimeout: (callback) => { + scheduled.push(callback) + return scheduled.length + }, + }) + + controller.fetch() + assert.equal(adapter.requests.length, 1) + assert.equal(scheduled.length, 1) + + controller.stop() + scheduled[0]() + assert.equal(adapter.requests.length, 1) +}) + +test('401, 403 and other 4xx do not retry while malformed callbacks schedule once', () => { + for (const statusCode of [400, 401, 403, 404]) { + const scheduled: number[] = [] + const adapter = createAdapter((options) => options.success?.({ statusCode })) + const controller = createRemoteConfigurationController(adapter, configuration(), { + setTimeout: (_callback, delay) => scheduled.push(delay), + }) + controller.fetch() + assert.equal(scheduled.length, 0, `status ${statusCode}`) + controller.stop() + } + + const scheduled: number[] = [] + const adapter = createAdapter((options) => { + options.success?.({ statusCode: 200, data: '{bad' }) + options.fail?.({ errMsg: 'late failure' }) + }) + const controller = createRemoteConfigurationController(adapter, configuration(), { + random: () => 0.5, + setTimeout: (_callback, delay) => scheduled.push(delay), + }) + assert.doesNotThrow(() => controller.fetch()) + assert.deepEqual(scheduled, [5_000]) + controller.stop() +}) diff --git a/test/sessionManager.test.ts b/test/sessionManager.test.ts index 12ed218..ea3db2e 100644 --- a/test/sessionManager.test.ts +++ b/test/sessionManager.test.ts @@ -54,6 +54,92 @@ test('sessionManager findTrackedSession returns undefined for sampled-out sessio assert.equal(found, undefined) }) +test('sessionManager keeps sampled-out session valid without drawing again', () => { + const store = createMockStore() + const originalRandom = Math.random + let configurationReads = 0 + Math.random = () => 0.75 + + try { + const manager = startSessionManager(store, { + getSessionConfiguration: () => { + configurationReads += 1 + return { sessionSampleRate: 50, rcVersion: 1 } + }, + }) + const created = manager.renew() + + assert.equal(created.isTracked, false) + assert.equal(manager.findSession()?.id, created.id) + assert.equal(manager.findSession()?.id, created.id) + assert.equal(manager.findTrackedSession(), undefined) + assert.equal(configurationReads, 1) + } finally { + Math.random = originalRandom + } +}) + +test('sessionManager locks rate and remote version once per new session', () => { + const store = createMockStore() + let snapshot = { sessionSampleRate: 100, rcVersion: 3 } + const manager = startSessionManager(store, { getSessionConfiguration: () => snapshot }) + + const first = manager.renew() + snapshot = { sessionSampleRate: 0, rcVersion: 4 } + + assert.equal(manager.findSession()?.sessionSampleRate, 100) + assert.equal(manager.findSession()?.rcVersion, 3) + + manager.expire() + const second = manager.renew() + assert.equal(second.sessionSampleRate, 0) + assert.equal(second.rcVersion, 4) + assert.equal(second.isTracked, false) +}) + +test('sessionManager normalizes legacy session metadata without changing its draw', () => { + const store = createMockStore() + store.set({ + id: 'legacy-session', + created: Date.now(), + expireAt: Date.now() + 60_000, + isTracked: false, + }) + + const manager = startSessionManager(store, { + sessionSampleRate: 67, + getSessionConfiguration: () => ({ sessionSampleRate: 100, rcVersion: 9 }), + }) + + const session = manager.findSession() + assert.equal(session?.id, 'legacy-session') + assert.equal(session?.isTracked, false) + assert.equal(session?.sessionSampleRate, 67) + assert.equal(session?.rcVersion, 0) +}) + +test('sessionManager does not rewrite a current-format initial session', () => { + const stored = { + id: 'current-session', + created: Date.now(), + expireAt: Date.now() + 60_000, + isTracked: true, + sessionSampleRate: 50, + rcVersion: 2, + } + let writes = 0 + const manager = startSessionManager({ + get: () => stored, + set: () => { + writes += 1 + }, + clear: () => undefined, + }) + + assert.equal(manager.findSession()?.id, stored.id) + assert.equal(writes, 0) +}) + test('sessionManager findTrackedSession returns undefined when no session', () => { const store = createMockStore() const manager = startSessionManager(store) diff --git a/test/startRum.test.ts b/test/startRum.test.ts index 25f02f1..0bf621c 100644 --- a/test/startRum.test.ts +++ b/test/startRum.test.ts @@ -6,7 +6,7 @@ import { LifeCycleEventType } from '../packages/miniprogram-rum/src/domain/lifeC import type { PlatformAdapter, RequestOptions, UploadFileOptions, DownloadFileOptions } from '../packages/miniprogram-platform/src/platform/types' function createAdapter(): PlatformAdapter { - let storage: unknown + const storage = new Map() return { request: (options: RequestOptions) => { options.success?.({ statusCode: 200, data: 'ok' }) @@ -23,12 +23,12 @@ function createAdapter(): PlatformAdapter { options.complete?.() return { abort: () => undefined } }, - setStorageSync: (_key, data) => { - storage = data + setStorageSync: (key, data) => { + storage.set(key, data) }, - getStorageSync: () => storage, - removeStorageSync: () => { - storage = undefined + getStorageSync: (key) => storage.get(key), + removeStorageSync: (key) => { + storage.delete(key) }, getSystemInfoSync: () => ({}), getNetworkType: ({ success }: { success: (res: any) => void }) => success({ networkType: 'wifi' }), @@ -173,3 +173,83 @@ test('startRum reports traced resource events with backend-compatible _dd identi assert.equal(resourceEvent._dd.trace_id, traceIdDecimal) assert.equal(resourceEvent._dd.span_id, spanIdDecimal) }) + +test('remote sampling keeps the current session and applies after stopSession', async () => { + const adapter = createAdapter() + const requests: RequestOptions[] = [] + let remoteRequest: RequestOptions | undefined + adapter.request = (options: RequestOptions) => { + requests.push(options) + if (options.url.includes('/api/v2/rum/config')) { + remoteRequest = options + } + return { abort: () => undefined } + } + + const configuration = validateAndBuildRumConfiguration({ + clientToken: 'token', + applicationId: 'app', + sessionSampleRate: 100, + remoteConfiguration: true, + trackPages: false, + trackActions: false, + trackPerformance: false, + flushInterval: 100000, + })! + const started = startRum(configuration, adapter) + const collected: any[] = [] + started.lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event) => collected.push(event)) + + const initialSession = started.sessionManager.findSession()! + assert.equal(initialSession.sessionSampleRate, 100) + assert.equal(initialSession.rcVersion, 0) + + await Promise.resolve() + assert.ok(remoteRequest) + remoteRequest.success?.({ + statusCode: 200, + data: { version: 8, enabled: true, rum: { sessionSampleRate: 0 } }, + }) + assert.equal(collected.some((event) => event.type === 'resource' || event.type === 'error'), false) + + started.addCustomEvent('current-session-still-sampled') + assert.equal(collected.length, 1) + + started.sessionManager.expire() + started.addCustomEvent('next-session-sampled-out') + const nextSession = started.sessionManager.findSession()! + assert.equal(nextSession.sessionSampleRate, 0) + assert.equal(nextSession.rcVersion, 8) + assert.equal(nextSession.isTracked, false) + assert.equal(collected.length, 1) + started.addCustomEvent('same-sampled-out-session') + assert.equal(started.sessionManager.findSession()?.id, nextSession.id) + assert.equal(collected.length, 1) + assert.equal(requests.filter((request) => request.url.includes('/api/v2/rum/config')).length, 1) + + started.stop() +}) + +test('startRum does not request remote configuration when it is disabled', async () => { + const adapter = createAdapter() + const requests: RequestOptions[] = [] + adapter.request = (options: RequestOptions) => { + requests.push(options) + return { abort: () => undefined } + } + const configuration = validateAndBuildRumConfiguration({ + clientToken: 'token', + applicationId: 'app', + remoteConfiguration: false, + trackPages: false, + trackActions: false, + trackPerformance: false, + flushInterval: 100000, + })! + + const started = startRum(configuration, adapter) + await Promise.resolve() + + assert.equal(requests.some((request) => request.url.includes('/api/v2/rum/config')), false) + started.stop() +}) diff --git a/yarn.lock b/yarn.lock index 883f9de..2de60bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21,16 +21,16 @@ resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz" integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg== -"@esbuild/android-arm@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz" - integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ== - "@esbuild/android-arm64@0.27.7": version "0.27.7" resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz" integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ== +"@esbuild/android-arm@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz" + integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ== + "@esbuild/android-x64@0.27.7": version "0.27.7" resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz" @@ -56,16 +56,16 @@ resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz" integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ== -"@esbuild/linux-arm@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz" - integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA== - "@esbuild/linux-arm64@0.27.7": version "0.27.7" resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz" integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A== +"@esbuild/linux-arm@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz" + integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA== + "@esbuild/linux-ia32@0.27.7": version "0.27.7" resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz" @@ -146,23 +146,6 @@ resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz" integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== -"@flashcatcloud/miniprogram-core@0.1.1", "@flashcatcloud/miniprogram-core@file:packages/core": - version "0.1.1" - resolved "file:packages/core" - -"@flashcatcloud/miniprogram-platform@0.1.1", "@flashcatcloud/miniprogram-platform@file:packages/miniprogram-platform": - version "0.1.1" - resolved "file:packages/miniprogram-platform" - dependencies: - "@flashcatcloud/miniprogram-core" "0.1.1" - -"@flashcatcloud/miniprogram-rum@file:packages/miniprogram-rum": - version "0.1.1" - resolved "file:packages/miniprogram-rum" - dependencies: - "@flashcatcloud/miniprogram-core" "0.1.1" - "@flashcatcloud/miniprogram-platform" "0.1.1" - "@istanbuljs/schema@^0.1.2", "@istanbuljs/schema@^0.1.3": version "0.1.6" resolved "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz" @@ -532,7 +515,7 @@ path-scurry@^2.0.2: lru-cache "^11.0.0" minipass "^7.1.2" -"picomatch@^3 || ^4", picomatch@^4.0.4: +picomatch@^4.0.4: version "4.0.4" resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz" integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== From 86a784b76651c17c765791c079928ea85f3b866c Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 25 Aug 2026 23:21:07 -0700 Subject: [PATCH 6/8] refactor(core): name the switch remoteConfigurationEnabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other four SDKs that carry this feature — web, iOS, Android and HarmonyOS — all spell the switch `remoteConfigurationEnabled`. A host writing the same integration twice should not have to remember that one platform spells it differently, and a boolean reads better with the suffix than as a bare noun. Renamed before any release, so no integration has to change. The JSDoc that came with it is translated, since everything git-facing here is English. --- .../core/src/domain/configuration/configuration.ts | 14 ++++++++------ .../domain/configuration/remoteConfiguration.ts | 2 +- test/configuration.test.ts | 10 +++++----- test/remoteConfiguration.test.ts | 4 ++-- test/startRum.test.ts | 4 ++-- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/core/src/domain/configuration/configuration.ts b/packages/core/src/domain/configuration/configuration.ts index 9ea8c0a..0993640 100644 --- a/packages/core/src/domain/configuration/configuration.ts +++ b/packages/core/src/domain/configuration/configuration.ts @@ -29,10 +29,12 @@ export interface InitConfiguration { debug?: boolean // 是否开启调试模式 trackAnonymousUser?: boolean /** - * 是否启用远程配置。默认关闭;关闭时不会读取缓存或发起配置请求。 + * Whether the console may set the sampling configuration remotely. When off — + * the default — nothing is read from the cache and no configuration request is + * ever made. * @default false */ - remoteConfiguration?: boolean + remoteConfigurationEnabled?: boolean } export interface Configuration { @@ -47,7 +49,7 @@ export interface Configuration { version?: string debug: boolean trackAnonymousUser: boolean - remoteConfiguration: boolean + remoteConfigurationEnabled: boolean } export function validateAndBuildConfiguration(initConfiguration: InitConfiguration): Configuration | undefined { @@ -64,7 +66,7 @@ export function validateAndBuildConfiguration(initConfiguration: InitConfigurati const flushInterval = initConfiguration.flushInterval ?? 15000 const debug = initConfiguration.debug ?? false const trackAnonymousUser = initConfiguration.trackAnonymousUser ?? true - const remoteConfiguration = initConfiguration.remoteConfiguration ?? false + const remoteConfigurationEnabled = initConfiguration.remoteConfigurationEnabled ?? false const configurationTags = buildTags(initConfiguration) const endpointBuilder = createEndpointBuilder(initConfiguration, 'rum', configurationTags) @@ -81,7 +83,7 @@ export function validateAndBuildConfiguration(initConfiguration: InitConfigurati version: initConfiguration.version, debug, trackAnonymousUser, - remoteConfiguration, + remoteConfigurationEnabled, } if (debug) { @@ -97,7 +99,7 @@ export function validateAndBuildConfiguration(initConfiguration: InitConfigurati sessionSampleRate: config.sessionSampleRate, flushInterval: `${config.flushInterval}ms`, trackAnonymousUser: config.trackAnonymousUser, - remoteConfiguration: config.remoteConfiguration, + remoteConfigurationEnabled: config.remoteConfigurationEnabled, }) } diff --git a/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts b/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts index f577940..9ede56e 100644 --- a/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts +++ b/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts @@ -48,7 +48,7 @@ export function createRemoteConfigurationController( rcVersion: 0, } - if (!configuration.remoteConfiguration) { + if (!configuration.remoteConfigurationEnabled) { return { getSessionConfiguration: () => initialSnapshot, fetch: () => undefined, diff --git a/test/configuration.test.ts b/test/configuration.test.ts index 045af39..62e3fcb 100644 --- a/test/configuration.test.ts +++ b/test/configuration.test.ts @@ -131,20 +131,20 @@ test('validateAndBuildConfiguration creates endpointBuilder', () => { assert.ok(result.endpointBuilder.urlPrefix) }) -test('validateAndBuildConfiguration defaults remoteConfiguration to false', () => { +test('validateAndBuildConfiguration defaults remoteConfigurationEnabled to false', () => { const result = validateAndBuildConfiguration({ clientToken: 'token', applicationId: 'app' }) assert.ok(result) - assert.equal(result.remoteConfiguration, false) + assert.equal(result.remoteConfigurationEnabled, false) }) -test('validateAndBuildConfiguration enables remoteConfiguration explicitly', () => { +test('validateAndBuildConfiguration enables remoteConfigurationEnabled explicitly', () => { const result = validateAndBuildConfiguration({ clientToken: 'token', applicationId: 'app', - remoteConfiguration: true, + remoteConfigurationEnabled: true, }) assert.ok(result) - assert.equal(result.remoteConfiguration, true) + assert.equal(result.remoteConfigurationEnabled, true) }) diff --git a/test/remoteConfiguration.test.ts b/test/remoteConfiguration.test.ts index e5dc578..2b2c361 100644 --- a/test/remoteConfiguration.test.ts +++ b/test/remoteConfiguration.test.ts @@ -61,7 +61,7 @@ function configuration(overrides: Record = {}) { clientToken: 'token value', applicationId: 'app-id', sessionSampleRate: 73, - remoteConfiguration: true, + remoteConfigurationEnabled: true, env: 'prod cn', version: '1.2.3', ...overrides, @@ -289,7 +289,7 @@ test('corrupt or incompatible cache is removed and storage failures are isolated test('disabled remote configuration performs no cache access and no request', () => { const adapter = createAdapter() - const controller = createRemoteConfigurationController(adapter, configuration({ remoteConfiguration: false })) + const controller = createRemoteConfigurationController(adapter, configuration({ remoteConfigurationEnabled: false })) controller.fetch(3) assert.equal(adapter.storageReads, 0) diff --git a/test/startRum.test.ts b/test/startRum.test.ts index 0bf621c..5dcd738 100644 --- a/test/startRum.test.ts +++ b/test/startRum.test.ts @@ -190,7 +190,7 @@ test('remote sampling keeps the current session and applies after stopSession', clientToken: 'token', applicationId: 'app', sessionSampleRate: 100, - remoteConfiguration: true, + remoteConfigurationEnabled: true, trackPages: false, trackActions: false, trackPerformance: false, @@ -240,7 +240,7 @@ test('startRum does not request remote configuration when it is disabled', async const configuration = validateAndBuildRumConfiguration({ clientToken: 'token', applicationId: 'app', - remoteConfiguration: false, + remoteConfigurationEnabled: false, trackPages: false, trackActions: false, trackPerformance: false, From cca630c0d96e8e5f962ac2b7e7510543c3605b31 Mon Sep 17 00:00:00 2001 From: guguji5 <604821528@qq.com> Date: Thu, 27 Aug 2026 11:38:45 +0800 Subject: [PATCH 7/8] feat(rum): add remote custom config, forced session and beforeSampling Extend remote configuration beyond session sampling so hosts can drive their own decisions and force collection during support flows: - parse the top-level `custom` and cache it atomically with the sampling snapshot and ETag, exposed via `getRemoteConfig()` - add `setForcedSession()` to mark the next new Session as collected without touching the current one or changing sample rates - add the `beforeSampling` callback to adjust the rate of the Session being created, with safe fallback on invalid results and thrown errors Also export `BeforeSamplingContext` and `BeforeSamplingCallback` from the package entry, and fix the README still documenting the removed `remoteConfiguration` switch instead of `remoteConfigurationEnabled`. --- README.md | 71 +++++- .../core/src/domain/session/sessionManager.ts | 65 ++++- packages/core/src/index.ts | 9 +- .../miniprogram-rum/src/boot/preStartRum.ts | 2 + .../miniprogram-rum/src/boot/rumPublicApi.ts | 8 + packages/miniprogram-rum/src/boot/startRum.ts | 1 + .../src/domain/configuration/configuration.ts | 18 +- .../configuration/remoteConfiguration.ts | 40 ++- .../src/domain/rumSessionManager.ts | 5 +- packages/miniprogram-rum/src/index.ts | 7 +- test/preStartRum.test.ts | 229 ++++++++++++++++++ test/remoteConfiguration.test.ts | 154 ++++++++++-- test/sessionManager.test.ts | 171 +++++++++++++ 13 files changed, 747 insertions(+), 33 deletions(-) create mode 100644 test/preStartRum.test.ts diff --git a/README.md b/README.md index 7e9bde2..9b8ee68 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,8 @@ flashcatRum.init({ service: "my-miniprogram", env: "production", version: "1.0.0", - // 可选:启用 RUM 远程会话采样配置 - remoteConfiguration: true, + // 可选:启用 RUM 远程配置 + remoteConfigurationEnabled: true, }); App({ @@ -148,7 +148,8 @@ SDK 通过以下机制实现自动追踪,**无需手动关联 APP 事件**: | `env` | string | ❌ | - | 环境(dev/test/prod) | | `version` | string | ❌ | - | 应用版本号 | | `sessionSampleRate` | number | ❌ | 100 | 会话采样率(0-100) | -| `remoteConfiguration` | boolean | ❌ | false | 是否启用远程会话采样配置 | +| `remoteConfigurationEnabled` | boolean | ❌ | false | 是否启用远程配置(会话采样率与 `custom`) | +| `beforeSampling` | function | ❌ | - | 创建新 Session 前同步调整采样率 | | `flushInterval` | number | ❌ | 15000 | 上报间隔(毫秒) | | `trackPages` | boolean | ❌ | true | 是否追踪页面 | | `trackActions` | boolean | ❌ | true | 是否追踪用户交互 | @@ -158,9 +159,11 @@ SDK 通过以下机制实现自动追踪,**无需手动关联 APP 事件**: | `debug` | boolean | ❌ | false | 是否开启调试模式 | | `beforeSend` | function | ❌ | - | 数据过滤钩子 | -### 远程会话采样配置 +### 远程配置 -设置 `remoteConfiguration: true` 后,SDK 会在初始化时同步读取上次缓存的有效配置,并在初始化完成后异步请求一次 `/api/v2/rum/config`。配置请求不阻塞初始化和事件采集,也不会被记录为 RUM resource 或 error 事件。 +设置 `remoteConfigurationEnabled: true` 后,SDK 会在初始化时同步读取上次缓存的有效配置,并在初始化完成后异步请求一次 `/api/v2/rum/config`。配置请求不阻塞初始化和事件采集,也不会被记录为 RUM resource 或 error 事件。 + +远程配置只消费两个字段:`rum.sessionSampleRate` 和顶层 `custom`;追踪采样率、回放采样率和隐私等级等字段会被忽略。 会话采样只在创建 Session 时执行一次: @@ -168,10 +171,61 @@ SDK 通过以下机制实现自动追踪,**无需手动关联 APP 事件**: - 没有缓存时,首个 Session 使用初始化的 `sessionSampleRate`;随后拉取到的新值只影响之后创建的 Session。 - 当前 Session 不会因配置拉取成功而重新抽签。调用 `flashcatRum.stopSession()` 后,下一次事件创建的新 Session 会使用最新配置。 - 配置接口不可用、响应非法或缓存不可读时,SDK 安全回退到初始化采样率,不影响正常采集。 -- 本期远程配置只支持 `sessionSampleRate`;追踪采样率、回放采样率和隐私等级等字段会被忽略。 远程配置沿用现有 `site` 或 `proxy`。因此直连模式无需额外添加小程序合法域名;代理模式需确保现有代理同时转发 `/api/v2/rum/config`,并建议透传 ETag 以使用 `304 Not Modified`。SDK 只在初始化时拉取(失败时会进行有限重试),不会定时轮询,也不会在创建新 Session 时额外请求。 +#### 读取 custom + +服务端响应的顶层 `custom` 供宿主自行决策,不参与 RUM 事件字段: + +```javascript +const custom = flashcatRum.getRemoteConfig(); +// 未启用远程配置、尚未拉取成功且无缓存、或服务端未下发 custom 时返回 undefined +if (custom?.featureFlags?.newCart) { + // ... +} +``` + +`custom` 只接受对象;非对象会被安全忽略,且不影响会话采样。每次调用都会返回一份副本,修改返回值不会影响 SDK 内部状态。旧版本写入的缓存仍可继续用于采样,只是 `getRemoteConfig()` 返回 `undefined`。 + +`custom` 的生命周期与采样快照一致:200 响应中缺少 `custom` 会清除已有值,`304 Not Modified` 保留缓存值,服务端下发 `enabled: false` 会同时清除 `custom` 和本地缓存。 + +#### 自定义采样决策 + +`beforeSampling` 在创建新 Session、执行抽签之前同步调用,可以基于远程 `custom` 覆盖本次采样率: + +```javascript +flashcatRum.init({ + // ... + remoteConfigurationEnabled: true, + beforeSampling: ({ sessionSampleRate, custom }) => { + // 返回 0-100 的数字覆盖采样率;返回 undefined 表示不修改 + if (custom?.vipUsers?.includes(getUserId())) { + return 100; + } + return sessionSampleRate; + }, +}); +``` + +- `sessionSampleRate` 是本次将要使用的采样率:有远程值时为远程值,否则为初始化值。 +- `custom` 是远程 `custom` 的副本,没有时为 `null`。 +- 回调抛错、返回非有限数字或超出 `0-100` 范围时,回退到传入的 `sessionSampleRate`。 + +#### 强制采集当前用户 + +排障场景下可以用 `setForcedSession()` 让下一个 Session 必定被采集,无需修改采样率: + +```javascript +flashcatRum.setForcedSession(); +flashcatRum.stopSession(); // 结束当前 Session,之后创建的新 Session 会被强制采集 +``` + +- 标记只作用于**下一个新建的 Session**,当前 Session 的抽签结果永不翻转。因此 support flow 需要在 `setForcedSession()` 之后结束当前 Session,才会开始强制采集。 +- 标记在 Session 创建后立即消耗,之后恢复常规抽样。 +- 优先级高于 `beforeSampling`:被标记的 Session 即使采样率为 0 也会被采集。 +- 初始化前调用会被保留到首个已创建 Session 之后的下一次 Session,不会追溯改变首个 Session。 + ## API 文档 ### 初始化 @@ -194,8 +248,13 @@ SDK 通过以下机制实现自动追踪,**无需手动关联 APP 事件**: ### 会话管理 - `flashcatRum.stopSession()` - 结束当前会话 +- `flashcatRum.setForcedSession()` - 标记下一个新建会话必定被采集 - `flashcatRum.getInitConfiguration()` - 获取初始化配置 +### 远程配置 + +- `flashcatRum.getRemoteConfig()` - 获取远程配置中的 `custom`,不可用时返回 `undefined` + ## 调试 如果接入后没有数据上报,开启调试模式: diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index fad3c34..5f49138 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -25,10 +25,24 @@ export interface SessionStore { clear: () => void } +export interface SessionConfiguration { + sessionSampleRate: number + rcVersion: number + custom?: Record | null +} + +export interface BeforeSamplingContext { + readonly sessionSampleRate: number + readonly custom: Record | null +} + +export type BeforeSamplingCallback = (context: BeforeSamplingContext) => number | undefined + export interface SessionManager { findSession: (time?: number) => SessionState | undefined findTrackedSession: (time?: number) => SessionState | undefined renew: () => SessionState + setForcedSession: () => void expand: () => void expire: () => void } @@ -39,13 +53,16 @@ export function startSessionManager( trackAnonymousUser = true, sessionSampleRate = 100, getSessionConfiguration, + beforeSampling, }: { trackAnonymousUser?: boolean sessionSampleRate?: number - getSessionConfiguration?: () => { sessionSampleRate: number; rcVersion: number } + getSessionConfiguration?: () => SessionConfiguration + beforeSampling?: BeforeSamplingCallback } = {}, ): SessionManager { let lastExpand = 0 + let forceNextSession = false const sessionHistory = createValueHistory(() => now(), { expireDelay: SESSION_TIME_OUT_DELAY, maxEntries: SESSION_HISTORY_MAX_ENTRIES, @@ -80,7 +97,7 @@ export function startSessionManager( function createSession(): SessionState { const time = now() - let currentConfiguration = { sessionSampleRate, rcVersion: 0 } + let currentConfiguration: SessionConfiguration = { sessionSampleRate, rcVersion: 0, custom: null } if (getSessionConfiguration) { try { currentConfiguration = getSessionConfiguration() @@ -88,13 +105,30 @@ export function startSessionManager( // Keep initialization values when a dynamic provider fails. } } + let resolvedSessionSampleRate = currentConfiguration.sessionSampleRate + if (beforeSampling) { + try { + const overriddenRate = beforeSampling({ + sessionSampleRate: resolvedSessionSampleRate, + custom: cloneCustom(currentConfiguration.custom || null), + }) + if (isSampleRate(overriddenRate)) { + resolvedSessionSampleRate = overriddenRate + } + } catch { + // Host callbacks must never prevent a session from being created. + } + } + + const isForced = forceNextSession + forceNextSession = false return { id: generateUUID(), created: time, expireAt: time + SESSION_EXPIRATION_DELAY, anonymousId: trackAnonymousUser ? store.get()?.anonymousId || generateUUID() : undefined, - isTracked: performDraw(currentConfiguration.sessionSampleRate), - sessionSampleRate: currentConfiguration.sessionSampleRate, + isTracked: isForced || performDraw(resolvedSessionSampleRate), + sessionSampleRate: resolvedSessionSampleRate, rcVersion: currentConfiguration.rcVersion, } } @@ -130,6 +164,9 @@ export function startSessionManager( lastExpand = now() return state }, + setForcedSession: () => { + forceNextSession = true + }, expand: () => { const t = now() if (t - lastExpand < EXPAND_THROTTLE) { @@ -157,3 +194,23 @@ function cloneSessionState(state: SessionState): SessionState { function performDraw(sampleRate: number): boolean { return Math.random() * 100 < sampleRate } + +function isSampleRate(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100 +} + +function cloneCustom(custom: Record | null): Record | null { + if (custom === null) { + return null + } + try { + const cloned = JSON.parse(JSON.stringify(custom)) + return isRecord(cloned) ? cloned : null + } catch { + return null + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7619ed0..e9c8a4c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,7 +12,14 @@ export * from './tools/serialisation/jsonStringify' export type { Context, ContextManager } from './domain/context/contextManager' export { createContextManager } from './domain/context/contextManager' -export type { SessionManager, SessionState, SessionStore } from './domain/session/sessionManager' +export type { + BeforeSamplingCallback, + BeforeSamplingContext, + SessionConfiguration, + SessionManager, + SessionState, + SessionStore, +} from './domain/session/sessionManager' export { startSessionManager } from './domain/session/sessionManager' export type { InitConfiguration, Configuration } from './domain/configuration/configuration' export { validateAndBuildConfiguration } from './domain/configuration/configuration' diff --git a/packages/miniprogram-rum/src/boot/preStartRum.ts b/packages/miniprogram-rum/src/boot/preStartRum.ts index ba4ac7d..3d841d4 100644 --- a/packages/miniprogram-rum/src/boot/preStartRum.ts +++ b/packages/miniprogram-rum/src/boot/preStartRum.ts @@ -38,6 +38,8 @@ export function createPreStartStrategy( setUser: (context) => buffer.push((strategy) => strategy.setUser(context)), startPage: (name) => buffer.push((strategy) => strategy.startPage(name)), stopSession: () => buffer.push((strategy) => strategy.stopSession()), + setForcedSession: () => buffer.push((strategy) => strategy.setForcedSession()), + getRemoteConfig: () => undefined, getInitConfiguration: () => initConfiguration, } diff --git a/packages/miniprogram-rum/src/boot/rumPublicApi.ts b/packages/miniprogram-rum/src/boot/rumPublicApi.ts index b216a34..254f3fa 100644 --- a/packages/miniprogram-rum/src/boot/rumPublicApi.ts +++ b/packages/miniprogram-rum/src/boot/rumPublicApi.ts @@ -17,6 +17,8 @@ export interface Strategy { setUser: (context: Context) => void startPage: (name?: string) => void stopSession: () => void + setForcedSession: () => void + getRemoteConfig: () => Record | undefined getInitConfiguration: () => RumInitConfiguration | undefined } @@ -30,6 +32,8 @@ export interface RumPublicApi { setUser: (context: Context) => void startPage: (name?: string) => void stopSession: () => void + setForcedSession: () => void + getRemoteConfig: () => Record | undefined getInitConfiguration: () => RumInitConfiguration | undefined } @@ -48,6 +52,8 @@ export function makeRumPublicApi(): RumPublicApi { setUser: (context) => started.userContext.setContext(context), startPage: started.startPage, stopSession: () => started.sessionManager.expire(), + setForcedSession: started.sessionManager.setForcedSession, + getRemoteConfig: started.getRemoteConfig, getInitConfiguration: () => strategy.initConfiguration, } strategy = nextStrategy @@ -64,6 +70,8 @@ export function makeRumPublicApi(): RumPublicApi { setUser: monitor((context) => strategy.setUser(context)), startPage: monitor((name) => strategy.startPage(name)), stopSession: monitor(() => strategy.stopSession()), + setForcedSession: monitor(() => strategy.setForcedSession()), + getRemoteConfig: monitor(() => strategy.getRemoteConfig()), getInitConfiguration: monitor(() => strategy.getInitConfiguration()), } diff --git a/packages/miniprogram-rum/src/boot/startRum.ts b/packages/miniprogram-rum/src/boot/startRum.ts index 9147de6..ef7a0a2 100644 --- a/packages/miniprogram-rum/src/boot/startRum.ts +++ b/packages/miniprogram-rum/src/boot/startRum.ts @@ -145,6 +145,7 @@ export function startRum(configuration: RumConfiguration, adapter: PlatformAdapt return { lifeCycle, sessionManager, + getRemoteConfig: remoteConfigurationController.getRemoteConfig, globalContext, userContext, addAction: actionCollection?.addAction || (() => undefined), diff --git a/packages/miniprogram-rum/src/domain/configuration/configuration.ts b/packages/miniprogram-rum/src/domain/configuration/configuration.ts index d4e6a2c..de784fb 100644 --- a/packages/miniprogram-rum/src/domain/configuration/configuration.ts +++ b/packages/miniprogram-rum/src/domain/configuration/configuration.ts @@ -1,7 +1,19 @@ -import type { Configuration, InitConfiguration, ProxyFn, TraceContext } from '@flashcatcloud/miniprogram-core' +import type { + BeforeSamplingCallback, + BeforeSamplingContext, + Configuration, + InitConfiguration, + ProxyFn, + TraceContext, +} from '@flashcatcloud/miniprogram-core' import { validateAndBuildConfiguration } from '@flashcatcloud/miniprogram-core' export interface RumInitConfiguration extends InitConfiguration { + /** + * Synchronously adjusts the sample rate used when a new Session is created. + * Invalid return values and thrown errors leave the incoming rate unchanged. + */ + beforeSampling?: BeforeSamplingCallback trackActions?: boolean trackRequests?: boolean trackErrors?: boolean @@ -62,8 +74,11 @@ export interface RumConfiguration extends Configuration { proxy?: string | ProxyFn site?: string } + beforeSampling?: BeforeSamplingCallback } +export type { BeforeSamplingCallback, BeforeSamplingContext } + export function validateAndBuildRumConfiguration( initConfiguration: RumInitConfiguration, ): RumConfiguration | undefined { @@ -94,6 +109,7 @@ export function validateAndBuildRumConfiguration( proxy: initConfiguration.proxy, site: initConfiguration.site, }, + beforeSampling: initConfiguration.beforeSampling, } } diff --git a/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts b/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts index 9ede56e..03c518d 100644 --- a/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts +++ b/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts @@ -14,6 +14,7 @@ const REMOTE_CONFIGURATION_INDEX_KEY_PREFIX = '_fc_rum_remote_config_index_v1_' export interface SessionConfigurationSnapshot { sessionSampleRate: number rcVersion: number + custom: Record | null } interface CachedRemoteConfiguration { @@ -30,6 +31,7 @@ interface RemoteConfigurationDependencies { export interface RemoteConfigurationController { getSessionConfiguration: () => SessionConfigurationSnapshot + getRemoteConfig: () => Record | undefined fetch: (appliedVersion?: number) => void stop: () => void } @@ -46,11 +48,13 @@ export function createRemoteConfigurationController( const initialSnapshot: SessionConfigurationSnapshot = { sessionSampleRate: configuration.sessionSampleRate, rcVersion: 0, + custom: null, } if (!configuration.remoteConfigurationEnabled) { return { getSessionConfiguration: () => initialSnapshot, + getRemoteConfig: () => undefined, fetch: () => undefined, stop: () => undefined, } @@ -69,6 +73,7 @@ export function createRemoteConfigurationController( if (!endpoint) { return { getSessionConfiguration: () => currentSnapshot, + getRemoteConfig: () => undefined, fetch: () => undefined, stop: () => { stopped = true @@ -117,7 +122,7 @@ export function createRemoteConfigurationController( clearCache() return } - currentSnapshot = parsed.snapshot + currentSnapshot = normalizeSnapshot(parsed.snapshot) etag = parsed.etag hasRemoteSnapshot = true } catch { @@ -251,7 +256,14 @@ export function createRemoteConfigurationController( } return { - getSessionConfiguration: () => currentSnapshot, + getSessionConfiguration: () => ({ + ...currentSnapshot, + custom: cloneCustom(currentSnapshot.custom), + }), + getRemoteConfig: () => { + const custom = cloneCustom(currentSnapshot.custom) + return custom || undefined + }, fetch: (appliedVersion) => { try { request(appliedVersion, 0) @@ -312,11 +324,14 @@ function parseResponse( sessionSampleRate = value.rum.sessionSampleRate } + const custom = isRecord(value.custom) ? cloneCustom(value.custom) : null + return { enabled: true, snapshot: { sessionSampleRate, rcVersion: isRemoteVersion(value.version) ? value.version : 0, + custom, }, } } @@ -328,6 +343,7 @@ function isCachedRemoteConfiguration(value: unknown): value is CachedRemoteConfi return ( isSampleRate(value.snapshot.sessionSampleRate) && isRemoteVersion(value.snapshot.rcVersion) && + (value.snapshot.custom === undefined || isRecord(value.snapshot.custom) || value.snapshot.custom === null) && (value.etag === undefined || typeof value.etag === 'string') ) } @@ -336,6 +352,26 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +function normalizeSnapshot(snapshot: SessionConfigurationSnapshot): SessionConfigurationSnapshot { + return { + sessionSampleRate: snapshot.sessionSampleRate, + rcVersion: snapshot.rcVersion, + custom: isRecord(snapshot.custom) ? cloneCustom(snapshot.custom) : null, + } +} + +function cloneCustom(custom: Record | null): Record | null { + if (custom === null) { + return null + } + try { + const cloned = JSON.parse(JSON.stringify(custom)) + return isRecord(cloned) ? cloned : null + } catch { + return null + } +} + function isSampleRate(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100 } diff --git a/packages/miniprogram-rum/src/domain/rumSessionManager.ts b/packages/miniprogram-rum/src/domain/rumSessionManager.ts index 7dfed02..9319735 100644 --- a/packages/miniprogram-rum/src/domain/rumSessionManager.ts +++ b/packages/miniprogram-rum/src/domain/rumSessionManager.ts @@ -1,5 +1,5 @@ import type { PlatformAdapter } from '@flashcatcloud/miniprogram-platform' -import type { SessionManager, SessionState, SessionStore } from '@flashcatcloud/miniprogram-core' +import type { SessionConfiguration, SessionManager, SessionState, SessionStore } from '@flashcatcloud/miniprogram-core' import { startSessionManager } from '@flashcatcloud/miniprogram-core' import type { RumConfiguration } from './configuration/configuration' @@ -26,11 +26,12 @@ export function createSessionStore(adapter: PlatformAdapter): SessionStore { export function startRumSessionManager( adapter: PlatformAdapter, configuration: RumConfiguration, - getSessionConfiguration?: () => { sessionSampleRate: number; rcVersion: number }, + getSessionConfiguration?: () => SessionConfiguration, ): SessionManager { return startSessionManager(createSessionStore(adapter), { trackAnonymousUser: configuration.trackAnonymousUser, sessionSampleRate: configuration.sessionSampleRate, getSessionConfiguration, + beforeSampling: configuration.beforeSampling, }) } diff --git a/packages/miniprogram-rum/src/index.ts b/packages/miniprogram-rum/src/index.ts index d509ad3..e84e510 100644 --- a/packages/miniprogram-rum/src/index.ts +++ b/packages/miniprogram-rum/src/index.ts @@ -8,6 +8,11 @@ export { makeRumPublicApi } from './boot/rumPublicApi' // 导出类型 export type { RumPublicApi } from './boot/rumPublicApi' -export type { RumInitConfiguration, RumConfiguration } from './domain/configuration/configuration' +export type { + BeforeSamplingCallback, + BeforeSamplingContext, + RumInitConfiguration, + RumConfiguration, +} from './domain/configuration/configuration' export type { RumEvent } from './rumEvent.types' export type { RawRumEvent } from './rawRumEvent.types' diff --git a/test/preStartRum.test.ts b/test/preStartRum.test.ts new file mode 100644 index 0000000..2e1e6d2 --- /dev/null +++ b/test/preStartRum.test.ts @@ -0,0 +1,229 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createPreStartStrategy } from '../packages/miniprogram-rum/src/boot/preStartRum' +import { startRum } from '../packages/miniprogram-rum/src/boot/startRum' +import type { RumPublicApi, Strategy } from '../packages/miniprogram-rum/src/boot/rumPublicApi' +import type { RumInitConfiguration } from '../packages/miniprogram-rum/src/domain/configuration/configuration' +import type { + DownloadFileOptions, + PlatformAdapter, + RequestOptions, + UploadFileOptions, +} from '../packages/miniprogram-platform/src/platform/types' + +type TestAdapter = PlatformAdapter & { configRequests: RequestOptions[] } + +function createAdapter(): TestAdapter { + const storage = new Map() + const adapter: TestAdapter = { + configRequests: [], + request: (options: RequestOptions) => { + if (options.url.includes('/api/v2/rum/config')) { + adapter.configRequests.push(options) + } + return { abort: () => undefined } + }, + uploadFile: (options: UploadFileOptions) => { + options.success?.({ statusCode: 200, data: 'ok' }) + return { abort: () => undefined } + }, + downloadFile: (options: DownloadFileOptions) => { + options.success?.({ statusCode: 200, tempFilePath: '/tmp/file' }) + return { abort: () => undefined } + }, + setStorageSync: (key, data) => { + storage.set(key, data) + }, + getStorageSync: (key) => storage.get(key), + removeStorageSync: (key) => { + storage.delete(key) + }, + getSystemInfoSync: () => ({}), + getNetworkType: ({ success }: { success: (res: any) => void }) => success({ networkType: 'wifi' }), + onNetworkStatusChange: () => undefined, + onAppShow: () => undefined, + onAppHide: () => undefined, + onError: () => undefined, + onUnhandledRejection: () => undefined, + onPageNotFound: () => undefined, + onLazyLoadError: () => undefined, + } + return adapter +} + +/** startRum instruments the miniprogram globals, which do not exist under Node. */ +function withPlatformGlobals(body: () => void | Promise) { + const globals = globalThis as any + const originalWx = globals.wx + const originalPage = globals.Page + globals.wx = { getPerformance: () => undefined } + globals.Page = (options: Record) => options + + const restore = () => { + globals.wx = originalWx + globals.Page = originalPage + } + + let result: void | Promise + try { + result = body() + } catch (error) { + restore() + throw error + } + if (result instanceof Promise) { + return result.finally(restore) + } + restore() + return undefined +} + +/** Mirrors makeRumPublicApi with an injected adapter so tests avoid platform globals. */ +function createTestApi(adapter: TestAdapter) { + let started: ReturnType | undefined + let strategy: Strategy = createPreStartStrategy(adapter, (configuration, adapterInstance) => { + started = startRum(configuration, adapterInstance) + const nextStrategy: Strategy = { + init: () => undefined, + initConfiguration: strategy.initConfiguration, + addAction: started.addAction, + addError: started.addError, + addTiming: started.addTiming, + addCustomEvent: started.addCustomEvent, + setGlobalContext: (context) => started!.globalContext.setContext(context), + setUser: (context) => started!.userContext.setContext(context), + startPage: started.startPage, + stopSession: () => started!.sessionManager.expire(), + setForcedSession: started.sessionManager.setForcedSession, + getRemoteConfig: started.getRemoteConfig, + getInitConfiguration: () => strategy.initConfiguration, + } + strategy = nextStrategy + return nextStrategy + }) + + const api = { + init: (initConfiguration: RumInitConfiguration) => strategy.init(initConfiguration, api as RumPublicApi), + setForcedSession: () => strategy.setForcedSession(), + getRemoteConfig: () => strategy.getRemoteConfig(), + getStarted: () => started!, + } + return api +} + +const baseConfiguration: RumInitConfiguration = { + clientToken: 'token', + applicationId: 'app', + trackPages: false, + trackActions: false, + trackPerformance: false, + flushInterval: 100_000, +} + +test('getRemoteConfig returns undefined before init and the fetched custom afterwards', () => + withPlatformGlobals(async () => { + const adapter = createAdapter() + const api = createTestApi(adapter) + + assert.equal(api.getRemoteConfig(), undefined) + + api.init({ ...baseConfiguration, remoteConfigurationEnabled: true }) + assert.equal(api.getRemoteConfig(), undefined) + + await Promise.resolve() + assert.equal(adapter.configRequests.length, 1) + adapter.configRequests[0].success?.({ + statusCode: 200, + data: { version: 3, enabled: true, rum: { sessionSampleRate: 100 }, custom: { tier: 'gold' } }, + }) + + assert.deepEqual(api.getRemoteConfig(), { tier: 'gold' }) + api.getStarted().stop() + })) + +test('getRemoteConfig returns undefined when remote configuration is disabled', () => + withPlatformGlobals(() => { + const adapter = createAdapter() + const api = createTestApi(adapter) + + api.init({ ...baseConfiguration, remoteConfigurationEnabled: false }) + + assert.equal(api.getRemoteConfig(), undefined) + api.getStarted().stop() + })) + +test('setForcedSession before init applies to the session after the first one', () => + withPlatformGlobals(() => { + const adapter = createAdapter() + const api = createTestApi(adapter) + + api.setForcedSession() + api.init({ ...baseConfiguration, sessionSampleRate: 0 }) + + const started = api.getStarted() + // The first session is created inside startRum, before the pre-start buffer + // is replayed, so a queued call must not retroactively change its draw. + assert.equal(started.sessionManager.findSession()?.isTracked, false) + + started.sessionManager.expire() + assert.equal(started.sessionManager.renew().isTracked, true) + + started.sessionManager.expire() + assert.equal(started.sessionManager.renew().isTracked, false) + started.stop() + })) + +test('setForcedSession after init leaves the current session and forces the next one', () => + withPlatformGlobals(() => { + const adapter = createAdapter() + const api = createTestApi(adapter) + + api.init({ ...baseConfiguration, sessionSampleRate: 0 }) + const started = api.getStarted() + const current = started.sessionManager.findSession()! + assert.equal(current.isTracked, false) + + api.setForcedSession() + assert.equal(started.sessionManager.findSession()?.id, current.id) + assert.equal(started.sessionManager.findSession()?.isTracked, false) + + started.sessionManager.expire() + assert.equal(started.sessionManager.renew().isTracked, true) + started.stop() + })) + +test('beforeSampling receives the remote sample rate and custom of the next session', () => + withPlatformGlobals(async () => { + const adapter = createAdapter() + const contexts: Array<{ sessionSampleRate: number; custom: Record | null }> = [] + const api = createTestApi(adapter) + + api.init({ + ...baseConfiguration, + sessionSampleRate: 100, + remoteConfigurationEnabled: true, + beforeSampling: (context) => { + contexts.push(context) + return context.custom?.tier === 'gold' ? 100 : 0 + }, + }) + + const started = api.getStarted() + assert.deepEqual(contexts, [{ sessionSampleRate: 100, custom: null }]) + assert.equal(started.sessionManager.findSession()?.isTracked, false) + + await Promise.resolve() + adapter.configRequests[0].success?.({ + statusCode: 200, + data: { version: 4, enabled: true, rum: { sessionSampleRate: 10 }, custom: { tier: 'gold' } }, + }) + + started.sessionManager.expire() + const next = started.sessionManager.renew() + + assert.deepEqual(contexts[1], { sessionSampleRate: 10, custom: { tier: 'gold' } }) + assert.equal(next.sessionSampleRate, 100) + assert.equal(next.isTracked, true) + assert.equal(next.rcVersion, 4) + started.stop() + })) diff --git a/test/remoteConfiguration.test.ts b/test/remoteConfiguration.test.ts index 2b2c361..7af1d06 100644 --- a/test/remoteConfiguration.test.ts +++ b/test/remoteConfiguration.test.ts @@ -68,12 +68,12 @@ function configuration(overrides: Record = {}) { })! } -test('remote configuration accepts compatible schemas and only consumes sessionSampleRate', () => { +test('remote configuration accepts compatible schemas and only consumes sessionSampleRate and custom', () => { const cases = [ { name: 'missing schema', data: { version: 2, enabled: true, rum: { sessionSampleRate: 21 } }, - expected: { sessionSampleRate: 21, rcVersion: 2 }, + expected: { sessionSampleRate: 21, rcVersion: 2, custom: null }, }, { name: 'schema v1 with unknown keys', @@ -84,12 +84,12 @@ test('remote configuration accepts compatible schemas and only consumes sessionS rum: { sessionSampleRate: 22, traceSampleRate: 0, privacyLevel: 'mask' }, custom: { anything: true }, }, - expected: { sessionSampleRate: 22, rcVersion: 3 }, + expected: { sessionSampleRate: 22, rcVersion: 3, custom: { anything: true } }, }, { name: 'missing sampling field', data: { schema_version: 1, version: 4, enabled: true, rum: { traceSampleRate: 0 } }, - expected: { sessionSampleRate: 73, rcVersion: 4 }, + expected: { sessionSampleRate: 73, rcVersion: 4, custom: null }, }, ] @@ -128,7 +128,7 @@ test('remote configuration logs fetched and applied values when debug is enabled '[FlashCat RUM][Debug] Remote configuration fetched', { response: data, - applied: { sessionSampleRate: 35, rcVersion: 6 }, + applied: { sessionSampleRate: 35, rcVersion: 6, custom: null }, etag: '"config-6"', }, ]]) @@ -157,7 +157,7 @@ test('remote configuration rejects incompatible or malformed snapshots as a whol setTimeout: () => 1, }) controller.fetch() - assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0 }) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0, custom: null }) controller.stop() } }) @@ -168,12 +168,12 @@ test('enabled false clears cache and falls back to the initialization rate', () const adapter = createAdapter((options) => options.success?.({ statusCode: 200, data: response }), storage) const controller = createRemoteConfigurationController(adapter, configuration()) controller.fetch() - assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 12, rcVersion: 7 }) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 12, rcVersion: 7, custom: null }) assert.equal(storage.size, 2) response = { version: 8, enabled: false, rum: { sessionSampleRate: 0 } } controller.fetch(7) - assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0 }) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0, custom: null }) assert.equal(storage.size, 0) controller.stop() }) @@ -191,10 +191,10 @@ test('cache is loaded synchronously with ETag and 304 preserves the snapshot', ( const secondAdapter = createAdapter((options) => options.success?.({ statusCode: 304 }), storage) const second = createRemoteConfigurationController(secondAdapter, configuration()) - assert.deepEqual(second.getSessionConfiguration(), { sessionSampleRate: 31, rcVersion: 9 }) + assert.deepEqual(second.getSessionConfiguration(), { sessionSampleRate: 31, rcVersion: 9, custom: null }) second.fetch(9) assert.equal(secondAdapter.requests[0].header?.['If-None-Match'], '"config-9"') - assert.deepEqual(second.getSessionConfiguration(), { sessionSampleRate: 31, rcVersion: 9 }) + assert.deepEqual(second.getSessionConfiguration(), { sessionSampleRate: 31, rcVersion: 9, custom: null }) second.stop() }) @@ -216,7 +216,7 @@ test('cache dimensions isolate endpoint, application, env and app version', () = ] for (const variant of variants) { const controller = createRemoteConfigurationController(createAdapter(undefined, storage), configuration(variant)) - assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0 }) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0, custom: null }) controller.stop() } }) @@ -246,9 +246,9 @@ test('cache cleanup removes the previous app version and ignores dynamic functio const second = createRemoteConfigurationController(secondAdapter, configuration({ proxy, version: '2.0.0' })) assert.equal(storage.has(firstCacheKey), false) - assert.deepEqual(second.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0 }) + assert.deepEqual(second.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0, custom: null }) second.fetch() - assert.deepEqual(second.getSessionConfiguration(), { sessionSampleRate: 30, rcVersion: 6 }) + assert.deepEqual(second.getSessionConfiguration(), { sessionSampleRate: 30, rcVersion: 6, custom: null }) assert.equal( [...storage.keys()].filter((key) => key.startsWith(REMOTE_CONFIGURATION_STORAGE_KEY_PREFIX)).length, 1, @@ -270,7 +270,7 @@ test('corrupt or incompatible cache is removed and storage failures are isolated storage.set(cacheKey, '{not json') const controller = createRemoteConfigurationController(createAdapter(undefined, storage), configuration()) - assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0 }) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0, custom: null }) assert.equal(storage.has(cacheKey), false) controller.stop() @@ -283,7 +283,7 @@ test('corrupt or incompatible cache is removed and storage failures are isolated throwingAdapter.removeStorageSync = () => { throw new Error('remove failed') } const storageFailure = createRemoteConfigurationController(throwingAdapter, configuration()) assert.doesNotThrow(() => storageFailure.fetch()) - assert.deepEqual(storageFailure.getSessionConfiguration(), { sessionSampleRate: 19, rcVersion: 2 }) + assert.deepEqual(storageFailure.getSessionConfiguration(), { sessionSampleRate: 19, rcVersion: 2, custom: null }) storageFailure.stop() }) @@ -294,7 +294,129 @@ test('disabled remote configuration performs no cache access and no request', () assert.equal(adapter.storageReads, 0) assert.equal(adapter.requests.length, 0) - assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0 }) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0, custom: null }) + assert.equal(controller.getRemoteConfig(), undefined) +}) + +test('custom survives a cold start through the cache and 304 keeps the cached value', () => { + const storage = new Map() + const firstAdapter = createAdapter((options) => options.success?.({ + statusCode: 200, + data: { + version: 20, + enabled: true, + rum: { sessionSampleRate: 44 }, + custom: { supportUsers: ['u-1'], featureFlags: { newCart: true } }, + }, + header: { ETag: '"config-20"' }, + }), storage) + const first = createRemoteConfigurationController(firstAdapter, configuration()) + assert.equal(first.getRemoteConfig(), undefined) + first.fetch() + assert.deepEqual(first.getRemoteConfig(), { supportUsers: ['u-1'], featureFlags: { newCart: true } }) + first.stop() + + const secondAdapter = createAdapter((options) => options.success?.({ statusCode: 304 }), storage) + const second = createRemoteConfigurationController(secondAdapter, configuration()) + assert.deepEqual(second.getRemoteConfig(), { supportUsers: ['u-1'], featureFlags: { newCart: true } }) + second.fetch(20) + assert.deepEqual(second.getSessionConfiguration(), { + sessionSampleRate: 44, + rcVersion: 20, + custom: { supportUsers: ['u-1'], featureFlags: { newCart: true } }, + }) + second.stop() +}) + +test('a 200 response without custom clears the previously applied custom', () => { + let response: unknown = { version: 21, enabled: true, rum: { sessionSampleRate: 40 }, custom: { tier: 'gold' } } + const adapter = createAdapter((options) => options.success?.({ statusCode: 200, data: response })) + const controller = createRemoteConfigurationController(adapter, configuration()) + controller.fetch() + assert.deepEqual(controller.getRemoteConfig(), { tier: 'gold' }) + + response = { version: 22, enabled: true, rum: { sessionSampleRate: 40 } } + controller.fetch(21) + assert.equal(controller.getRemoteConfig(), undefined) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 40, rcVersion: 22, custom: null }) + controller.stop() +}) + +test('the kill switch clears custom together with the cache', () => { + const storage = new Map() + let response: unknown = { version: 23, enabled: true, rum: { sessionSampleRate: 40 }, custom: { tier: 'gold' } } + const adapter = createAdapter((options) => options.success?.({ statusCode: 200, data: response }), storage) + const controller = createRemoteConfigurationController(adapter, configuration()) + controller.fetch() + assert.deepEqual(controller.getRemoteConfig(), { tier: 'gold' }) + + response = { version: 24, enabled: false } + controller.fetch(23) + assert.equal(controller.getRemoteConfig(), undefined) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0, custom: null }) + assert.equal(storage.size, 0) + controller.stop() +}) + +test('a non-object custom is ignored without affecting session sampling', () => { + for (const custom of ['text', 42, true, null, ['a'], undefined]) { + const adapter = createAdapter((options) => options.success?.({ + statusCode: 200, + data: { version: 25, enabled: true, rum: { sessionSampleRate: 41 }, custom }, + })) + const controller = createRemoteConfigurationController(adapter, configuration()) + controller.fetch() + assert.equal(controller.getRemoteConfig(), undefined, `custom ${JSON.stringify(custom)}`) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 41, rcVersion: 25, custom: null }) + controller.stop() + } +}) + +test('custom accessors return defensive copies that cannot mutate internal state', () => { + const adapter = createAdapter((options) => options.success?.({ + statusCode: 200, + data: { version: 26, enabled: true, rum: { sessionSampleRate: 40 }, custom: { nested: { tier: 'gold' } } }, + })) + const controller = createRemoteConfigurationController(adapter, configuration()) + controller.fetch() + + const first = controller.getRemoteConfig()! + const second = controller.getRemoteConfig()! + assert.notEqual(first, second) + assert.notEqual(first.nested, second.nested) + + first.injected = true + ;(first.nested as Record).tier = 'bronze' + assert.deepEqual(controller.getRemoteConfig(), { nested: { tier: 'gold' } }) + + const snapshot = controller.getSessionConfiguration() + snapshot.custom!.injected = true + assert.deepEqual(controller.getSessionConfiguration().custom, { nested: { tier: 'gold' } }) + controller.stop() +}) + +test('a cache written before custom existed stays usable while getRemoteConfig returns undefined', () => { + const storage = new Map() + const seedAdapter = createAdapter((options) => options.success?.({ + statusCode: 200, + data: { version: 27, enabled: true, rum: { sessionSampleRate: 33 } }, + }), storage) + const seed = createRemoteConfigurationController(seedAdapter, configuration()) + seed.fetch() + seed.stop() + + // Rewrite the cache the way an SDK version without custom support would have. + const cacheKey = [...storage.keys()].find((key) => key.startsWith(REMOTE_CONFIGURATION_STORAGE_KEY_PREFIX))! + storage.set(cacheKey, JSON.stringify({ + formatVersion: 1, + snapshot: { sessionSampleRate: 33, rcVersion: 27 }, + etag: '"config-27"', + })) + + const controller = createRemoteConfigurationController(createAdapter(undefined, storage), configuration()) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 33, rcVersion: 27, custom: null }) + assert.equal(controller.getRemoteConfig(), undefined) + controller.stop() }) test('direct request contains complete parameters, applied version and internal marker', () => { diff --git a/test/sessionManager.test.ts b/test/sessionManager.test.ts index ea3db2e..2a1e605 100644 --- a/test/sessionManager.test.ts +++ b/test/sessionManager.test.ts @@ -266,6 +266,177 @@ test('sessionManager hard timeout', () => { assert.equal(found, undefined) }) +test('beforeSampling receives the initialization rate when no remote provider is set', () => { + const store = createMockStore() + const contexts: unknown[] = [] + const manager = startSessionManager(store, { + sessionSampleRate: 61, + beforeSampling: (context) => { + contexts.push(context) + return undefined + }, + }) + + const session = manager.renew() + assert.deepEqual(contexts, [{ sessionSampleRate: 61, custom: null }]) + assert.equal(session.sessionSampleRate, 61) +}) + +test('beforeSampling receives the remote rate and custom of the session being created', () => { + const store = createMockStore() + const contexts: Array<{ sessionSampleRate: number; custom: Record | null }> = [] + let snapshot = { sessionSampleRate: 30, rcVersion: 4, custom: { tier: 'gold' } as Record | null } + const manager = startSessionManager(store, { + sessionSampleRate: 61, + getSessionConfiguration: () => snapshot, + beforeSampling: (context) => { + contexts.push(context) + return undefined + }, + }) + + manager.renew() + snapshot = { sessionSampleRate: 70, rcVersion: 5, custom: null } + manager.expire() + manager.renew() + + assert.deepEqual(contexts, [ + { sessionSampleRate: 30, custom: { tier: 'gold' } }, + { sessionSampleRate: 70, custom: null }, + ]) +}) + +test('beforeSampling receives a copy of custom that cannot mutate the provider snapshot', () => { + const store = createMockStore() + const snapshot = { sessionSampleRate: 100, rcVersion: 1, custom: { tier: 'gold' } as Record } + const manager = startSessionManager(store, { + getSessionConfiguration: () => snapshot, + beforeSampling: (context) => { + ;(context.custom as Record).tier = 'bronze' + return undefined + }, + }) + + manager.renew() + assert.deepEqual(snapshot.custom, { tier: 'gold' }) +}) + +test('beforeSampling overrides the rate used for the single draw', () => { + const store = createMockStore() + const manager = startSessionManager(store, { + sessionSampleRate: 100, + beforeSampling: () => 0, + }) + + const session = manager.renew() + assert.equal(session.sessionSampleRate, 0) + assert.equal(session.isTracked, false) + assert.equal(manager.findTrackedSession(), undefined) +}) + +test('beforeSampling falls back to the incoming rate for invalid results and thrown errors', () => { + const invalidResults: unknown[] = [ + undefined, + null, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + -1, + 101, + '50', + {}, + ] + + for (const result of invalidResults) { + const manager = startSessionManager(createMockStore(), { + sessionSampleRate: 100, + beforeSampling: () => result as number | undefined, + }) + const session = manager.renew() + assert.equal(session.sessionSampleRate, 100, `result ${JSON.stringify(result)}`) + assert.equal(session.isTracked, true, `result ${JSON.stringify(result)}`) + } + + const throwing = startSessionManager(createMockStore(), { + sessionSampleRate: 100, + beforeSampling: () => { + throw new Error('host callback failed') + }, + }) + let session!: ReturnType + assert.doesNotThrow(() => { + session = throwing.renew() + }) + assert.equal(session.sessionSampleRate, 100) + assert.equal(session.isTracked, true) +}) + +test('beforeSampling accepts the boundary rates 0 and 100', () => { + const zero = startSessionManager(createMockStore(), { sessionSampleRate: 100, beforeSampling: () => 0 }) + assert.equal(zero.renew().isTracked, false) + + const hundred = startSessionManager(createMockStore(), { sessionSampleRate: 0, beforeSampling: () => 100 }) + assert.equal(hundred.renew().isTracked, true) +}) + +test('setForcedSession leaves the current session untouched and forces only the next one', () => { + const store = createMockStore() + const manager = startSessionManager(store, { sessionSampleRate: 0 }) + + const current = manager.renew() + assert.equal(current.isTracked, false) + + manager.setForcedSession() + assert.equal(manager.findSession()?.id, current.id) + assert.equal(manager.findSession()?.isTracked, false) + assert.equal(manager.findTrackedSession(), undefined) + + manager.expire() + const forced = manager.renew() + assert.equal(forced.isTracked, true) + assert.equal(forced.sessionSampleRate, 0) + assert.equal(manager.findTrackedSession()?.id, forced.id) + + manager.expire() + const afterForced = manager.renew() + assert.equal(afterForced.isTracked, false) +}) + +test('setForcedSession before any session is created forces that first session', () => { + const manager = startSessionManager(createMockStore(), { sessionSampleRate: 0 }) + + manager.setForcedSession() + assert.equal(manager.renew().isTracked, true) +}) + +test('setForcedSession is idempotent and consumed by a single session', () => { + const manager = startSessionManager(createMockStore(), { sessionSampleRate: 0 }) + + manager.renew() + manager.setForcedSession() + manager.setForcedSession() + + manager.expire() + assert.equal(manager.renew().isTracked, true) + manager.expire() + assert.equal(manager.renew().isTracked, false) +}) + +test('setForcedSession takes precedence over a beforeSampling rate of 0', () => { + const manager = startSessionManager(createMockStore(), { + sessionSampleRate: 100, + beforeSampling: () => 0, + }) + + manager.renew() + manager.setForcedSession() + manager.expire() + + const forced = manager.renew() + assert.equal(forced.isTracked, true) + assert.equal(forced.sessionSampleRate, 0) +}) + test('sessionManager is silent by default', () => { const store = createMockStore() const manager = startSessionManager(store) From 82875d59720483fb5bacebe98287c6f5ff0f5b93 Mon Sep 17 00:00:00 2001 From: guguji5 <604821528@qq.com> Date: Thu, 27 Aug 2026 12:09:27 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20=E8=BE=93=E5=87=BA=E4=B8=80?= =?UTF-8?q?=E4=B8=8B=E7=9C=9F=E6=AD=A3=E4=BD=BF=E7=94=A8=E7=9A=84sessionSa?= =?UTF-8?q?mpleRate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/src/domain/session/sessionManager.ts | 19 ++++++- packages/miniprogram-rum/src/boot/startRum.ts | 1 + .../src/domain/rumSessionManager.ts | 1 + test/sessionManager.test.ts | 53 +++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index 5f49138..1414fc4 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -54,11 +54,13 @@ export function startSessionManager( sessionSampleRate = 100, getSessionConfiguration, beforeSampling, + debug = false, }: { trackAnonymousUser?: boolean sessionSampleRate?: number getSessionConfiguration?: () => SessionConfiguration beforeSampling?: BeforeSamplingCallback + debug?: boolean } = {}, ): SessionManager { let lastExpand = 0 @@ -89,6 +91,13 @@ export function startSessionManager( } } sessionHistory.add(cloneSessionState(initialSession), initialSession.created) + if (debug) { + try { + console.log('[FlashCat RUM SDK][Debug] Using sessionSampleRate', initialSession.sessionSampleRate) + } catch { + // Console implementations are host code and must not affect session restore. + } + } } function isExpiredAt(state: SessionState, time: number) { @@ -122,12 +131,20 @@ export function startSessionManager( const isForced = forceNextSession forceNextSession = false + const isTracked = isForced || performDraw(resolvedSessionSampleRate) + if (debug) { + try { + console.log('[FlashCat RUM][Debug] Using sessionSampleRate', resolvedSessionSampleRate) + } catch { + // Console implementations are host code and must not affect session creation. + } + } return { id: generateUUID(), created: time, expireAt: time + SESSION_EXPIRATION_DELAY, anonymousId: trackAnonymousUser ? store.get()?.anonymousId || generateUUID() : undefined, - isTracked: isForced || performDraw(resolvedSessionSampleRate), + isTracked, sessionSampleRate: resolvedSessionSampleRate, rcVersion: currentConfiguration.rcVersion, } diff --git a/packages/miniprogram-rum/src/boot/startRum.ts b/packages/miniprogram-rum/src/boot/startRum.ts index ef7a0a2..a69c4bb 100644 --- a/packages/miniprogram-rum/src/boot/startRum.ts +++ b/packages/miniprogram-rum/src/boot/startRum.ts @@ -39,6 +39,7 @@ export function startRum(configuration: RumConfiguration, adapter: PlatformAdapt if (configuration.debug) { console.log('[FlashCat RUM][Debug] RUM monitoring started', { + sessionSampleRate: sessionManager.findSession()?.sessionSampleRate, trackPages: configuration.trackPages, trackActions: configuration.trackActions, trackRequests: configuration.trackRequests, diff --git a/packages/miniprogram-rum/src/domain/rumSessionManager.ts b/packages/miniprogram-rum/src/domain/rumSessionManager.ts index 9319735..f6a8e68 100644 --- a/packages/miniprogram-rum/src/domain/rumSessionManager.ts +++ b/packages/miniprogram-rum/src/domain/rumSessionManager.ts @@ -33,5 +33,6 @@ export function startRumSessionManager( sessionSampleRate: configuration.sessionSampleRate, getSessionConfiguration, beforeSampling: configuration.beforeSampling, + debug: configuration.debug, }) } diff --git a/test/sessionManager.test.ts b/test/sessionManager.test.ts index 2a1e605..d00064e 100644 --- a/test/sessionManager.test.ts +++ b/test/sessionManager.test.ts @@ -455,3 +455,56 @@ test('sessionManager is silent by default', () => { assert.equal(logs.length, 0) }) + +test('sessionManager logs the resolved sessionSampleRate when debug is enabled', () => { + const originalLog = console.log + const logs: unknown[][] = [] + console.log = (...args: unknown[]) => logs.push(args) + + try { + const initOnly = startSessionManager(createMockStore(), { + sessionSampleRate: 40, + debug: true, + }) + initOnly.renew() + + const remote = startSessionManager(createMockStore(), { + sessionSampleRate: 40, + getSessionConfiguration: () => ({ sessionSampleRate: 12, rcVersion: 7 }), + debug: true, + }) + remote.renew() + + const overridden = startSessionManager(createMockStore(), { + sessionSampleRate: 40, + getSessionConfiguration: () => ({ sessionSampleRate: 12, rcVersion: 7 }), + beforeSampling: () => 88, + debug: true, + }) + overridden.renew() + + const restoredStore = createMockStore() + restoredStore.set({ + id: 'existing', + created: Date.now(), + expireAt: Date.now() + 60_000, + isTracked: true, + sessionSampleRate: 67, + rcVersion: 3, + }) + startSessionManager(restoredStore, { + sessionSampleRate: 40, + getSessionConfiguration: () => ({ sessionSampleRate: 12, rcVersion: 7 }), + debug: true, + }) + } finally { + console.log = originalLog + } + + assert.deepEqual(logs, [ + ['[FlashCat RUM SDK][Debug] Using sessionSampleRate', 40], + ['[FlashCat RUM SDK][Debug] Using sessionSampleRate', 12], + ['[FlashCat RUM SDK][Debug] Using sessionSampleRate', 88], + ['[FlashCat RUM SDK][Debug] Using sessionSampleRate', 67], + ]) +})