From 710826404660975b597ea2323237b6b8a48662c6 Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 10 Jul 2026 00:40:48 -0700 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] =?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 06/11] 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 07/11] 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 08/11] =?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], + ]) +}) From f87527fc15eb36e1c3a4850b77a6d9e92583e078 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 06:57:52 -0700 Subject: [PATCH 09/11] fix(rum): ask for the configuration again when a session is renewed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The configuration was requested once, at start-up. A miniprogram process routinely outlives a session: it is backgrounded and foregrounded for hours without a cold launch, so every session drawn after the first went on using whatever the console had published when the app first opened. That is the one promise the feature makes — a change takes effect at the next new session — and on this platform it held only for the session that happened to be first. A renewal now asks again, reporting the version the renewed session was drawn under. The response lands for the session after it, which is the same ordering the other platforms have. Alongside it, three things the extra trigger made matter: Only one request is in flight at a time. Start-up and a renewal can land together on a cold start, and nothing stopped both from going out. A schema version this build cannot read is treated as a settled answer rather than a failure to retry. Asking again only fetches the same refusal. The kill switch keeps the version that set it. Every knob goes back to what the app was initialised with, but clearing the version too meant the next request reported none, and the console could not tell a client that had taken the kill switch from one that had never heard of it. --- packages/miniprogram-rum/src/boot/startRum.ts | 12 ++++ .../configuration/remoteConfiguration.ts | 71 +++++++++++++++---- test/remoteConfiguration.test.ts | 51 ++++++++++++- test/startRum.test.ts | 45 ++++++++++++ 4 files changed, 164 insertions(+), 15 deletions(-) diff --git a/packages/miniprogram-rum/src/boot/startRum.ts b/packages/miniprogram-rum/src/boot/startRum.ts index a69c4bb..6de25bc 100644 --- a/packages/miniprogram-rum/src/boot/startRum.ts +++ b/packages/miniprogram-rum/src/boot/startRum.ts @@ -143,6 +143,17 @@ export function startRum(configuration: RumConfiguration, adapter: PlatformAdapt const appliedVersion = sessionManager.findSession()?.rcVersion void Promise.resolve().then(() => remoteConfigurationController.fetch(appliedVersion)) + // ...and again whenever a session is renewed. A miniprogram process routinely outlives a session: + // it is backgrounded and foregrounded for hours without a cold onLaunch, so a launch-only fetch + // would leave every later session in that process drawing against whatever the configuration was + // when the app first opened. The response lands for the session AFTER this one, which is exactly + // the "takes effect at the next new session" semantics the console promises. The controller + // ignores a call while one is already in flight. + const remoteConfigRenewalSubscription = lifeCycle.subscribe( + LifeCycleEventType.SESSION_RENEWED, + ({ session }) => remoteConfigurationController.fetch(session.rcVersion), + ) + return { lifeCycle, sessionManager, @@ -183,6 +194,7 @@ export function startRum(configuration: RumConfiguration, adapter: PlatformAdapt stopRequestObservable() rumBatch.stop() rumAssembly.stop() + remoteConfigRenewalSubscription.unsubscribe() remoteConfigurationController.stop() requestCollection?.stop() actionCollection?.stop() diff --git a/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts b/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts index 03c518d..52f9348 100644 --- a/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts +++ b/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts @@ -67,6 +67,10 @@ export function createRemoteConfigurationController( let etag: string | undefined let hasRemoteSnapshot = false let stopped = false + // One chain at a time. Init and every session renewal ask for the configuration, and those two + // can land together on a cold start, so without this the very first thing a launch does is send + // the same request twice. + let inFlight = false const retryTimers = new Set() const endpoint = safelyCreateEndpoint(configuration) @@ -145,8 +149,14 @@ export function createRemoteConfigurationController( } } - function resetToInitialization() { - currentSnapshot = initialSnapshot + /** + * The kill switch. Every knob goes back to what the app was initialised with, but the version + * that switched it off is kept: it is what the next request echoes as applied_version, and + * without it the console's rollout view cannot tell a client that took the change from one that + * never heard about it. + */ + function resetToInitialization(version = 0) { + currentSnapshot = { ...initialSnapshot, rcVersion: version } etag = undefined hasRemoteSnapshot = false clearCache() @@ -159,6 +169,8 @@ export function createRemoteConfigurationController( function scheduleRetry(appliedVersion: number | undefined, retryIndex: number) { if (stopped || retryIndex >= RETRY_DELAYS.length) { + // The chain is over: release the guard so the next session renewal can ask again. + inFlight = false return } const jitter = 0.8 + random() * 0.4 @@ -170,10 +182,17 @@ export function createRemoteConfigurationController( }, Math.round(RETRY_DELAYS[retryIndex] * jitter)) retryTimers.add(timer) } catch { - // Timer failures are isolated like request and storage failures. + // Timer failures are isolated like request and storage failures. Nothing will call back, so + // the chain ends here. + inFlight = false } } + /** The chain reached an answer it will not retry. */ + function finish() { + inFlight = false + } + function request(appliedVersion: number | undefined, retryIndex: number) { if (stopped) { return @@ -208,7 +227,9 @@ export function createRemoteConfigurationController( success: (response) => settle(() => { const { statusCode } = response if (statusCode === 304) { - if (!hasRemoteSnapshot) { + if (hasRemoteSnapshot) { + finish() + } else { scheduleRetry(appliedVersion, retryIndex) } return @@ -219,6 +240,12 @@ export function createRemoteConfigurationController( scheduleRetry(appliedVersion, retryIndex) return } + if (parsed.unsupportedSchema) { + // A settled answer, not a failure: the server is describing the configuration in a + // shape this build cannot read, and asking again only fetches the same refusal. + finish() + return + } const nextEtag = findHeader(response.header, 'etag') if (configuration.debug) { try { @@ -232,18 +259,24 @@ export function createRemoteConfigurationController( } } if (parsed.enabled === false) { - resetToInitialization() + resetToInitialization(parsed.version) + finish() return } currentSnapshot = parsed.snapshot etag = nextEtag hasRemoteSnapshot = true persist(parsed.snapshot, nextEtag) + finish() return } if (statusCode === 0 || statusCode === 429 || statusCode >= 500) { scheduleRetry(appliedVersion, retryIndex) + return } + // Any other status is the server's settled answer — a 4xx will say the same thing next + // time — so the values already in force stay and the chain ends here. + finish() }), fail: () => settle(() => scheduleRetry(appliedVersion, retryIndex)), }) @@ -265,14 +298,20 @@ export function createRemoteConfigurationController( return custom || undefined }, fetch: (appliedVersion) => { + if (inFlight) { + return + } + inFlight = true try { request(appliedVersion, 0) } catch { // URL builders and platform adapters are host code and may throw. + inFlight = false } }, stop: () => { stopped = true + inFlight = false retryTimers.forEach((timer) => { try { cancelTimeout(timer) @@ -285,10 +324,14 @@ export function createRemoteConfigurationController( } } -function parseResponse( - data: unknown, - initialSessionSampleRate: number, -): { enabled: false } | { enabled: true; snapshot: SessionConfigurationSnapshot } | undefined { +type ParsedResponse = + /** The contract the body is written to is one this build cannot read; nothing is applied. */ + | { unsupportedSchema: true } + /** The kill switch, carrying the version that set it so it can still be reported. */ + | { unsupportedSchema?: false; enabled: false; version: number } + | { unsupportedSchema?: false; enabled: true; snapshot: SessionConfigurationSnapshot } + +function parseResponse(data: unknown, initialSessionSampleRate: number): ParsedResponse | undefined { let value: unknown = data if (typeof value === 'string') { try { @@ -301,17 +344,19 @@ function parseResponse( return undefined } if ('schema_version' in value && value.schema_version !== 1) { - return undefined + // Told apart from an unreadable body on purpose: this one is a settled answer and retrying it + // just fetches the same refusal, while a body we failed to read may well be a transient fault. + return { unsupportedSchema: true } } 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 (value.enabled === false) { + return { enabled: false, version: isRemoteVersion(value.version) ? value.version : 0 } + } if ('rum' in value && !isRecord(value.rum)) { return undefined } diff --git a/test/remoteConfiguration.test.ts b/test/remoteConfiguration.test.ts index 7af1d06..82cc571 100644 --- a/test/remoteConfiguration.test.ts +++ b/test/remoteConfiguration.test.ts @@ -173,7 +173,10 @@ test('enabled false clears cache and falls back to the initialization rate', () response = { version: 8, enabled: false, rum: { sessionSampleRate: 0 } } controller.fetch(7) - assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0, custom: null }) + // Every knob goes back to the initialization value, but the version that switched it off is + // kept: it is what the next request echoes as applied_version, and without it the console cannot + // tell a client that took the kill switch from one that never heard about it. + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 8, custom: null }) assert.equal(storage.size, 0) controller.stop() }) @@ -353,11 +356,55 @@ test('the kill switch clears custom together with the cache', () => { response = { version: 24, enabled: false } controller.fetch(23) assert.equal(controller.getRemoteConfig(), undefined) - assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0, custom: null }) + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 24, custom: null }) assert.equal(storage.size, 0) controller.stop() }) +test('a schema this build cannot read is a settled answer, not something to retry', () => { + // Told apart from an unreadable body on purpose: asking again would fetch the same refusal. + let requests = 0 + const timers: Array<() => void> = [] + const adapter = createAdapter((options) => { + requests += 1 + options.success?.({ statusCode: 200, data: { schema_version: 2, version: 5, enabled: true, rum: {} } }) + }) + const controller = createRemoteConfigurationController(adapter, configuration(), { + setTimeout: (callback) => { + timers.push(callback) + return timers.length + }, + clearTimeout: () => undefined, + }) + + controller.fetch() + + assert.equal(requests, 1) + assert.equal(timers.length, 0, 'a refusal this definite must not be retried') + assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 0, custom: null }) + controller.stop() +}) + +test('one configuration request at a time, however many triggers arrive', () => { + // Start-up and a session renewal can land together on a cold start. + let requests = 0 + let settle: (() => void) | undefined + const adapter = createAdapter((options) => { + requests += 1 + settle = () => options.success?.({ statusCode: 200, data: { version: 3, enabled: true, rum: { sessionSampleRate: 9 } } }) + }) + const controller = createRemoteConfigurationController(adapter, configuration()) + + controller.fetch() + controller.fetch(0) + assert.equal(requests, 1, 'the second trigger must join the request already in flight') + + settle?.() + controller.fetch(3) + assert.equal(requests, 2, 'once the first answer lands, the next trigger asks again') + 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?.({ diff --git a/test/startRum.test.ts b/test/startRum.test.ts index 5dcd738..efa83d7 100644 --- a/test/startRum.test.ts +++ b/test/startRum.test.ts @@ -230,6 +230,51 @@ test('remote sampling keeps the current session and applies after stopSession', started.stop() }) +test('asks the console again when a session is renewed', async () => { + // A miniprogram process routinely outlives a session: backgrounded and foregrounded for hours + // without a cold onLaunch. Asking only at start-up would leave every later session in that + // process drawing against whatever the configuration was when the app first opened, which is + // the one promise the whole feature makes. + const adapter = createAdapter() + const configRequests: RequestOptions[] = [] + adapter.request = (options: RequestOptions) => { + if (options.url.includes('/api/v2/rum/config')) { + configRequests.push(options) + } + return { abort: () => undefined } + } + + const configuration = validateAndBuildRumConfiguration({ + clientToken: 'token', + applicationId: 'app', + sessionSampleRate: 100, + remoteConfigurationEnabled: true, + trackPages: false, + trackActions: false, + trackPerformance: false, + flushInterval: 100000, + })! + const started = startRum(configuration, adapter) + + await Promise.resolve() + assert.equal(configRequests.length, 1) + configRequests[0].success?.({ + statusCode: 200, + data: { version: 4, enabled: true, rum: { sessionSampleRate: 100 } }, + }) + + started.sessionManager.expire() + started.addCustomEvent('draws-a-new-session') + + assert.equal(configRequests.length, 2, 'a renewed session must ask for the configuration again') + assert.ok( + configRequests[1].url.includes('applied_version=4'), + 'the new request reports the version the renewed session was drawn under' + ) + + started.stop() +}) + test('startRum does not request remote configuration when it is disabled', async () => { const adapter = createAdapter() const requests: RequestOptions[] = [] From a18adce1374d967cb9e1455443307955a32cc441 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 08:01:00 -0700 Subject: [PATCH 10/11] fix(core): one debug log prefix, not two Two tests on this branch contradicted each other: one asserted every console message carries the "[FlashCat RUM]" prefix, the other asserted this particular line carries "[FlashCat RUM SDK]". Both were failing, and had been since they landed, because no prefix can satisfy them at once. The single odd line is brought in line with the other eight, and the test that pinned it to the odd form is corrected. The suite goes green. --- packages/core/src/domain/session/sessionManager.ts | 2 +- test/sessionManager.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index 1414fc4..9dba00e 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -93,7 +93,7 @@ export function startSessionManager( sessionHistory.add(cloneSessionState(initialSession), initialSession.created) if (debug) { try { - console.log('[FlashCat RUM SDK][Debug] Using sessionSampleRate', initialSession.sessionSampleRate) + console.log('[FlashCat RUM][Debug] Using sessionSampleRate', initialSession.sessionSampleRate) } catch { // Console implementations are host code and must not affect session restore. } diff --git a/test/sessionManager.test.ts b/test/sessionManager.test.ts index d00064e..7c6d3a8 100644 --- a/test/sessionManager.test.ts +++ b/test/sessionManager.test.ts @@ -502,9 +502,9 @@ test('sessionManager logs the resolved sessionSampleRate when debug is enabled', } 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], + ['[FlashCat RUM][Debug] Using sessionSampleRate', 40], + ['[FlashCat RUM][Debug] Using sessionSampleRate', 12], + ['[FlashCat RUM][Debug] Using sessionSampleRate', 88], + ['[FlashCat RUM][Debug] Using sessionSampleRate', 67], ]) }) From a0c48febfa7f47ee41377a42a83c57045f460f3c Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 08:14:57 -0700 Subject: [PATCH 11/11] fix(rum): stop a lost callback from wedging configuration for the whole process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems with the guard added a moment ago, both found reviewing that change rather than the code it fixed. The guard was a flag, released only by a callback. The request timeout is an option handed to the host, and nothing guarantees it answers: a request whose success and fail callbacks both go missing left the flag set for the life of the process, and every later session renewal was dropped without a sound. That is worse than what it replaced, where a lost callback cost one fetch. It is now the moment the request started, and a chain older than the timeout no longer counts as in flight — no timer to arm, none to cancel, and the next session renewal simply asks again. The kill switch kept the version that switched it off, but only in memory: the same call cleared the cache. A miniprogram process is reclaimed far more readily than an app, and on the next launch the client reported no version at all — indistinguishable, to the console, from one that never heard about the change. The kill-switch state is written instead of cleared. Nothing stale can come back with it: every knob in it is already the initialization value. --- .../configuration/remoteConfiguration.ts | 42 ++++++++++++------- test/remoteConfiguration.test.ts | 12 +++++- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts b/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts index 52f9348..440cc6b 100644 --- a/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts +++ b/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts @@ -6,6 +6,13 @@ import type { RumConfiguration } from './configuration' const CONFIG_PATH = '/api/v2/rum/config' const CACHE_FORMAT_VERSION = 1 const REQUEST_TIMEOUT = 10_000 + +// How long past the platform's own timeout a request is still considered in flight. The timeout +// above is an option handed to the host, and nothing guarantees it answers at all: a request whose +// success and fail callbacks both go missing would otherwise hold the guard for the life of the +// process, and every later session renewal would be dropped without a sound. Expiring the guard by +// the clock costs no timer and needs no cancelling — the next session renewal simply asks again. +const IN_FLIGHT_MAX_MS = REQUEST_TIMEOUT + 5_000 const RETRY_DELAYS = [5_000, 60_000] export const REMOTE_CONFIGURATION_STORAGE_KEY_PREFIX = '_fc_rum_remote_config_v1_' @@ -69,8 +76,13 @@ export function createRemoteConfigurationController( let stopped = false // One chain at a time. Init and every session renewal ask for the configuration, and those two // can land together on a cold start, so without this the very first thing a launch does is send - // the same request twice. - let inFlight = false + // the same request twice. Held as the moment it started rather than as a flag, so a chain whose + // callbacks never arrive stops blocking the next trigger instead of blocking every one after it. + let inFlightSince: number | undefined + + function inFlight(): boolean { + return inFlightSince !== undefined && Date.now() - inFlightSince < IN_FLIGHT_MAX_MS + } const retryTimers = new Set() const endpoint = safelyCreateEndpoint(configuration) @@ -159,18 +171,18 @@ export function createRemoteConfigurationController( currentSnapshot = { ...initialSnapshot, rcVersion: version } etag = undefined hasRemoteSnapshot = false - clearCache() - try { - adapter.removeStorageSync(cacheIndexKey) - } catch { - // Storage is an optimization only. - } + // Written, not cleared. Every knob is already back at its initialization value, so there is + // nothing stale to resurrect; what survives is the version that switched the channel off. A + // miniprogram process is killed and restarted far more readily than an app, and a client that + // dropped the version on restart would report none at all — indistinguishable, to the console, + // from one that never heard about the change. + persist(currentSnapshot) } function scheduleRetry(appliedVersion: number | undefined, retryIndex: number) { if (stopped || retryIndex >= RETRY_DELAYS.length) { // The chain is over: release the guard so the next session renewal can ask again. - inFlight = false + inFlightSince = undefined return } const jitter = 0.8 + random() * 0.4 @@ -184,13 +196,13 @@ export function createRemoteConfigurationController( } catch { // Timer failures are isolated like request and storage failures. Nothing will call back, so // the chain ends here. - inFlight = false + inFlightSince = undefined } } /** The chain reached an answer it will not retry. */ function finish() { - inFlight = false + inFlightSince = undefined } function request(appliedVersion: number | undefined, retryIndex: number) { @@ -298,20 +310,20 @@ export function createRemoteConfigurationController( return custom || undefined }, fetch: (appliedVersion) => { - if (inFlight) { + if (inFlight()) { return } - inFlight = true + inFlightSince = Date.now() try { request(appliedVersion, 0) } catch { // URL builders and platform adapters are host code and may throw. - inFlight = false + inFlightSince = undefined } }, stop: () => { stopped = true - inFlight = false + inFlightSince = undefined retryTimers.forEach((timer) => { try { cancelTimeout(timer) diff --git a/test/remoteConfiguration.test.ts b/test/remoteConfiguration.test.ts index 82cc571..5d489c0 100644 --- a/test/remoteConfiguration.test.ts +++ b/test/remoteConfiguration.test.ts @@ -177,7 +177,16 @@ test('enabled false clears cache and falls back to the initialization rate', () // kept: it is what the next request echoes as applied_version, and without it the console cannot // tell a client that took the kill switch from one that never heard about it. assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 8, custom: null }) - assert.equal(storage.size, 0) + // And it is written, not merely held: a miniprogram process is reclaimed readily, and a version + // that lived only in memory would be gone on the next launch — leaving the console unable to tell + // this client from one that never heard about the change. + const relaunched = createRemoteConfigurationController( + createAdapter(() => undefined, storage), + configuration(), + ) + assert.equal(relaunched.getSessionConfiguration().rcVersion, 8, 'the version survives a relaunch') + assert.equal(relaunched.getSessionConfiguration().sessionSampleRate, 73, 'the knobs stay at initialization') + relaunched.stop() controller.stop() }) @@ -357,7 +366,6 @@ test('the kill switch clears custom together with the cache', () => { controller.fetch(23) assert.equal(controller.getRemoteConfig(), undefined) assert.deepEqual(controller.getSessionConfiguration(), { sessionSampleRate: 73, rcVersion: 24, custom: null }) - assert.equal(storage.size, 0) controller.stop() })