Skip to content

Commit 23e3bed

Browse files
frose-devaditi-s3mbudinskySMickelsncarlosmcevilly
authored
DEMRUM-6211: Release 2.4.1 (#709)
* DEMRUM-577: Implement explicit stack Error tracking API (#702) * DEMRUM-5771: Implement custom raw error tracking API * DEMRUM-5771: Fix lint & format * DEMRUM-5771: Address code review remarks 1 * DEMRUM-5771: Fix map lining format * DEMRUM-5369: Update attribute to development.environment.name (#701) * DEMRUM-5369: Update to development.environment.name * DEMRUM-5369: Reorganize unit tests * DEMRUM-6055: Rewrite RELEASING.md with current release process (#693) * DEMRUM-6055: Rewrite RELEASING.md with current release process * DEMRUM-6055: Restore SPM tag note, build/test steps, and VERSION placeholder convention * DEMRUM-6055: Refine release workflow and recovery guidance * DEMRUM-6055: Add post-upload XCFramework asset verification * DEMRUM-6055: Clarify Swift Package and XCFramework distribution * DEMRUM-6028: Slow frame detection accuracy (#696) * DEMRUM-6028: Improve iOS slowRenders/frozenRenders detection accuracy * Derive expected cadence per frame from targetTimestamp - timestamp instead before slow * Count one continuous freeze episode once instead of once per 700ms tick, coordinated between the watchdog and handleFrame via an inFreezeEpisode flag * Preserve the existing slowRenders/frozenRenders/count telemetry contract * DEMRUM-6028: Ignore frames delivered while inactive * Add an actor-owned isActive flag to SlowFrameLogic, gated in handleFrame and toggled by the lifecycle handlers * Closes a race between the frame-consuming AsyncStream task and the lifecycle-notification task: a frame already in flight when the app resigns active could previously re-arm the heartbeat after appWillResignActive had reset it, letting the watchdog count a spurious frozen frame while backgrounded * DEMRUM-6028: Fix stale doc comment and copyright years * Correct the slowFrameTolerance doc comment: it avoids false negatives on a frame that missed its deadline by only a jitter margin, not false positives * Update copyright year to 2026 on two new test support files * DEMRUM-6028: Add watchdog recovery test; split logic test file * Add testWatchdogCountsSecondDistinctFreezeAfterRecovery, proving a recovery frame clears inFreezeEpisode so a second distinct freeze is counted independently by the watchdog. * Move the four wall-clock/watchdog-dependent tests and the polling helper into a new SlowFrameLogicWatchdogTests, mirroring the existing deterministic/integration test split, keeping each file under the 400-line limit. * Use a generous 4.0s poll timeout since the watchdog phase is independent of the heartbeat and detection can exceed one threshold interval under CI scheduling jitter. * DEMRUM-6028: Fix watchdog recovery test flush race * Poll the total frozen episode count (buffered plus flushed) instead of the actor's buffered count alone. The one-second flush loop drains the buffered count, so it oscillated and never reached 2, timing out the second-episode wait. * DEMRUM-6028: Update slow/frozen frame documentation * Update the DocC page to state that the module reports periodic aggregated slow and frozen frame counts, not one span per frame. * Note that the deprecated slowRenderingDetectionEnabled builder method toggles both slow and frozen frame spans, not slow frames only. * DEMRUM-6028: Split watchdog tests and fix doc accuracy * Move the four wall-clock watchdog tests into a new SlowFrameLogicWatchdogTests class, resolving the file_length violation and isolating timing-dependent tests from the deterministic handleFrame suite. * Drop start() from the deterministic suite's setUp so those tests run without the background watchdog and flush timers. * Raise the watchdog poll timeout to 4.0s for CI robustness. * Correct docs to describe cadence-based slow classification and aggregated-count flushing, and fix a garbled MARK comment in the agent-level proxy. * DEMRUM-6028: Fix watchdog test flake and docc accuracy * Reverse the read order in the frozen-episode polling helper so a flushed count is read before the buffered count. Reading buffered first let an event that migrated from the buffer to the destination between the two reads appear in both, double-counting one episode and making the recovery test flake. * Rewrite testFrozenFrameIsDetectedWhenFramesStop to reuse that helper instead of spawning its own unretained polling task, so the poll loop is cancelled on both success and failure rather than surviving a failed test. * Correct the Slow & Frozen Frame Detection docc: each flush emits a span only for a nonzero count, not always both types. Add the missing final newline. * DEMRUM-6028: Rename slowFrameTolerance to cadenceJitterMargin * Rename the internal slow-frame slack constant to break the naming echo of the removed slowFrameTolerancePercentage, which could read as though the value were still a configurable tolerance. The new name states the constant's role: a small margin absorbing floating-point and measurement jitter in the per-frame cadence comparison. Behavior and value (0.001s) are unchanged. * DEMRUM-6028: Classify threshold-length stalls consistently as frozen * Measure the frozen-frame threshold against the previous frame's timestamp (the inter-frame gap) instead of the target-relative lateness. The watchdog already measures elapsed time since the last frame, so a stall in the [700ms, 700ms + one cadence) window was classified slow or frozen depending on watchdog timing phase. Both paths now share the 700ms boundary on the same timebase. * Keep target-relative lateness for the slow-frame test, which is about missed presentation deadlines rather than main-thread unresponsiveness. * Add two deterministic boundary tests constructed so the inter-frame gap (not the target lateness) hits the threshold: one at exactly 700ms asserting frozen, one just below asserting slow. * Correct a stale comment in the watchdog test helper about the polled total being monotonic. * DEMRUM-6028: Discard buffered pre-background ticks after foregrounding * Add an activation-timestamp fence in SlowFrameLogic so a CADisplayLink tick produced before backgrounding, but delivered to the actor only after appDidBecomeActive, is rejected instead of seeding the baseline. * Capture CACurrentMediaTime() at foreground and reset the logic before resuming the ticker, so a stale tick is fenced by timestamp while a fresh post-resume tick is still accepted. * Prevents the next real frame's gap from spanning the whole background interval and emitting a spurious frozenRenders event; the isActive guard alone could not catch this, since it reflects when a tick is processed rather than when it was produced. * Add a deterministic regression test for the stale-tick ordering. * DEMRUM-6028: Consolidate duplicate Unreleased Fixed heading * Merge the two DEMRUM-6028 bullets into the single [Unreleased] Fixed section and drop the duplicate heading. * DEMRUM-6028: Move changelog entries to Unreleased * DEMRUM-6028: Add lifecycle integration coverage * DEMRUM-5269: Add userActivity timestamps to session replay metadata for timeline visualization (#646) * Add userActivity timestamps to session replay metadata for timeline visualization * Fix lint, format, and build failures in userActivity PR - Make activityStream public so SplunkAgent can access it across modules - Refactor fallback JSON path in SessionReplayDataEvent to stay within line length limit - Collapse sendEvent trailing closure in DefaultEventManager to reduce closure body length - Add blank line after #if in switch to satisfy vertical_whitespace_between_cases - Fix import order in SpanExtensionsTests to satisfy swift-format OrderedImports * Fix import order in test files to satisfy swift-format OrderedImports * Add swift-format-ignore-file to test files with @_spi/@testable imports * Suppress sorted_imports in test files with mixed @testable/@_spi imports * Fix swiftformat and swiftlint sorted_imports in test files after develop merge * DEMRUM-5269: Fix format check and address review feedback - Fix swift-format indentation/brace style in customizeInteractions() - Restrict activityStream to @_spi(SplunkInternal) to prevent external consumers from draining the stream - Cap activityStream buffer to 1000 entries (bufferingNewest) to avoid unbounded memory growth when replay is inactive Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Fix JSON escaping in fallbackJSON and clarify flush doc comment - Use JSONSerialization for fallbackJSON so metadata.source is properly escaped instead of interpolated directly into the string - Clarify flush(startMs:endMs:) doc comment: timestamps after endMs are retained, not discarded Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Fix swift-format doc comment warning in UserActivityCollector Add blank line after one-line summary in flush(startMs:endMs:) doc comment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Fix swiftformat blankLinesAfterGuardStatements in fallbackJSON Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * DEMRUM-5269: Address PR review findings - Fix test cast from [NSNumber] instead of [Int] when deserializing userActivity via JSONSerialization - Fix last-resort fallback JSON missing source field and add proper escaping - Restore CrashReportKeys to public access level (was accidentally narrowed from public) - Move swift-format-ignore-file directives before import statements so they are recognized - Clarify UserActivityCollector.flush doc comment to reflect that pre-window timestamps are discarded Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * DEMRUM-5269: Fix last-resort fallback JSON line length Split long interpolation into local variables to satisfy swift-format LineLength rule (160 char limit). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * DEMRUM-5269: Remove superfluous swiftlint disable comment The line is within the 160-char limit so the disable comment triggers superfluous_disable_command violation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * DEMRUM-5269: Address vaclav-halik-smartlook review findings - Fix flush() to remove only timestamps in [startMs,endMs] window; previously `timestamps.filter { $0 > endMs }` also discarded pre-startMs timestamps that could belong to a concurrent segment flush - Add maxBufferSize=10_000 cap to UserActivityCollector to prevent unbounded growth when recording is paused or stopped for extended periods - Clarify activityStream doc comment to document best-effort delivery semantics (.bufferingNewest(1_000) can drop oldest timestamps under backpressure) - Add reset() to UserActivityCollector for callers that need an explicit clear Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * DEMRUM-5269: Fix doc comment format in UserActivityCollector swift-format requires a blank line after the one-line summary sentence. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * DEMRUM-5269: Address Codex review — retry restore, cap, escaping, access level - Revert CrashReportKeys to internal (was mistakenly made public in response to an outdated Copilot comment; on develop it has been internal since #658) - Restore flushed timestamps to collector on sendEvent failure so they are included when the segment is retried (fixes silent loss on processed==false) - Add restore() to UserActivityCollector for the retry path - Fix buffer cap: append first, then batch-trim with removeFirst(_:) instead of calling removeFirst() per-record (O(n) shift on hot path) - Fix last-resort JSON fallback to escape all JSON control characters (newline, tab, backspace, etc.) not just backslash and quote - Add UserActivityCollectorTests covering window partitioning, concurrent segment flushes, retry restore, buffer cap, and reset Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * DEMRUM-5269: Eliminate yield→record race via synchronous onActivity callback Replace the async activityStream+Task pipeline with a direct synchronous callback on Interactions.onActivity. Previously, yield() and record() ran on separate tasks so a segment flush could drain the collector before an already-yielded timestamp was written — silently losing it. With onActivity the timestamp is recorded inside handleEvent(), atomically before the event is forwarded to the destination. No async hop, no race. Changes: - Interactions: replace AsyncStream+Continuation with `onActivity: ((Date)->Void)?` called synchronously in handleEvent - UserActivityCollector: convert from actor to NSLock-guarded final class so it can be called from any context without await - SplunkRum+Modules: wire collector.record via onActivity closure; remove the now-unnecessary userActivityTask and its Task wrapper - SplunkRum: remove userActivityTask property and empty deinit - DefaultEventManager: flush/restore are now synchronous — remove await - UserActivityCollectorTests: remove async/await (not needed for sync class) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * DEMRUM-5269: Fix lint/format violations after develop merge - UserActivityCollectorTests: rename loop var 'i' to 'idx' (identifier_name) - UserActivityCollector/DefaultEventManager/SessionReplayDataEvent: fix swiftformat issues (elseOnSameLine, blankLinesAfterGuardStatements, wrapConditionalBodies, switch_case_on_newline, blankLineAfterSwitchCase, consecutiveSpaces, numberFormatting) - OTLPSpan/SpanEvent/SpanLink/Resource/InstrumentationScope/LogRecord: rename shadowed 'count' local vars to use the optional directly — fixes empty_count swiftlint violations introduced by DEMRUM-5867 merge Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * DEMRUM-5269: Wire collector.reset() to sessionReplay.stop() lifecycle Add onStop callback to SessionReplay proxy and call collector.reset() when the user (or the module itself on app exit) stops recording. Without this, timestamps accumulated during a recording session would bleed into segments produced after the next start(), and the buffer would grow unboundedly while recording was paused. Add two lifecycle tests: verify that reset() clears pre-stop timestamps and that they do not reappear after recording restarts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * DEMRUM-5269: Address post-review findings - Guard `Interactions.onActivity` with an NSLock so the host-app install path and the detector task cannot race on read/write. - Add explicit recording gate to `UserActivityCollector`: `record(at:)` is a no-op unless Session Replay has entered the recording state, preventing unbounded growth when SR is sampled out or not installed. - Amortize the buffer cap: drop `trimSlack` (1024) entries at once instead of shifting ~10 000 elements on every event at the cap. - Preserve the final segment's activity on `stop()`: no longer eager- reset the collector before `module.stop()` — publish still flushes buffered events for the last record. - Add a generation token returned by `flush()` and consumed by `restore()`; a stop/reset invalidates the token, so a delayed failure callback cannot leak the previous session's activity into a new one. - Reset the collector on `start()` rather than on `stop()`, and prime it at install time (Cisco SR auto-starts recording on install). - Attach `onActivity` wiring regardless of `InteractionsConfiguration .isEnabled`; document that when the Interactions detector is disabled the collector receives no events (source of truth is the detector). - Flush semantics: include timestamps `<= endMs` in the current segment so late-arriving events whose timestamp falls in a past window are attached to the next segment rather than orphaned. - Update tests to cover: recording gate default-off, late-arrival, retry restore with generation, restore after reset dropped, sequential flushes, cap trim behavior, and concurrent onActivity read/write. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * DEMRUM-5269: Drive UserActivityCollector state from Cisco SR isRecording publisher Address post-review codex findings on b09a005: - Redundant SessionReplay.start() calls no longer discard the current segment's buffered activity. The collector is reset only on a real stopped->recording transition, driven by Cisco SR's @published isRecording property (deduplicated at the publisher level). - Autonomous SR state changes (diskCacheCapacityOverreached, internal errors) that stop recording without going through our proxy now flip the collector's recording gate, so the hot append path is disabled instead of relying solely on the buffer cap. The proxy-level onStart/onStop callback hooks are removed; the Combine subscription in wireUserActivityCollector is the single source of truth. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * DEMRUM-5269: Filter flush timestamps by both startMs and endMs bounds Segment ownership was ambiguous when publish tasks ran concurrently: a later-started segment could consume the earlier segment's activity because flush() only checked endMs and swept every timestamp <= endMs into the current batch. When two segments' publish tasks overlapped (e.g. B prepared before A's failure retry) B silently absorbed A's timestamps. Filter strictly by [startMs, endMs] so each segment picks up exactly the events that occurred inside its own window, and retain everything else for whichever segment actually owns it. Tests cover reverse-order flushes, failure-then-neighbour scenarios, and events outside any active window. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * DEMRUM-6211: Prepare 2.4.1 version --------- Co-authored-by: Aditi Sharma <aditis3@cisco.com> Co-authored-by: mbudinsky <23032136+mbudinsky@users.noreply.github.com> Co-authored-by: Stephen Mickelsen <122052675+SMickelsn@users.noreply.github.com> Co-authored-by: Carlos McEvilly <12955+carlosmcevilly@users.noreply.github.com> Co-authored-by: Filip Růžička <58311864+froseio@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2 parents bf81b47 + caa5526 commit 23e3bed

51 files changed

Lines changed: 2257 additions & 367 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/ISSUE_TEMPLATE/bug.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ body:
1919
label: 2. Agent Version
2020
description: Please select the exact version of the `splunk-otel-ios` agent you are using.
2121
options:
22+
- 2.4.1
2223
- 2.4.0
2324
- 2.3.2
2425
- 2.3.1

‎Applications/DevelApp/DevelApp/CustomTrackingDemo/CustomTrackingDemoView.swift‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ struct CustomTrackingDemoView: View {
7777
}
7878
)
7979

80+
FeatureSection(
81+
title: "Explicit Stacktrace Error Tracking (hybrid agents)",
82+
content: FeatureButton(label: "Track Explicit Error (JS stack)") {
83+
trackExplicitError()
84+
}
85+
)
86+
8087
FeatureSection(
8188
title: "Legacy Tracking",
8289
content: VStack(spacing: 8) {
@@ -136,6 +143,33 @@ struct CustomTrackingDemoView: View {
136143
}
137144

138145

146+
// MARK: - Explicit stacktrace error tracking
147+
148+
/// Simulates how the React Native / Flutter bridge reports a caught JS/Dart
149+
/// error: an explicit type, message, and verbatim (here Hermes-style) stack,
150+
/// with the hybrid layer's context carried through `attributes`.
151+
func trackExplicitError() {
152+
let frames = [
153+
"TypeError: undefined is not a function (evaluating 'cart.checkout()')",
154+
" at Checkout (index.android.bundle:1:537284)",
155+
" at onPress (index.android.bundle:1:540122)"
156+
]
157+
let stacktrace = frames.joined(separator: "\n")
158+
159+
SplunkRum.shared.customTracking.trackError(
160+
typeName: "TypeError",
161+
message: "undefined is not a function (evaluating 'cart.checkout()')",
162+
stacktrace: stacktrace,
163+
attributes: [
164+
"screen.name": "Cart",
165+
"error.source": "custom",
166+
"exception.escaped": false,
167+
"rum.sdk.rn.version": "react-native"
168+
]
169+
)
170+
}
171+
172+
139173
// MARK: - Legacy API calls demonstrating available but deprecated methods
140174

141175
// Note:

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
99

10+
## [2.4.1] - 2026-08-13
11+
12+
### Changed
13+
14+
* Deployment environment resources now use the OpenTelemetry `deployment.environment.name` attribute instead of `deployment.environment`
15+
16+
### Added
17+
18+
* Session Replay segment metadata now includes a `userActivity` field containing Unix-millisecond timestamps of user interactions that occurred during the segment window, enabling timeline visualization in the backend. Timestamps are only collected while Session Replay is recording; disabling `InteractionsConfiguration` does not affect Session Replay segment recording but does prevent user-activity timestamps from being collected because they originate from the Interactions detector. #646
19+
* Added `customTracking.trackError(typeName:message:stacktrace:attributes:)` for reporting an error with an explicitly supplied stacktrace. Unlike the existing `trackError` overloads, the supplied stack is emitted verbatim as `exception.stacktrace` (no native stack is derived) and the resulting `component=error` span is named after `typeName` (falling back to `"error"`). This is the native emission path for caught JavaScript/Dart errors bridged from the React Native and Flutter agents. A matching Objective-C selector (`trackErrorWithType:message:stacktrace:attributes:`) is also available.
20+
21+
### Fixed
22+
23+
* Improved accuracy of `slowRenders` and `frozenRenders` detection: the detector now derives the expected cadence per frame, counts a continuous freeze once, and makes slow and frozen classifications mutually exclusive. #696
24+
* Fixed a spurious `frozenRenders` event that could be emitted when the app backgrounded while a frame was in flight. #696
25+
1026
## [2.4.0] - 2026-07-20
1127

1228
### Fixed

‎RELEASING.md‎

Lines changed: 82 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,84 @@
11
# How to Release Splunk RUM for iOS
22

3-
This project is distributed as a Swift Package. The release process is driven by creating and pushing versioned Git tags.
4-
5-
## Automated workflow
6-
7-
- An action named “New release” is available in the Actions tab of the repository.
8-
- Fill in the required fields and select the target branch.
9-
- The action will prepare a release pull request.
10-
- After the release pull request is merged into main, a new tag and release will be created automatically, and a new pull request into develop will be opened.
11-
12-
## Manual workflow
13-
14-
### 1. Prepare the Release
15-
16-
1. Ensure the `main` branch is stable and all changes for the release have been merged.
17-
2. Update `CHANGELOG.md` by moving all items from the `[Unreleased]` section to a new version heading (e.g., `[2.0.0] - YYYY-MM-DD`).
18-
3. Update the `version` constant in `SplunkAgent/Sources/SplunkAgent/Public API/SplunkRum.swift`.
19-
20-
### 2. Run Final Checks
21-
22-
Build the project and run the full test suite from the command line to ensure everything is passing.
23-
24-
```bash
25-
# Build the project
26-
xcodebuild build -scheme SplunkAgent -destination 'generic/platform=iOS Simulator'
27-
28-
# Run the tests
29-
xcodebuild test -scheme SplunkAgent -destination 'platform=iOS Simulator,name=iPhone 16'
30-
```
31-
32-
### 3. Tag and Push the Release
33-
34-
Create a signed Git tag for the new version. Swift Package Manager uses these tags to resolve package versions.
35-
36-
```bash
37-
# Create a signed tag (e.g., for version 2.0.0) with a 'v' prefix
38-
git tag -s v2.0.0
39-
40-
# Push the tag to the remote repository
41-
git push origin v2.0.0
42-
```
43-
44-
### 4. Publish on GitHub
45-
46-
1. Go to the "Releases" page in the GitHub repository and click "Draft a new release".
47-
2. Choose the tag you just pushed.
48-
3. For the release notes, copy and paste the content from the corresponding version section in `CHANGELOG.md`.
49-
4. Click the **Publish release** button. This will finalize the release, making it public and visible to the community.
50-
3+
This project is distributed through Swift Package Manager and as signed XCFramework archives attached to GitHub releases. Versioned Git tags define the versions available to Swift Package Manager.
4+
5+
## Pre-flight
6+
7+
1. Confirm all features for the release are merged to `develop`.
8+
2. Confirm `CHANGELOG.md` on `develop` has complete `[Unreleased]` entries, each with a PR link (e.g., `#123`).
9+
10+
## Release
11+
12+
3. Run the **"New release"** workflow from the Actions tab, targeting `develop`, with the version (e.g., `2.3.2`) and Jira ticket (e.g., `DEMRUM-1234`). The action will prepare a release pull request.
13+
4. On the resulting PR, click **"Approve workflows to run"** when prompted, obtain the required approvals, and wait for the required checks.
14+
5. Merge the PR into `main` with a **regular merge commit only** (not squash or rebase). If the CLA check remains pending after everything else passes, use the administrator workaround below.
15+
6. After the release PR is merged into `main`, a new tag and release will be created automatically, and a new back-merge PR into `develop` will be opened.
16+
17+
## Sign and upload XCFrameworks
18+
19+
7. With the Apple Distribution certificate and private key in your keychain and the Session Replay repository checked out locally, replace `<VERSION>` and `<APPLE_DISTRIBUTION_SIGNING_IDENTITY>` with the release values and upload both distributions:
20+
```bash
21+
export SESSION_REPLAY_LOCAL_PATH=/path/to/session-replay
22+
tools/xcframework/scripts/sign-and-upload.sh <VERSION> \
23+
--identity "<APPLE_DISTRIBUTION_SIGNING_IDENTITY>" \
24+
--upload-to <VERSION>
25+
tools/xcframework/scripts/sign-and-upload.sh <VERSION> \
26+
--identity "<APPLE_DISTRIBUTION_SIGNING_IDENTITY>" \
27+
--ios-only \
28+
--upload-to <VERSION>
29+
```
30+
8. Open the GitHub release for `<VERSION>` and confirm both `SplunkAgent-XCFrameworks.zip` and `SplunkAgent-XCFrameworks-iOS-only.zip` are attached and downloadable. This check validates the uploaded XCFramework distributions; the SPM smoke test below does not validate these archives.
31+
32+
## Back-merge
33+
34+
9. On the back-merge PR, click **"Approve workflows to run"** when prompted, obtain the required approvals, and wait for the required checks.
35+
10. Ask a repository administrator to temporarily disable **"Require linear history"** for `develop`, merge the PR into `develop` with a **regular merge commit only** (not squash or rebase), and immediately re-enable the rule. If the CLA check remains pending after everything else passes, the administrator should bypass it during this merge.
36+
37+
## Smoke test
38+
39+
11. In a test app, add an SPM dependency on `signalfx/splunk-otel-ios` at the new version.
40+
12. In Xcode: **File → Packages → Reset Package Caches**, then **File → Packages → Resolve Package Versions**.
41+
13. Build and run. Confirm the session appears in a testing realm with `rum.sdk.version` matching the new version.
42+
43+
## Manual recovery
44+
45+
Use this only when an automated workflow fails. Inspect the failed run and complete only the missing steps; do not recreate a branch, tag, release, or PR that already exists.
46+
47+
### Prepare the release PR
48+
49+
1. Create the release branch from `develop`:
50+
```bash
51+
git switch develop
52+
git pull --ff-only
53+
git switch -c release/<VERSION>
54+
```
55+
2. In `CHANGELOG.md`, rename `[Unreleased]` to `[<VERSION>] - YYYY-MM-DD` and add a new empty `[Unreleased]` section above it.
56+
3. Bump the version constant in `SplunkAgent/Sources/SplunkAgent/Public API/SplunkRum.swift`.
57+
4. Add `<VERSION>` to the version dropdown in `.github/ISSUE_TEMPLATE/bug.yml`.
58+
5. Build the project and run the full test suite using an installed simulator:
59+
```bash
60+
xcodebuild -scheme SplunkAgent -destination 'generic/platform=iOS Simulator' build
61+
xcodebuild -scheme SplunkAgent -showdestinations
62+
xcodebuild -scheme SplunkAgent -destination 'OS=<installed OS>,name=<installed iPhone simulator>' test
63+
```
64+
6. Create a signed commit, push the branch, and open a PR from `release/<VERSION>` to `main`. Title: `DEMRUM-XXXX: Release <VERSION>`.
65+
7. Merge the PR with a **regular merge commit only** (not squash or rebase). This starts the **"Release finalize"** workflow.
66+
67+
### Complete a failed Release finalize workflow
68+
69+
The workflow creates the tag, GitHub release, and back-merge PR in that order. Check which artifacts exist and perform only the missing steps.
70+
71+
1. If the `<VERSION>` tag is missing, create and push it without a `v` prefix. Swift Package Manager uses this tag to resolve the release:
72+
```bash
73+
git switch main
74+
git pull --ff-only
75+
git tag -s <VERSION> -m "Release <VERSION>"
76+
git push origin <VERSION>
77+
```
78+
2. If the GitHub release is missing, draft one for the `<VERSION>` tag, use the `[<VERSION>]` section from `CHANGELOG.md` as its notes, and publish it.
79+
3. If the back-merge PR is missing, open one from `main` to `develop`. Title: `NO-TICKET: Merge back <VERSION> to develop`.
80+
4. Complete the back-merge steps above.
81+
82+
## Known issue: pending CLA check
83+
84+
The `ContributorLicenseAgreement` check on bot-created PRs may remain at "Waiting for status to be reported." After every other required check and review passes, ask a repository administrator to bypass the pending check and perform a regular merge commit. This workaround applies to both the release and back-merge PRs until the workflow is fixed.

‎SplunkAgent/Sources/SplunkAgent/Agent/Events/Event Manager/DefaultEventManager.swift‎

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ class DefaultEventManager: AgentEventManager {
7979

8080
var sessionReplayIndexer: EventIndexer
8181
var sessionReplayMemorizer: EventMemorizer
82+
let userActivityCollector = UserActivityCollector()
8283

8384
/// Agent reference.
8485
unowned let agent: SplunkRum
@@ -149,6 +150,7 @@ class DefaultEventManager: AgentEventManager {
149150

150151
// Crash Reports module data
151152
#if canImport(SplunkCrashReports)
153+
152154
case let (metadata as CrashReportsMetadata, data as String):
153155
publishCrashReports(data: data, metadata: metadata, completion: completion)
154156
#endif
@@ -196,29 +198,29 @@ class DefaultEventManager: AgentEventManager {
196198
// Use scriptInstanceId as a 16 character substring of a sessionId
197199
let scriptInstanceId = String(sessionId.prefix(upTo: sessionId.index(sessionId.startIndex, offsetBy: 16)))
198200

201+
let flush = userActivityCollector.flush(startMs: metadata.startUnixMs, endMs: metadata.endUnixMs)
202+
199203
let event = SessionReplayDataEvent(
200204
metadata: metadata,
201205
data: data,
202206
index: eventIndex,
203207
sessionId: sessionId,
204-
scriptInstanceId: scriptInstanceId
208+
scriptInstanceId: scriptInstanceId,
209+
userActivity: flush.timestamps
205210
)
206211

207-
sessionReplayProcessor.sendEvent(
208-
event: event,
209-
immediateProcessing: false,
210-
completion: { [weak self] processed in
211-
if processed {
212-
self?
213-
.removeSessionReplayIndex(
214-
sessionId: sessionId,
215-
timestamp: metadata.timestamp
216-
)
217-
}
218-
219-
completion(processed)
212+
sessionReplayProcessor.sendEvent(event: event, immediateProcessing: false) { [weak self] processed in
213+
if processed {
214+
self?.removeSessionReplayIndex(sessionId: sessionId, timestamp: metadata.timestamp)
220215
}
221-
)
216+
else {
217+
// Return timestamps to the collector so they are included on retry.
218+
// The generation guards against a stop/reset that happened while the
219+
// send was in flight — restore is a no-op in that case.
220+
self?.userActivityCollector.restore(flush.timestamps, generation: flush.generation)
221+
}
222+
completion(processed)
223+
}
222224
}
223225
}
224226

‎SplunkAgent/Sources/SplunkAgent/Agent/Events/Events/SessionReplayDataEvent.swift‎

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ class SessionReplayDataEvent: AgentEvent {
5151
/// - sessionId: The `session Id` of a session in which the event occurred.
5252
/// Optional so that we can see sessions with no session id in the backend.
5353
/// - scriptInstanceId: Internal identifier used for backend purposes.
54-
init(metadata: Metadata, data: Data, index: Int, sessionId: String?, scriptInstanceId: String) {
54+
/// - userActivity: Unix-millisecond timestamps of user interactions recorded during this segment.
55+
init(metadata: Metadata, data: Data, index: Int, sessionId: String?, scriptInstanceId: String, userActivity: [Int] = []) {
5556
// Event properties
5657
timestamp = metadata.timestamp
5758
body = EventAttributeValue(data)
@@ -64,7 +65,7 @@ class SessionReplayDataEvent: AgentEvent {
6465
// Event attributes
6566
attributes = [
6667
// Chunk metadata
67-
"segmentMetadata": .string(metadataToJSONString(metadata)),
68+
"segmentMetadata": .string(metadataToJSONString(metadata, userActivity: userActivity)),
6869

6970
// Script ID
7071
"splunk.scriptInstance": .string(scriptInstanceId),
@@ -80,16 +81,82 @@ class SessionReplayDataEvent: AgentEvent {
8081

8182
// MARK: - Private methods
8283

83-
private func metadataToJSONString(_ metadata: Metadata) -> String {
84+
private func metadataToJSONString(_ metadata: Metadata, userActivity: [Int]) -> String {
8485
// We always prefer conversion with a standard encoder ...
8586
guard
8687
let metadataContent = try? JSONEncoder().encode(metadata),
87-
let jsonString = String(data: metadataContent, encoding: .utf8)
88+
var jsonObject = try? JSONSerialization.jsonObject(with: metadataContent) as? [String: Any]
8889
else {
8990
// ... but if something fails, we can build it manually
90-
return "{\"startUnixMs\":\(metadata.startUnixMs), \"endUnixMs\":\(metadata.endUnixMs), \"source\":\"\(metadata.source)\"}"
91+
return fallbackJSON(metadata: metadata, userActivity: userActivity)
92+
}
93+
94+
jsonObject["userActivity"] = userActivity
95+
96+
guard
97+
let enrichedData = try? JSONSerialization.data(withJSONObject: jsonObject),
98+
let jsonString = String(data: enrichedData, encoding: .utf8)
99+
else {
100+
return fallbackJSON(metadata: metadata, userActivity: userActivity)
101+
}
102+
103+
return jsonString
104+
}
105+
106+
private func fallbackJSON(metadata: Metadata, userActivity: [Int]) -> String {
107+
let dict: [String: Any] = [
108+
"startUnixMs": metadata.startUnixMs,
109+
"endUnixMs": metadata.endUnixMs,
110+
"source": metadata.source,
111+
"userActivity": userActivity
112+
]
113+
guard
114+
let data = try? JSONSerialization.data(withJSONObject: dict),
115+
let jsonString = String(data: data, encoding: .utf8)
116+
else {
117+
let activityJSON = userActivity.map(String.init).joined(separator: ",")
118+
let src = jsonEscaped(metadata.source)
119+
let start = metadata.startUnixMs
120+
let end = metadata.endUnixMs
121+
return "{\"startUnixMs\":\(start),\"endUnixMs\":\(end),\"source\":\"\(src)\",\"userActivity\":[\(activityJSON)]}"
91122
}
92123

93124
return jsonString
94125
}
126+
127+
private func jsonEscaped(_ string: String) -> String {
128+
var result = ""
129+
result.reserveCapacity(string.utf16.count)
130+
for char in string.unicodeScalars {
131+
switch char.value {
132+
case 0x22:
133+
result += "\\\""
134+
135+
case 0x5C:
136+
result += "\\\\"
137+
138+
case 0x08:
139+
result += "\\b"
140+
141+
case 0x09:
142+
result += "\\t"
143+
144+
case 0x0A:
145+
result += "\\n"
146+
147+
case 0x0C:
148+
result += "\\f"
149+
150+
case 0x0D:
151+
result += "\\r"
152+
153+
case 0x00 ..< 0x20:
154+
result += String(format: "\\u%04X", char.value)
155+
156+
default:
157+
result.unicodeScalars.append(char)
158+
}
159+
}
160+
return result
161+
}
95162
}

0 commit comments

Comments
 (0)