diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 7ecf938..1a05b15 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: | @@ -90,3 +91,18 @@ 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: + ignoreError: 'true' + 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 }})" + } + } diff --git a/README.md b/README.md index 0f62f25..9b8ee68 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ flashcatRum.init({ service: "my-miniprogram", env: "production", version: "1.0.0", + // 可选:启用 RUM 远程配置 + remoteConfigurationEnabled: true, }); App({ @@ -141,11 +143,13 @@ 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) | +| `remoteConfigurationEnabled` | boolean | ❌ | false | 是否启用远程配置(会话采样率与 `custom`) | +| `beforeSampling` | function | ❌ | - | 创建新 Session 前同步调整采样率 | | `flushInterval` | number | ❌ | 15000 | 上报间隔(毫秒) | | `trackPages` | boolean | ❌ | true | 是否追踪页面 | | `trackActions` | boolean | ❌ | true | 是否追踪用户交互 | @@ -155,6 +159,73 @@ SDK 通过以下机制实现自动追踪,**无需手动关联 APP 事件**: | `debug` | boolean | ❌ | false | 是否开启调试模式 | | `beforeSend` | function | ❌ | - | 数据过滤钩子 | +### 远程配置 + +设置 `remoteConfigurationEnabled: true` 后,SDK 会在初始化时同步读取上次缓存的有效配置,并在初始化完成后异步请求一次 `/api/v2/rum/config`。配置请求不阻塞初始化和事件采集,也不会被记录为 RUM resource 或 error 事件。 + +远程配置只消费两个字段:`rum.sessionSampleRate` 和顶层 `custom`;追踪采样率、回放采样率和隐私等级等字段会被忽略。 + +会话采样只在创建 Session 时执行一次: + +- 冷启动已有有效缓存时,首个新 Session 直接使用缓存中的采样率。 +- 没有缓存时,首个 Session 使用初始化的 `sessionSampleRate`;随后拉取到的新值只影响之后创建的 Session。 +- 当前 Session 不会因配置拉取成功而重新抽签。调用 `flashcatRum.stopSession()` 后,下一次事件创建的新 Session 会使用最新配置。 +- 配置接口不可用、响应非法或缓存不可读时,SDK 安全回退到初始化采样率,不影响正常采集。 + +远程配置沿用现有 `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 文档 ### 初始化 @@ -177,8 +248,13 @@ SDK 通过以下机制实现自动追踪,**无需手动关联 APP 事件**: ### 会话管理 - `flashcatRum.stopSession()` - 结束当前会话 +- `flashcatRum.setForcedSession()` - 标记下一个新建会话必定被采集 - `flashcatRum.getInitConfiguration()` - 获取初始化配置 +### 远程配置 + +- `flashcatRum.getRemoteConfig()` - 获取远程配置中的 `custom`,不可用时返回 `undefined` + ## 调试 如果接入后没有数据上报,开启调试模式: 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/configuration.ts b/packages/core/src/domain/configuration/configuration.ts index 9d4acdb..0993640 100644 --- a/packages/core/src/domain/configuration/configuration.ts +++ b/packages/core/src/domain/configuration/configuration.ts @@ -28,6 +28,13 @@ export interface InitConfiguration { version?: string 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 + */ + remoteConfigurationEnabled?: boolean } export interface Configuration { @@ -42,6 +49,7 @@ export interface Configuration { version?: string debug: boolean trackAnonymousUser: boolean + remoteConfigurationEnabled: boolean } export function validateAndBuildConfiguration(initConfiguration: InitConfiguration): Configuration | undefined { @@ -58,6 +66,7 @@ export function validateAndBuildConfiguration(initConfiguration: InitConfigurati const flushInterval = initConfiguration.flushInterval ?? 15000 const debug = initConfiguration.debug ?? false const trackAnonymousUser = initConfiguration.trackAnonymousUser ?? true + const remoteConfigurationEnabled = initConfiguration.remoteConfigurationEnabled ?? false const configurationTags = buildTags(initConfiguration) const endpointBuilder = createEndpointBuilder(initConfiguration, 'rum', configurationTags) @@ -74,6 +83,7 @@ export function validateAndBuildConfiguration(initConfiguration: InitConfigurati version: initConfiguration.version, debug, trackAnonymousUser, + remoteConfigurationEnabled, } if (debug) { @@ -89,6 +99,7 @@ export function validateAndBuildConfiguration(initConfiguration: InitConfigurati sessionSampleRate: config.sessionSampleRate, flushInterval: `${config.flushInterval}ms`, trackAnonymousUser: config.trackAnonymousUser, + remoteConfigurationEnabled: config.remoteConfigurationEnabled, }) } diff --git a/packages/core/src/domain/configuration/sdkVersion.ts b/packages/core/src/domain/configuration/sdkVersion.ts index e225c7b..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.0' +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..1414fc4 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 { @@ -21,18 +25,46 @@ 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 } export function startSessionManager( store: SessionStore, - { trackAnonymousUser = true, sessionSampleRate = 100 }: { trackAnonymousUser?: boolean; sessionSampleRate?: number } = {}, + { + trackAnonymousUser = true, + sessionSampleRate = 100, + getSessionConfiguration, + beforeSampling, + debug = false, + }: { + trackAnonymousUser?: boolean + sessionSampleRate?: number + getSessionConfiguration?: () => SessionConfiguration + beforeSampling?: BeforeSamplingCallback + debug?: boolean + } = {}, ): SessionManager { let lastExpand = 0 + let forceNextSession = false const sessionHistory = createValueHistory(() => now(), { expireDelay: SESSION_TIME_OUT_DELAY, maxEntries: SESSION_HISTORY_MAX_ENTRIES, @@ -40,7 +72,32 @@ 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) + 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) { @@ -49,29 +106,70 @@ export function startSessionManager( function createSession(): SessionState { const time = now() + let currentConfiguration: SessionConfiguration = { sessionSampleRate, rcVersion: 0, custom: null } + if (getSessionConfiguration) { + try { + currentConfiguration = getSessionConfiguration() + } catch { + // 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 + 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: performDraw(sessionSampleRate), + isTracked, + sessionSampleRate: resolvedSessionSampleRate, + 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 @@ -83,6 +181,9 @@ export function startSessionManager( lastExpand = now() return state }, + setForcedSession: () => { + forceNextSession = true + }, expand: () => { const t = now() if (t - lastExpand < EXPAND_THROTTLE) { @@ -110,3 +211,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 b2511f8..e9c8a4c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,12 +12,20 @@ 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' 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/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-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/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" } } 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 e3e10e4..6de25bc 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,13 +27,19 @@ 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() } if (configuration.debug) { console.log('[FlashCat RUM][Debug] RUM monitoring started', { + sessionSampleRate: sessionManager.findSession()?.sessionSampleRate, trackPages: configuration.trackPages, trackActions: configuration.trackActions, trackRequests: configuration.trackRequests, @@ -131,9 +138,26 @@ 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)) + + // ...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, + getRemoteConfig: remoteConfigurationController.getRemoteConfig, globalContext, userContext, addAction: actionCollection?.addAction || (() => undefined), @@ -170,6 +194,8 @@ export function startRum(configuration: RumConfiguration, adapter: PlatformAdapt stopRequestObservable() rumBatch.stop() rumAssembly.stop() + remoteConfigRenewalSubscription.unsubscribe() + 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..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, 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 @@ -58,8 +70,15 @@ export interface RumConfiguration extends Configuration { rootTraceContext?: TraceContext headerName: string } + remoteConfigurationSource: { + proxy?: string | ProxyFn + site?: string + } + beforeSampling?: BeforeSamplingCallback } +export type { BeforeSamplingCallback, BeforeSamplingContext } + export function validateAndBuildRumConfiguration( initConfiguration: RumInitConfiguration, ): RumConfiguration | undefined { @@ -86,6 +105,11 @@ export function validateAndBuildRumConfiguration( rootTraceContext: initConfiguration.tracing?.rootTraceContext, headerName: initConfiguration.tracing?.headerName ?? 'traceparent', }, + remoteConfigurationSource: { + 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 new file mode 100644 index 0000000..52f9348 --- /dev/null +++ b/packages/miniprogram-rum/src/domain/configuration/remoteConfiguration.ts @@ -0,0 +1,522 @@ +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 + custom: Record | null +} + +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 + getRemoteConfig: () => Record | undefined + 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, + custom: null, + } + + if (!configuration.remoteConfigurationEnabled) { + return { + getSessionConfiguration: () => initialSnapshot, + getRemoteConfig: () => undefined, + 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 + // 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) + if (!endpoint) { + return { + getSessionConfiguration: () => currentSnapshot, + getRemoteConfig: () => undefined, + 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 = normalizeSnapshot(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. + } + } + + /** + * 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() + try { + adapter.removeStorageSync(cacheIndexKey) + } catch { + // Storage is an optimization only. + } + } + + 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 + 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. 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 + } + + 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) { + finish() + } else { + scheduleRetry(appliedVersion, retryIndex) + } + return + } + if (statusCode === 200) { + const parsed = parseResponse(response.data, configuration.sessionSampleRate) + if (!parsed) { + 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 { + 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(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)), + }) + + try { + adapter.request(options) + } catch { + settle(() => scheduleRetry(appliedVersion, retryIndex)) + } + } + + return { + getSessionConfiguration: () => ({ + ...currentSnapshot, + custom: cloneCustom(currentSnapshot.custom), + }), + getRemoteConfig: () => { + const custom = cloneCustom(currentSnapshot.custom) + 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) + } catch { + // Ignore timer implementation failures. + } + }) + retryTimers.clear() + }, + } +} + +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 { + value = JSON.parse(value) + } catch { + return undefined + } + } + if (!isRecord(value)) { + return undefined + } + if ('schema_version' in value && value.schema_version !== 1) { + // 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 ('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 + } + + let sessionSampleRate = initialSessionSampleRate + if (isRecord(value.rum) && 'sessionSampleRate' in value.rum) { + if (!isSampleRate(value.rum.sessionSampleRate)) { + return undefined + } + 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, + }, + } +} + +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.snapshot.custom === undefined || isRecord(value.snapshot.custom) || value.snapshot.custom === null) && + (value.etag === undefined || typeof value.etag === 'string') + ) +} + +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 +} + +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..f6a8e68 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' @@ -23,9 +23,16 @@ export function createSessionStore(adapter: PlatformAdapter): SessionStore { } } -export function startRumSessionManager(adapter: PlatformAdapter, configuration: RumConfiguration): SessionManager { +export function startRumSessionManager( + adapter: PlatformAdapter, + configuration: RumConfiguration, + getSessionConfiguration?: () => SessionConfiguration, +): SessionManager { return startSessionManager(createSessionStore(adapter), { trackAnonymousUser: configuration.trackAnonymousUser, sessionSampleRate: configuration.sessionSampleRate, + getSessionConfiguration, + beforeSampling: configuration.beforeSampling, + debug: configuration.debug, }) } 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/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..62e3fcb 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 remoteConfigurationEnabled to false', () => { + const result = validateAndBuildConfiguration({ clientToken: 'token', applicationId: 'app' }) + + assert.ok(result) + assert.equal(result.remoteConfigurationEnabled, false) +}) + +test('validateAndBuildConfiguration enables remoteConfigurationEnabled explicitly', () => { + const result = validateAndBuildConfiguration({ + clientToken: 'token', + applicationId: 'app', + remoteConfigurationEnabled: true, + }) + + assert.ok(result) + assert.equal(result.remoteConfigurationEnabled, true) +}) 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 new file mode 100644 index 0000000..82cc571 --- /dev/null +++ b/test/remoteConfiguration.test.ts @@ -0,0 +1,593 @@ +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, + remoteConfigurationEnabled: true, + env: 'prod cn', + version: '1.2.3', + ...overrides, + })! +} + +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, custom: null }, + }, + { + 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, custom: { anything: true } }, + }, + { + name: 'missing sampling field', + data: { schema_version: 1, version: 4, enabled: true, rum: { traceSampleRate: 0 } }, + expected: { sessionSampleRate: 73, rcVersion: 4, custom: null }, + }, + ] + + 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, custom: null }, + 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, custom: null }) + 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, custom: null }) + assert.equal(storage.size, 2) + + response = { version: 8, enabled: false, rum: { sessionSampleRate: 0 } } + controller.fetch(7) + // 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() +}) + +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, custom: null }) + second.fetch(9) + assert.equal(secondAdapter.requests[0].header?.['If-None-Match'], '"config-9"') + assert.deepEqual(second.getSessionConfiguration(), { sessionSampleRate: 31, rcVersion: 9, custom: null }) + 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, custom: null }) + 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, custom: null }) + second.fetch() + 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, + ) + 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, custom: null }) + 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, custom: null }) + storageFailure.stop() +}) + +test('disabled remote configuration performs no cache access and no request', () => { + const adapter = createAdapter() + const controller = createRemoteConfigurationController(adapter, configuration({ remoteConfigurationEnabled: false })) + controller.fetch(3) + + assert.equal(adapter.storageReads, 0) + assert.equal(adapter.requests.length, 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: 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?.({ + 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', () => { + 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..d00064e 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) @@ -180,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) @@ -198,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], + ]) +}) diff --git a/test/startRum.test.ts b/test/startRum.test.ts index 25f02f1..efa83d7 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,128 @@ 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, + remoteConfigurationEnabled: 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('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[] = [] + adapter.request = (options: RequestOptions) => { + requests.push(options) + return { abort: () => undefined } + } + const configuration = validateAndBuildRumConfiguration({ + clientToken: 'token', + applicationId: 'app', + remoteConfigurationEnabled: 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==