From 0642884c2263809d21ac4c564df151ad02d50154 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 10:08:53 +0200 Subject: [PATCH 001/102] add plan for DatadogTimeseries standalone business logic --- plans/1-plan-business-logic.md | 344 +++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 plans/1-plan-business-logic.md diff --git a/plans/1-plan-business-logic.md b/plans/1-plan-business-logic.md new file mode 100644 index 0000000000..24f76d1d65 --- /dev/null +++ b/plans/1-plan-business-logic.md @@ -0,0 +1,344 @@ +# PLAN.md — DatadogTimeseries Standalone Package + +**Date:** 2026-04-13 +**Epic:** RUM-13949 +**Pod:** AI-first Performance Timeseries +**Author:** Barbora Plasovska + +--- + +## Idea Summary + +Standalone Swift package (`DatadogTimeseries`) with zero SDK dependencies that implements the pure timeseries transform logic: takes timestamped performance samples (memory, CPU) and produces complete RUM timeseries JSON events. Runs with `swift build` / `swift test` only. Includes a verification pipeline (CSV fake data in, expected JSON out, exact match comparison). Lives on a feature branch in dd-sdk-ios. + +### Why standalone? + +This package is designed for fast agent-driven iteration: +- `swift build` compiles in seconds — no Xcode workspace, no simulators, no Carthage, no CocoaPods +- `swift test` runs all tests headlessly — the agent can loop (edit → test → fix) autonomously in YOLO mode +- Zero SDK dependencies means zero setup — clone, `cd DatadogTimeseries/`, `swift test`, done + +### Two-plan approach + +This is **Plan 1 of 2**: +- **Plan 1 (this plan):** Build and verify the standalone package — pure logic, CSV in, JSON out, verification pipeline +- **Plan 2 (separate IPCIVR session, Week 2+):** Integrate into DatadogRUM — replace CSVDataProvider with real VitalMemoryReader/VitalCPUReader, wire into RUM session lifecycle, connect to the upload pipeline + +Plan 2 starts once Plan 1 is solid and verified. The integration is glue code on top of a battle-tested transform library. + +--- + +## Decisions Log + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Consumers | Both RUM Investigation Agent + Mobile Vitals | Both are first-class from day 1 | +| Event scope | Full RUM envelope | Package produces complete JSON events (with application, session, _dd) | +| Timer/scheduling | Platform glue (not in package) | Package is pure stateless transform | +| Delta compression | Deferred to Week 2+ | Simpler first iteration | +| RUM context injection | Config struct | Simple TimeseriesConfig passed at init | +| CSV format | timestamp,metric,value | Three-column, one CSV for all metrics | +| JSON encoding | Codable + .sortedKeys | Type-safe AND deterministic output for exact match | +| Batching | Package enforces | Batcher accumulates samples, flushes at batch size | +| DataProvider | Pull-based sync | `func read() -> Sample?` — matches VitalMemoryReader pattern | +| Fixture generation | Backend-driven | Hand-write from staging schema, validate with William | +| Timestamps | Event date in ms, start/end/data_point in ns | Matches staging schema | +| Error handling | Skip failed samples | Log warning, leave gap, continue | +| UUID comparison | Mask before diff | Replace UUIDs with placeholder for verification | +| Output format | Individual JSON events | Batching into NDJSON is platform glue | +| Location | Feature branch in dd-sdk-ios | Directory at repo root: `DatadogTimeseries/` | + +--- + +## Architecture + +``` +DatadogTimeseries/ +├── Package.swift # Zero external dependencies +├── Sources/ +│ └── DatadogTimeseries/ +│ ├── Models/ +│ │ ├── TimeseriesEvent.swift # Codable RUM timeseries event (full envelope) +│ │ ├── TimeseriesName.swift # Enum: memory_usage, cpu_usage +│ │ └── Sample.swift # (timestamp: Int64, value: Double) value type +│ ├── DataProvider/ +│ │ ├── DataProvider.swift # Protocol: func read() -> Sample? +│ │ └── CSVDataProvider.swift # Reads CSV fixtures for testing +│ ├── Core/ +│ │ ├── TimeseriesConfig.swift # RUM context: app id, session id, source, etc. +│ │ ├── TimeseriesBatcher.swift # Accumulates samples, flushes at batch size +│ │ └── TimeseriesEventBuilder.swift # Samples → RUM JSON event +│ ├── Encoding/ +│ │ └── TimeseriesEncoder.swift # JSONEncoder wrapper with sorted keys +│ └── TimeseriesPipeline.swift # Convenience: wires provider → batcher → builder → encoder +├── Tests/ +│ └── DatadogTimeseriesTests/ +│ ├── Fixtures/ +│ │ ├── input_memory_cpu.csv # Fake input: timestamp,metric,value +│ │ ├── expected_memory_batch1.json # Expected output for memory batch 1 +│ │ ├── expected_memory_batch2.json # Expected output for memory batch 2 +│ │ ├── expected_cpu_batch1.json # Expected output for CPU batch 1 +│ │ └── expected_cpu_batch2.json # Expected output for CPU batch 2 +│ ├── CSVDataProviderTests.swift +│ ├── TimeseriesBatcherTests.swift +│ ├── TimeseriesEventBuilderTests.swift +│ ├── TimeseriesEncoderTests.swift +│ └── EndToEndVerificationTests.swift # CSV in → JSON out → exact match +└── Scripts/ # Reserved for future tooling +``` + +--- + +## Data Flow + +``` +CSV file TimeseriesConfig + │ │ + ▼ │ +CSVDataProvider │ + │ │ + ▼ │ + Sample(timestamp, value) │ + │ │ + ▼ │ +TimeseriesBatcher │ + │ (accumulates N samples) │ + │ (flushes when full) │ + ▼ ▼ +TimeseriesEventBuilder ◄─────────┘ + │ + ▼ +TimeseriesEvent (Codable struct) + │ + ▼ +TimeseriesEncoder (.sortedKeys) + │ + ▼ +JSON string (deterministic) + │ + ▼ +Compare vs expected fixture (exact match, UUIDs masked) +``` + +--- + +## Task Breakdown + +### Task 1: Package scaffolding +Create the Swift package structure with `Package.swift`, directory layout, empty source files. +- No external dependencies +- Targets: `DatadogTimeseries` (library) + `DatadogTimeseriesTests` (test) +- Swift 5.9+ (match dd-sdk-ios) + +### Task 2: Models +Define the core data types: + +**Sample.swift:** +```swift +struct Sample { + let timestamp: Int64 // nanoseconds + let value: Double +} +``` + +**TimeseriesName.swift:** +```swift +enum TimeseriesName: String, Codable { + case memoryUsage = "memory_usage" + case cpuUsage = "cpu_usage" +} +``` + +**TimeseriesEvent.swift** (Codable, full RUM envelope): +```swift +struct TimeseriesEvent: Codable { + let dd: DD // { format_version: 2 } + let application: Application // { id: String } + let date: Int64 // milliseconds + let session: Session // { id: String, type: "user" } + let source: String // "ios" + let type: String // "timeseries" + let service: String? + let version: String? + let timeseries: Timeseries + + struct DD: Codable { + let formatVersion: Int // 2 + } + struct Application: Codable { + let id: String + } + struct Session: Codable { + let id: String + let type: String // "user" + } + struct Timeseries: Codable { + let id: String // UUID + let name: TimeseriesName + let start: Int64 // nanoseconds + let end: Int64 // nanoseconds + let data: [DataPoint] + } + struct DataPoint: Codable { + let timestamp: Int64 // nanoseconds + let dataPointValue: Double + } +} +``` + +Use explicit `CodingKeys` on every struct to map to snake_case (`format_version`, `data_point_value`, `_dd`). No `.convertToSnakeCase` encoder strategy — CodingKeys gives full control over edge cases like `_dd` and avoids double-conversion bugs. + +### Task 3: TimeseriesConfig +```swift +struct TimeseriesConfig { + let applicationId: String + let sessionId: String + let sessionType: String // "user" + let source: String // "ios" + let service: String? + let version: String? +} +``` + +### Task 4: DataProvider protocol + CSVDataProvider + +**DataProvider.swift:** +```swift +protocol DataProvider { + func read() -> Sample? +} +``` + +**CSVDataProvider.swift:** +- Reads a CSV file with format: `timestamp,metric_name,value` +- Filters by a given `TimeseriesName` +- Returns samples one by one via `read()` (pull-based) +- Returns `nil` when exhausted + +### Task 5: TimeseriesBatcher +- Initialized with `batchSize: Int` (default 30) — metric-agnostic, it just batches samples +- `add(_ sample: Sample)` — appends to internal buffer +- `shouldFlush() -> Bool` — true when buffer.count >= batchSize +- `flush() -> [Sample]` — returns accumulated samples, clears buffer +- `flushRemaining() -> [Sample]?` — returns whatever is left (for session end), nil if empty + +### Task 6: TimeseriesEventBuilder +- Initialized with `TimeseriesConfig` +- `build(samples: [Sample], name: TimeseriesName, eventId: String) -> TimeseriesEvent` +- Computes `start` = first sample timestamp, `end` = last sample timestamp +- Computes `date` = `start` converted from ns to ms (integer division by 1_000_000) +- Maps samples to `DataPoint` array + +### Task 7: TimeseriesEncoder +- Wraps `JSONEncoder` with: + - `.sortedKeys` output formatting + - No `.convertToSnakeCase` — all snake_case mapping handled by explicit CodingKeys on the model structs +- `func encode(_ event: TimeseriesEvent) -> Data` +- Returns deterministic JSON bytes + +### Task 8: CSV test fixtures +Create `input_memory_cpu.csv` with realistic fake data: +- ~20 rows (10 `memory_usage` + 10 `cpu_usage`, simulating 10 seconds at 1Hz) +- Memory values in ~30-40 MB range (bytes), CPU values in 0-100 range (percent) +- Timestamps in nanoseconds, 1-second intervals starting from a fixed epoch +- Tests use `batchSize=5` so this produces 2 batches per metric (4 expected JSON files) +- Production default of 30 is a tuning concern for Plan 2, not a verification concern here + +### Task 9: Expected JSON fixtures (backend-driven) +The expected JSON fixtures should represent what the backend actually accepts. Two-step approach: +1. **Hand-write initial fixtures** based on the staging schema contract (the JSON format already documented in the kickoff context + what William's backend validates against) +2. **Validate with William** — share the fixture files with William/backend team to confirm they match the intake contract. If the backend rejects the format, the fixtures are wrong regardless of what our code produces. + +This avoids the "testing our code with our code" problem — the expected output is defined by the backend contract, not by our own generator. + +Files: +- `Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json` +- `Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json` +- `Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json` +- `Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json` +- All with masked UUIDs (`00000000-0000-0000-0000-000000000000`) and sorted keys + +### Task 10: Unit tests +- **CSVDataProviderTests**: reads CSV, filters by metric, returns correct samples, returns nil at end +- **TimeseriesBatcherTests**: accumulates correctly, flushes at batch size, flushRemaining works, empty flush returns nil +- **TimeseriesEventBuilderTests**: correct envelope fields, correct start/end, correct data points, correct timestamps +- **TimeseriesEncoderTests**: sorted keys, snake_case, valid JSON + +### Task 11: End-to-end verification test +`EndToEndVerificationTests.swift`: +1. Read `input_memory_cpu.csv` via `CSVDataProvider` +2. Feed samples through `TimeseriesBatcher` + `TimeseriesEventBuilder` + `TimeseriesEncoder` +3. Mask UUIDs in actual output (regex replace UUID pattern with `"00000000-0000-0000-0000-000000000000"`) +4. Load expected JSON fixture (already has masked UUIDs) +5. Compare byte-for-byte +6. Pass/fail + +### Task 12: TimeseriesPipeline (orchestrator) +- Convenience type that wires the full flow: `DataProvider` → `TimeseriesBatcher` → `TimeseriesEventBuilder` → `TimeseriesEncoder` +- `init(provider: DataProvider, config: TimeseriesConfig, metricName: TimeseriesName, batchSize: Int)` +- `func processAll() -> [Data]` — reads all samples from provider, batches, builds events, encodes, returns JSON data array +- This is what the E2E test calls — keeps wiring logic out of the test itself +- In Plan 2 (platform integration), the real orchestrator is the session scope + timer, not this pipeline + +### Task 13: Skip-sample error handling +- `DataProvider.read()` returns `Sample?` — nil means skip +- `TimeseriesBatcher.add()` only accepts non-nil samples +- Test: CSV with a gap (missing row) → output event has fewer data points, timestamps reflect the gap + +--- + +## Verification Strategy + +The agent must run these checks **in order** after every change: + +### 1. Build check +```bash +cd DatadogTimeseries && swift build +``` +Must compile with zero errors and zero warnings. Fastest feedback — catches type errors, missing imports, syntax issues. + +### 2. Unit tests +```bash +cd DatadogTimeseries && swift test +``` +Runs all tests in `DatadogTimeseriesTests`. Each component has dedicated tests (Tasks 10). Pass/fail is unambiguous. + +### 3. End-to-end exact match +Part of `swift test` (Task 11) — the `EndToEndVerificationTests`: +- CSV in → pipeline → JSON out → mask UUIDs → compare byte-for-byte against expected fixtures +- UUID masking regex: `[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` → `00000000-0000-0000-0000-000000000000` +- Fixtures are hand-written from backend contract (validated with William) + +### 4. JSON schema validation +A test that validates output JSON structure against expected RUM timeseries schema: +- Required fields present: `_dd`, `application`, `date`, `session`, `source`, `type`, `timeseries` +- Correct types: `date` is Int64, `timeseries.data` is array, `data_point_value` is Double +- Correct constants: `type` == `"timeseries"`, `_dd.format_version` == 2, `session.type` == `"user"` +- This catches structural errors even before fixtures are finalized + +### Agent loop +After every code change, the agent runs: +```bash +cd DatadogTimeseries && swift build && swift test +``` +All green = proceed. Any red = fix before moving on. + +--- + +## Week 1 Milestone (Friday Apr 18 Demo) + +- [ ] Package compiles with `swift build` +- [ ] All unit tests pass with `swift test` +- [ ] End-to-end verification passes (CSV in → JSON out → exact match) +- [ ] Can show: "same CSV input, same expected JSON — ready for Kotlin/Go to verify against" + +--- + +## Future (Week 2+) + +- Delta compression (DeltaEncoder) +- Integration into DatadogRUM (replace CSVDataProvider with VitalMemoryReader/VitalCPUReader) +- Wire TimeseriesEventBuilder output into RUM Writer pipeline +- NDJSON batch format for upload +- Kotlin rewrite + verification against same fixtures +- Configurable batch size tuning based on backend feedback From 1a615f707b67d09cd5268edbeece51a857f7fa44 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 13:30:25 +0200 Subject: [PATCH 002/102] add git rules override for timeseries pod --- plans/git-rules-pod.md | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 plans/git-rules-pod.md diff --git a/plans/git-rules-pod.md b/plans/git-rules-pod.md new file mode 100644 index 0000000000..161782f60d --- /dev/null +++ b/plans/git-rules-pod.md @@ -0,0 +1,48 @@ +# Git Workflow — Timeseries Pod Override + +> These rules apply **only** during the AI Pod (Apr 13 – ~May 9, 2026) on the `feature/timeseries` branch. +> They override the global git-workflow rules for the duration of the pod. +> Once the pod ends and code moves toward `develop`, revert to the standard rules. + +--- + +## Branching + +- All work happens on `feature/timeseries` (already created). +- No new branches needed unless explicitly discussed with the team. +- Do NOT create PRs to `develop` during the pod. + +--- + +## Commits + +- **Single-line commit message**, starting with a **verb** in the imperative form. +- **No JIRA prefix** — there are no individual tickets during the pod. +- **No co-author lines** — Barbora is the sole commit author. +- **No signed commits required** — skip GPG/SSH signing for speed. +- Commit frequently — small, logical units of work. The agent should commit after each completed task or meaningful step. +- No description lines unless explicitly requested. + +Examples: +``` +add Package.swift scaffolding for DatadogTimeseries +implement TimeseriesBatcher with configurable batch size +fix CodingKeys for _dd field in TimeseriesEvent +add end-to-end verification test with UUID masking +``` + +--- + +## Pull Requests + +- **No PRs during the pod.** All commits go directly to `feature/timeseries`. +- Code review happens post-pod when merging to `develop`. + +--- + +## Agent autonomy + +- The agent commits directly without asking for approval. +- The agent does NOT need to run `git status` or `git diff` before committing — just stage the relevant files and commit. +- After a successful `swift build && swift test` (or equivalent verification), the agent should commit immediately. +- Keep commits atomic: one logical change per commit. From c3a19fff1b9c68505e0b66b5a964cc284ee5e91d Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 13:33:55 +0200 Subject: [PATCH 003/102] add Package.swift scaffolding for DatadogTimeseries --- DatadogTimeseries/Package.swift | 28 +++++++++++++++++++ .../DatadogTimeseries/Models/Sample.swift | 9 ++++++ .../Models/TimeseriesName.swift | 6 ++++ .../DatadogTimeseriesTests/Fixtures/.gitkeep | 0 .../DatadogTimeseriesTests/SmokeTests.swift | 10 +++++++ 5 files changed, 53 insertions(+) create mode 100644 DatadogTimeseries/Package.swift create mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift create mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/.gitkeep create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/SmokeTests.swift diff --git a/DatadogTimeseries/Package.swift b/DatadogTimeseries/Package.swift new file mode 100644 index 0000000000..c34c78a702 --- /dev/null +++ b/DatadogTimeseries/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "DatadogTimeseries", + products: [ + .library( + name: "DatadogTimeseries", + targets: ["DatadogTimeseries"] + ), + ], + targets: [ + .target( + name: "DatadogTimeseries", + dependencies: [], + path: "Sources/DatadogTimeseries" + ), + .testTarget( + name: "DatadogTimeseriesTests", + dependencies: ["DatadogTimeseries"], + path: "Tests/DatadogTimeseriesTests", + resources: [ + .copy("Fixtures"), + ] + ), + ] +) diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift new file mode 100644 index 0000000000..d08b65329d --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift @@ -0,0 +1,9 @@ +import Foundation + +/// A single timestamped performance sample. +struct Sample { + /// Timestamp in nanoseconds. + let timestamp: Int64 + /// Metric value (e.g. bytes for memory, percent for CPU). + let value: Double +} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift new file mode 100644 index 0000000000..d377a4204c --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift @@ -0,0 +1,6 @@ +import Foundation + +enum TimeseriesName: String, Codable { + case memoryUsage = "memory_usage" + case cpuUsage = "cpu_usage" +} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/.gitkeep b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/SmokeTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/SmokeTests.swift new file mode 100644 index 0000000000..d8df032d21 --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/SmokeTests.swift @@ -0,0 +1,10 @@ +import XCTest +@testable import DatadogTimeseries + +final class SmokeTests: XCTestCase { + func testPackageCompiles() { + let sample = Sample(timestamp: 1_000_000_000, value: 42.0) + XCTAssertEqual(sample.timestamp, 1_000_000_000) + XCTAssertEqual(sample.value, 42.0) + } +} From 67ef551a6e710b3b5a4368146c18b64ad7b88a02 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 13:34:47 +0200 Subject: [PATCH 004/102] implement models with explicit CodingKeys for JSON serialization --- .../Models/TimeseriesEvent.swift | 60 ++++++++++ .../TimeseriesEventModelTests.swift | 107 ++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift new file mode 100644 index 0000000000..fb0f1901c2 --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift @@ -0,0 +1,60 @@ +import Foundation + +struct TimeseriesEvent: Codable { + let dd: DD + let application: Application + let date: Int64 + let session: Session + let source: String + let type: String + let service: String? + let version: String? + let timeseries: Timeseries + + enum CodingKeys: String, CodingKey { + case dd = "_dd" + case application + case date + case session + case source + case type + case service + case version + case timeseries + } + + struct DD: Codable { + let formatVersion: Int + + enum CodingKeys: String, CodingKey { + case formatVersion = "format_version" + } + } + + struct Application: Codable { + let id: String + } + + struct Session: Codable { + let id: String + let type: String + } + + struct Timeseries: Codable { + let id: String + let name: TimeseriesName + let start: Int64 + let end: Int64 + let data: [DataPoint] + } + + struct DataPoint: Codable { + let timestamp: Int64 + let dataPointValue: Double + + enum CodingKeys: String, CodingKey { + case timestamp + case dataPointValue = "data_point_value" + } + } +} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift new file mode 100644 index 0000000000..7356e569ce --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift @@ -0,0 +1,107 @@ +import XCTest +@testable import DatadogTimeseries + +final class TimeseriesEventModelTests: XCTestCase { + func testTimeseriesEventEncodesToExpectedJSON() throws { + let event = TimeseriesEvent( + dd: TimeseriesEvent.DD(formatVersion: 2), + application: TimeseriesEvent.Application(id: "app-id-123"), + date: 1773055119487, + session: TimeseriesEvent.Session(id: "session-id-456", type: "user"), + source: "ios", + type: "timeseries", + service: "my-service", + version: "1.0.0", + timeseries: TimeseriesEvent.Timeseries( + id: "ts-id-789", + name: .memoryUsage, + start: 1773055068831000000, + end: 1773055082916000000, + data: [ + TimeseriesEvent.DataPoint(timestamp: 1773055068831000000, dataPointValue: 38052032), + TimeseriesEvent.DataPoint(timestamp: 1773055069917000000, dataPointValue: 37970112), + ] + ) + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(event) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + // Root-level fields + let dd = try XCTUnwrap(json["_dd"] as? [String: Any]) + XCTAssertEqual(dd["format_version"] as? Int, 2) + + let application = try XCTUnwrap(json["application"] as? [String: Any]) + XCTAssertEqual(application["id"] as? String, "app-id-123") + + XCTAssertEqual(json["date"] as? Int64, 1773055119487) + + let session = try XCTUnwrap(json["session"] as? [String: Any]) + XCTAssertEqual(session["id"] as? String, "session-id-456") + XCTAssertEqual(session["type"] as? String, "user") + + XCTAssertEqual(json["source"] as? String, "ios") + XCTAssertEqual(json["type"] as? String, "timeseries") + XCTAssertEqual(json["service"] as? String, "my-service") + XCTAssertEqual(json["version"] as? String, "1.0.0") + + // Timeseries nested object + let ts = try XCTUnwrap(json["timeseries"] as? [String: Any]) + XCTAssertEqual(ts["id"] as? String, "ts-id-789") + XCTAssertEqual(ts["name"] as? String, "memory_usage") + XCTAssertEqual(ts["start"] as? Int64, 1773055068831000000) + XCTAssertEqual(ts["end"] as? Int64, 1773055082916000000) + + let dataPoints = try XCTUnwrap(ts["data"] as? [[String: Any]]) + XCTAssertEqual(dataPoints.count, 2) + XCTAssertEqual(dataPoints[0]["timestamp"] as? Int64, 1773055068831000000) + XCTAssertEqual(dataPoints[0]["data_point_value"] as? Double, 38052032) + } + + func testTimeseriesEventOmitsNilServiceAndVersion() throws { + let event = TimeseriesEvent( + dd: TimeseriesEvent.DD(formatVersion: 2), + application: TimeseriesEvent.Application(id: "app-id"), + date: 1000, + session: TimeseriesEvent.Session(id: "sess-id", type: "user"), + source: "ios", + type: "timeseries", + service: nil, + version: nil, + timeseries: TimeseriesEvent.Timeseries( + id: "ts-id", + name: .cpuUsage, + start: 1000000000, + end: 2000000000, + data: [ + TimeseriesEvent.DataPoint(timestamp: 1000000000, dataPointValue: 55.3), + ] + ) + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(event) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertNil(json["service"]) + XCTAssertNil(json["version"]) + XCTAssertEqual(json["type"] as? String, "timeseries") + + let ts = try XCTUnwrap(json["timeseries"] as? [String: Any]) + XCTAssertEqual(ts["name"] as? String, "cpu_usage") + } + + func testTimeseriesNameRawValues() { + XCTAssertEqual(TimeseriesName.memoryUsage.rawValue, "memory_usage") + XCTAssertEqual(TimeseriesName.cpuUsage.rawValue, "cpu_usage") + } + + func testSampleStoresValues() { + let sample = Sample(timestamp: 5_000_000_000, value: 123.456) + XCTAssertEqual(sample.timestamp, 5_000_000_000) + XCTAssertEqual(sample.value, 123.456) + } +} From dac302e180564252e3c1869e06cacee46456ad12 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 13:35:04 +0200 Subject: [PATCH 005/102] add TimeseriesConfig for RUM context injection --- .../DatadogTimeseries/Core/TimeseriesConfig.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift new file mode 100644 index 0000000000..7b8c8c2617 --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift @@ -0,0 +1,10 @@ +import Foundation + +struct TimeseriesConfig { + let applicationId: String + let sessionId: String + let sessionType: String + let source: String + let service: String? + let version: String? +} From 1ca099a3d289f3eae7e9c416f919959d4fd06f93 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 13:35:45 +0200 Subject: [PATCH 006/102] add DataProvider protocol and CSVDataProvider with tests --- .../DataProvider/CSVDataProvider.swift | 34 ++++++++ .../DataProvider/DataProvider.swift | 5 ++ .../CSVDataProviderTests.swift | 78 +++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift create mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift new file mode 100644 index 0000000000..7283c7918c --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift @@ -0,0 +1,34 @@ +import Foundation + +class CSVDataProvider: DataProvider { + private var samples: [Sample] + private var index: Int = 0 + + init(csvContent: String, metric: TimeseriesName) { + var parsed: [Sample] = [] + let lines = csvContent.components(separatedBy: "\n") + + for line in lines.dropFirst() { // skip header + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + let columns = trimmed.components(separatedBy: ",") + guard columns.count == 3 else { continue } + + guard columns[1] == metric.rawValue else { continue } + guard let timestamp = Int64(columns[0]), + let value = Double(columns[2]) else { continue } + + parsed.append(Sample(timestamp: timestamp, value: value)) + } + + self.samples = parsed + } + + func read() -> Sample? { + guard index < samples.count else { return nil } + let sample = samples[index] + index += 1 + return sample + } +} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift new file mode 100644 index 0000000000..9a0ec4d0f2 --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol DataProvider { + func read() -> Sample? +} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift new file mode 100644 index 0000000000..7d265798dd --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift @@ -0,0 +1,78 @@ +import XCTest +@testable import DatadogTimeseries + +final class CSVDataProviderTests: XCTestCase { + func testReadsFilteredSamplesFromCSV() throws { + let csv = """ + timestamp,metric,value + 1000000000,memory_usage,30000000 + 1000000000,cpu_usage,12.5 + 2000000000,memory_usage,31000000 + 2000000000,cpu_usage,15.0 + 3000000000,memory_usage,32000000 + """ + + let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) + + let s1 = provider.read() + XCTAssertEqual(s1?.timestamp, 1000000000) + XCTAssertEqual(s1?.value, 30000000) + + let s2 = provider.read() + XCTAssertEqual(s2?.timestamp, 2000000000) + XCTAssertEqual(s2?.value, 31000000) + + let s3 = provider.read() + XCTAssertEqual(s3?.timestamp, 3000000000) + XCTAssertEqual(s3?.value, 32000000) + + let s4 = provider.read() + XCTAssertNil(s4) + } + + func testFiltersByCPUUsage() throws { + let csv = """ + timestamp,metric,value + 1000000000,memory_usage,30000000 + 1000000000,cpu_usage,12.5 + 2000000000,cpu_usage,15.0 + """ + + let provider = CSVDataProvider(csvContent: csv, metric: .cpuUsage) + + let s1 = provider.read() + XCTAssertEqual(s1?.timestamp, 1000000000) + XCTAssertEqual(s1?.value, 12.5) + + let s2 = provider.read() + XCTAssertEqual(s2?.timestamp, 2000000000) + XCTAssertEqual(s2?.value, 15.0) + + XCTAssertNil(provider.read()) + } + + func testReturnsNilForEmptyCSV() { + let csv = "timestamp,metric,value\n" + let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) + XCTAssertNil(provider.read()) + } + + func testSkipsMalformedRows() { + let csv = """ + timestamp,metric,value + 1000000000,memory_usage,30000000 + bad_row + 2000000000,memory_usage,31000000 + """ + + let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) + + let s1 = provider.read() + XCTAssertEqual(s1?.timestamp, 1000000000) + + let s2 = provider.read() + XCTAssertEqual(s2?.timestamp, 2000000000) + + XCTAssertNil(provider.read()) + } +} From f36dac5375bacb616b353a467374765707e094da Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 13:36:37 +0200 Subject: [PATCH 007/102] implement TimeseriesBatcher with configurable batch size --- .../Core/TimeseriesBatcher.swift | 29 ++++++++ .../TimeseriesBatcherTests.swift | 71 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesBatcher.swift create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesBatcherTests.swift diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesBatcher.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesBatcher.swift new file mode 100644 index 0000000000..9ddc6498e3 --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesBatcher.swift @@ -0,0 +1,29 @@ +import Foundation + +class TimeseriesBatcher { + private let batchSize: Int + private var buffer: [Sample] = [] + + init(batchSize: Int = 30) { + self.batchSize = batchSize + } + + func add(_ sample: Sample) { + buffer.append(sample) + } + + func shouldFlush() -> Bool { + buffer.count >= batchSize + } + + func flush() -> [Sample] { + let batch = buffer + buffer = [] + return batch + } + + func flushRemaining() -> [Sample]? { + guard !buffer.isEmpty else { return nil } + return flush() + } +} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesBatcherTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesBatcherTests.swift new file mode 100644 index 0000000000..93960929f5 --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesBatcherTests.swift @@ -0,0 +1,71 @@ +import XCTest +@testable import DatadogTimeseries + +final class TimeseriesBatcherTests: XCTestCase { + func testDoesNotFlushBeforeBatchSize() { + let batcher = TimeseriesBatcher(batchSize: 3) + batcher.add(Sample(timestamp: 1, value: 10)) + batcher.add(Sample(timestamp: 2, value: 20)) + + XCTAssertFalse(batcher.shouldFlush()) + } + + func testFlushesAtBatchSize() { + let batcher = TimeseriesBatcher(batchSize: 3) + batcher.add(Sample(timestamp: 1, value: 10)) + batcher.add(Sample(timestamp: 2, value: 20)) + batcher.add(Sample(timestamp: 3, value: 30)) + + XCTAssertTrue(batcher.shouldFlush()) + + let batch = batcher.flush() + XCTAssertEqual(batch.count, 3) + XCTAssertEqual(batch[0].timestamp, 1) + XCTAssertEqual(batch[1].timestamp, 2) + XCTAssertEqual(batch[2].timestamp, 3) + } + + func testFlushClearsBuffer() { + let batcher = TimeseriesBatcher(batchSize: 2) + batcher.add(Sample(timestamp: 1, value: 10)) + batcher.add(Sample(timestamp: 2, value: 20)) + + _ = batcher.flush() + + XCTAssertFalse(batcher.shouldFlush()) + XCTAssertTrue(batcher.flush().isEmpty) + } + + func testFlushRemainingReturnsSamples() { + let batcher = TimeseriesBatcher(batchSize: 5) + batcher.add(Sample(timestamp: 1, value: 10)) + batcher.add(Sample(timestamp: 2, value: 20)) + + let remaining = batcher.flushRemaining() + XCTAssertNotNil(remaining) + XCTAssertEqual(remaining?.count, 2) + } + + func testFlushRemainingReturnsNilWhenEmpty() { + let batcher = TimeseriesBatcher(batchSize: 5) + XCTAssertNil(batcher.flushRemaining()) + } + + func testMultipleBatches() { + let batcher = TimeseriesBatcher(batchSize: 2) + batcher.add(Sample(timestamp: 1, value: 10)) + batcher.add(Sample(timestamp: 2, value: 20)) + + XCTAssertTrue(batcher.shouldFlush()) + let batch1 = batcher.flush() + XCTAssertEqual(batch1.count, 2) + + batcher.add(Sample(timestamp: 3, value: 30)) + batcher.add(Sample(timestamp: 4, value: 40)) + + XCTAssertTrue(batcher.shouldFlush()) + let batch2 = batcher.flush() + XCTAssertEqual(batch2.count, 2) + XCTAssertEqual(batch2[0].timestamp, 3) + } +} From 0672ce899fcbf9038be84b271471d516acae8879 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 13:37:18 +0200 Subject: [PATCH 008/102] implement TimeseriesEventBuilder with config injection --- .../Core/TimeseriesEventBuilder.swift | 40 ++++++++ .../TimeseriesEventBuilderTests.swift | 96 +++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift new file mode 100644 index 0000000000..0d23a47e8b --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift @@ -0,0 +1,40 @@ +import Foundation + +struct TimeseriesEventBuilder { + private let config: TimeseriesConfig + + init(config: TimeseriesConfig) { + self.config = config + } + + func build(samples: [Sample], name: TimeseriesName, eventId: String) -> TimeseriesEvent { + let start = samples.first?.timestamp ?? 0 + let end = samples.last?.timestamp ?? 0 + let dateMs = start / 1_000_000 + + let dataPoints = samples.map { sample in + TimeseriesEvent.DataPoint( + timestamp: sample.timestamp, + dataPointValue: sample.value + ) + } + + return TimeseriesEvent( + dd: TimeseriesEvent.DD(formatVersion: 2), + application: TimeseriesEvent.Application(id: config.applicationId), + date: dateMs, + session: TimeseriesEvent.Session(id: config.sessionId, type: config.sessionType), + source: config.source, + type: "timeseries", + service: config.service, + version: config.version, + timeseries: TimeseriesEvent.Timeseries( + id: eventId, + name: name, + start: start, + end: end, + data: dataPoints + ) + ) + } +} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift new file mode 100644 index 0000000000..84fc14f4b7 --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import DatadogTimeseries + +final class TimeseriesEventBuilderTests: XCTestCase { + private let config = TimeseriesConfig( + applicationId: "app-123", + sessionId: "session-456", + sessionType: "user", + source: "ios", + service: "test-service", + version: "2.0.0" + ) + + func testBuildsEventWithCorrectEnvelope() { + let builder = TimeseriesEventBuilder(config: config) + let samples = [ + Sample(timestamp: 5_000_000_000, value: 100), + Sample(timestamp: 6_000_000_000, value: 200), + ] + + let event = builder.build(samples: samples, name: .memoryUsage, eventId: "evt-id") + + XCTAssertEqual(event.dd.formatVersion, 2) + XCTAssertEqual(event.application.id, "app-123") + XCTAssertEqual(event.session.id, "session-456") + XCTAssertEqual(event.session.type, "user") + XCTAssertEqual(event.source, "ios") + XCTAssertEqual(event.type, "timeseries") + XCTAssertEqual(event.service, "test-service") + XCTAssertEqual(event.version, "2.0.0") + } + + func testBuildsEventWithCorrectTimeseries() { + let builder = TimeseriesEventBuilder(config: config) + let samples = [ + Sample(timestamp: 5_000_000_000, value: 100), + Sample(timestamp: 6_000_000_000, value: 200), + Sample(timestamp: 7_000_000_000, value: 300), + ] + + let event = builder.build(samples: samples, name: .cpuUsage, eventId: "my-uuid") + + XCTAssertEqual(event.timeseries.id, "my-uuid") + XCTAssertEqual(event.timeseries.name, .cpuUsage) + XCTAssertEqual(event.timeseries.start, 5_000_000_000) + XCTAssertEqual(event.timeseries.end, 7_000_000_000) + XCTAssertEqual(event.timeseries.data.count, 3) + } + + func testDateIsStartTimestampConvertedToMilliseconds() { + let builder = TimeseriesEventBuilder(config: config) + let samples = [ + Sample(timestamp: 1_773_055_068_831_000_000, value: 42), + ] + + let event = builder.build(samples: samples, name: .memoryUsage, eventId: "id") + + // 1_773_055_068_831_000_000 ns / 1_000_000 = 1_773_055_068_831 ms + XCTAssertEqual(event.date, 1_773_055_068_831) + } + + func testDataPointsMatchSamples() { + let builder = TimeseriesEventBuilder(config: config) + let samples = [ + Sample(timestamp: 1000, value: 42.5), + Sample(timestamp: 2000, value: 99.9), + ] + + let event = builder.build(samples: samples, name: .memoryUsage, eventId: "id") + + XCTAssertEqual(event.timeseries.data[0].timestamp, 1000) + XCTAssertEqual(event.timeseries.data[0].dataPointValue, 42.5) + XCTAssertEqual(event.timeseries.data[1].timestamp, 2000) + XCTAssertEqual(event.timeseries.data[1].dataPointValue, 99.9) + } + + func testNilServiceAndVersion() { + let configNoOptionals = TimeseriesConfig( + applicationId: "app", + sessionId: "sess", + sessionType: "user", + source: "ios", + service: nil, + version: nil + ) + let builder = TimeseriesEventBuilder(config: configNoOptionals) + let event = builder.build( + samples: [Sample(timestamp: 1, value: 1)], + name: .cpuUsage, + eventId: "id" + ) + + XCTAssertNil(event.service) + XCTAssertNil(event.version) + } +} From fa62e576eaaf129d0400cdb3251ec109205ff75f Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 13:37:49 +0200 Subject: [PATCH 009/102] implement TimeseriesEncoder with sorted keys output --- .../Encoding/TimeseriesEncoder.swift | 15 ++++ .../TimeseriesEncoderTests.swift | 70 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Encoding/TimeseriesEncoder.swift create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Encoding/TimeseriesEncoder.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Encoding/TimeseriesEncoder.swift new file mode 100644 index 0000000000..d6bf001c84 --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Encoding/TimeseriesEncoder.swift @@ -0,0 +1,15 @@ +import Foundation + +struct TimeseriesEncoder { + private let encoder: JSONEncoder + + init() { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + self.encoder = encoder + } + + func encode(_ event: TimeseriesEvent) throws -> Data { + try encoder.encode(event) + } +} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift new file mode 100644 index 0000000000..09cbe1b6c1 --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import DatadogTimeseries + +final class TimeseriesEncoderTests: XCTestCase { + func testProducesSortedKeys() throws { + let event = makeSimpleEvent() + let encoder = TimeseriesEncoder() + let data = try encoder.encode(event) + let json = String(data: data, encoding: .utf8)! + + // _dd should come before application (underscore sorts first in ASCII) + let ddRange = json.range(of: "\"_dd\"")! + let appRange = json.range(of: "\"application\"")! + XCTAssertTrue(ddRange.lowerBound < appRange.lowerBound, "Keys should be sorted") + } + + func testProducesSnakeCaseKeys() throws { + let event = makeSimpleEvent() + let encoder = TimeseriesEncoder() + let data = try encoder.encode(event) + let json = String(data: data, encoding: .utf8)! + + XCTAssertTrue(json.contains("\"format_version\"")) + XCTAssertTrue(json.contains("\"data_point_value\"")) + XCTAssertFalse(json.contains("\"formatVersion\"")) + XCTAssertFalse(json.contains("\"dataPointValue\"")) + } + + func testProducesValidJSON() throws { + let event = makeSimpleEvent() + let encoder = TimeseriesEncoder() + let data = try encoder.encode(event) + + let parsed = try JSONSerialization.jsonObject(with: data) + XCTAssertTrue(parsed is [String: Any]) + } + + func testDeterministicOutput() throws { + let event = makeSimpleEvent() + let encoder = TimeseriesEncoder() + let data1 = try encoder.encode(event) + let data2 = try encoder.encode(event) + + XCTAssertEqual(data1, data2, "Encoding the same event should produce identical bytes") + } + + // MARK: - Helpers + + private func makeSimpleEvent() -> TimeseriesEvent { + TimeseriesEvent( + dd: TimeseriesEvent.DD(formatVersion: 2), + application: TimeseriesEvent.Application(id: "app"), + date: 1000, + session: TimeseriesEvent.Session(id: "sess", type: "user"), + source: "ios", + type: "timeseries", + service: nil, + version: nil, + timeseries: TimeseriesEvent.Timeseries( + id: "ts-id", + name: .memoryUsage, + start: 1_000_000_000, + end: 2_000_000_000, + data: [ + TimeseriesEvent.DataPoint(timestamp: 1_000_000_000, dataPointValue: 42), + ] + ) + ) + } +} From 351e179b0831dddf9fb15c8f6e2cebf5a3c9196c Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 13:38:15 +0200 Subject: [PATCH 010/102] add CSV test fixture with 10 memory and 10 CPU samples --- .../Fixtures/input_memory_cpu.csv | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_memory_cpu.csv diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_memory_cpu.csv b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_memory_cpu.csv new file mode 100644 index 0000000000..992dead619 --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_memory_cpu.csv @@ -0,0 +1,21 @@ +timestamp,metric,value +1700000001000000000,memory_usage,31233300 +1700000001000000000,cpu_usage,12.5 +1700000002000000000,memory_usage,31245500 +1700000002000000000,cpu_usage,14.2 +1700000003000000000,memory_usage,31300800 +1700000003000000000,cpu_usage,11.8 +1700000004000000000,memory_usage,31289100 +1700000004000000000,cpu_usage,16.3 +1700000005000000000,memory_usage,31350000 +1700000005000000000,cpu_usage,13.7 +1700000006000000000,memory_usage,31420200 +1700000006000000000,cpu_usage,18.1 +1700000007000000000,memory_usage,31510400 +1700000007000000000,cpu_usage,22.4 +1700000008000000000,memory_usage,31498700 +1700000008000000000,cpu_usage,19.6 +1700000009000000000,memory_usage,31550300 +1700000009000000000,cpu_usage,15.9 +1700000010000000000,memory_usage,31600100 +1700000010000000000,cpu_usage,13.2 From 4da23e453dede3630392a9a90e55d8fe90d9c427 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 13:38:54 +0200 Subject: [PATCH 011/102] add expected JSON fixtures for E2E verification --- .../DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json | 1 + .../DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json | 1 + .../DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json | 1 + .../DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json | 1 + 4 files changed, 4 insertions(+) create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json new file mode 100644 index 0000000000..c435273d8d --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json @@ -0,0 +1 @@ +{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000001000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":12.5,"timestamp":1700000001000000000},{"data_point_value":14.199999999999999,"timestamp":1700000002000000000},{"data_point_value":11.800000000000001,"timestamp":1700000003000000000},{"data_point_value":16.300000000000001,"timestamp":1700000004000000000},{"data_point_value":13.699999999999999,"timestamp":1700000005000000000}],"end":1700000005000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","start":1700000001000000000},"type":"timeseries"} \ No newline at end of file diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json new file mode 100644 index 0000000000..1cc8e8eec2 --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json @@ -0,0 +1 @@ +{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000006000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":18.100000000000001,"timestamp":1700000006000000000},{"data_point_value":22.399999999999999,"timestamp":1700000007000000000},{"data_point_value":19.600000000000001,"timestamp":1700000008000000000},{"data_point_value":15.9,"timestamp":1700000009000000000},{"data_point_value":13.199999999999999,"timestamp":1700000010000000000}],"end":1700000010000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","start":1700000006000000000},"type":"timeseries"} \ No newline at end of file diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json new file mode 100644 index 0000000000..bd1bf0b3f2 --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json @@ -0,0 +1 @@ +{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000001000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":31233300,"timestamp":1700000001000000000},{"data_point_value":31245500,"timestamp":1700000002000000000},{"data_point_value":31300800,"timestamp":1700000003000000000},{"data_point_value":31289100,"timestamp":1700000004000000000},{"data_point_value":31350000,"timestamp":1700000005000000000}],"end":1700000005000000000,"id":"00000000-0000-0000-0000-000000000000","name":"memory_usage","start":1700000001000000000},"type":"timeseries"} \ No newline at end of file diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json new file mode 100644 index 0000000000..98d8b1e89e --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json @@ -0,0 +1 @@ +{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000006000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":31420200,"timestamp":1700000006000000000},{"data_point_value":31510400,"timestamp":1700000007000000000},{"data_point_value":31498700,"timestamp":1700000008000000000},{"data_point_value":31550300,"timestamp":1700000009000000000},{"data_point_value":31600100,"timestamp":1700000010000000000}],"end":1700000010000000000,"id":"00000000-0000-0000-0000-000000000000","name":"memory_usage","start":1700000006000000000},"type":"timeseries"} \ No newline at end of file From 4451706aa9f3157c7a0584a85d1b0ea6997f51cf Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 13:39:45 +0200 Subject: [PATCH 012/102] implement TimeseriesPipeline orchestrator --- .../TimeseriesPipeline.swift | 49 ++++++++++++ .../TimeseriesPipelineTests.swift | 75 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift b/DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift new file mode 100644 index 0000000000..6d2da7a31c --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift @@ -0,0 +1,49 @@ +import Foundation + +struct TimeseriesPipeline { + private let provider: DataProvider + private let config: TimeseriesConfig + private let metricName: TimeseriesName + private let batchSize: Int + + init(provider: DataProvider, config: TimeseriesConfig, metricName: TimeseriesName, batchSize: Int = 30) { + self.provider = provider + self.config = config + self.metricName = metricName + self.batchSize = batchSize + } + + func processAll() throws -> [Data] { + let batcher = TimeseriesBatcher(batchSize: batchSize) + let builder = TimeseriesEventBuilder(config: config) + let encoder = TimeseriesEncoder() + + var results: [Data] = [] + + while let sample = provider.read() { + batcher.add(sample) + if batcher.shouldFlush() { + let batch = batcher.flush() + let event = builder.build( + samples: batch, + name: metricName, + eventId: UUID().uuidString.lowercased() + ) + let data = try encoder.encode(event) + results.append(data) + } + } + + if let remaining = batcher.flushRemaining() { + let event = builder.build( + samples: remaining, + name: metricName, + eventId: UUID().uuidString.lowercased() + ) + let data = try encoder.encode(event) + results.append(data) + } + + return results + } +} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift new file mode 100644 index 0000000000..87a6e416b0 --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift @@ -0,0 +1,75 @@ +import XCTest +@testable import DatadogTimeseries + +final class TimeseriesPipelineTests: XCTestCase { + private let config = TimeseriesConfig( + applicationId: "app-id", + sessionId: "session-id", + sessionType: "user", + source: "ios", + service: nil, + version: nil + ) + + func testProcessAllProducesCorrectNumberOfBatches() throws { + let csv = """ + timestamp,metric,value + 1000000000,memory_usage,100 + 2000000000,memory_usage,200 + 3000000000,memory_usage,300 + 4000000000,memory_usage,400 + 5000000000,memory_usage,500 + 6000000000,memory_usage,600 + 7000000000,memory_usage,700 + """ + + let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) + let pipeline = TimeseriesPipeline( + provider: provider, + config: config, + metricName: .memoryUsage, + batchSize: 3 + ) + + let results = try pipeline.processAll() + // 7 samples / 3 batch size = 2 full batches + 1 remaining batch (1 sample) + XCTAssertEqual(results.count, 3) + } + + func testProcessAllProducesValidJSON() throws { + let csv = """ + timestamp,metric,value + 1000000000,memory_usage,100 + 2000000000,memory_usage,200 + """ + + let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) + let pipeline = TimeseriesPipeline( + provider: provider, + config: config, + metricName: .memoryUsage, + batchSize: 5 + ) + + let results = try pipeline.processAll() + // 2 samples < batchSize 5 → 1 remaining batch + XCTAssertEqual(results.count, 1) + + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: results[0]) as? [String: Any]) + XCTAssertEqual(json["type"] as? String, "timeseries") + } + + func testEmptyProviderProducesNoOutput() throws { + let csv = "timestamp,metric,value\n" + let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) + let pipeline = TimeseriesPipeline( + provider: provider, + config: config, + metricName: .memoryUsage, + batchSize: 5 + ) + + let results = try pipeline.processAll() + XCTAssertTrue(results.isEmpty) + } +} From dbdc896301c7e269a2d31e793aaf4308d4a41388 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 13:40:38 +0200 Subject: [PATCH 013/102] add end-to-end verification tests with UUID masking --- .../EndToEndVerificationTests.swift | 114 ++++++++++++++++++ .../Fixtures/expected_cpu_batch1.json | 2 +- .../Fixtures/expected_cpu_batch2.json | 2 +- 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/EndToEndVerificationTests.swift diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/EndToEndVerificationTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/EndToEndVerificationTests.swift new file mode 100644 index 0000000000..37b30919af --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/EndToEndVerificationTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import DatadogTimeseries + +final class EndToEndVerificationTests: XCTestCase { + private let config = TimeseriesConfig( + applicationId: "00000000-0000-0000-0000-000000000000", + sessionId: "00000000-0000-0000-0000-000000000000", + sessionType: "user", + source: "ios", + service: nil, + version: nil + ) + + // MARK: - Memory + + func testMemoryBatch1MatchesFixture() throws { + let actual = try processMetric(.memoryUsage, batchIndex: 0) + let expected = try loadFixture("expected_memory_batch1") + XCTAssertEqual(actual, expected, "Memory batch 1 does not match fixture") + } + + func testMemoryBatch2MatchesFixture() throws { + let actual = try processMetric(.memoryUsage, batchIndex: 1) + let expected = try loadFixture("expected_memory_batch2") + XCTAssertEqual(actual, expected, "Memory batch 2 does not match fixture") + } + + // MARK: - CPU + + func testCPUBatch1MatchesFixture() throws { + let actual = try processMetric(.cpuUsage, batchIndex: 0) + let expected = try loadFixture("expected_cpu_batch1") + XCTAssertEqual(actual, expected, "CPU batch 1 does not match fixture") + } + + func testCPUBatch2MatchesFixture() throws { + let actual = try processMetric(.cpuUsage, batchIndex: 1) + let expected = try loadFixture("expected_cpu_batch2") + XCTAssertEqual(actual, expected, "CPU batch 2 does not match fixture") + } + + // MARK: - Structural validation + + func testOutputContainsRequiredFields() throws { + let results = try runPipeline(metric: .memoryUsage) + for data in results { + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertNotNil(json["_dd"]) + XCTAssertNotNil(json["application"]) + XCTAssertNotNil(json["date"]) + XCTAssertNotNil(json["session"]) + XCTAssertNotNil(json["source"]) + XCTAssertNotNil(json["timeseries"]) + XCTAssertEqual(json["type"] as? String, "timeseries") + + let dd = try XCTUnwrap(json["_dd"] as? [String: Any]) + XCTAssertEqual(dd["format_version"] as? Int, 2) + + let session = try XCTUnwrap(json["session"] as? [String: Any]) + XCTAssertEqual(session["type"] as? String, "user") + } + } + + func testMemoryProducesTwoBatches() throws { + let results = try runPipeline(metric: .memoryUsage) + XCTAssertEqual(results.count, 2, "10 samples / batchSize 5 = 2 batches") + } + + func testCPUProducesTwoBatches() throws { + let results = try runPipeline(metric: .cpuUsage) + XCTAssertEqual(results.count, 2, "10 samples / batchSize 5 = 2 batches") + } + + // MARK: - Helpers + + private func runPipeline(metric: TimeseriesName) throws -> [Data] { + let csvURL = try XCTUnwrap( + Bundle.module.url(forResource: "input_memory_cpu", withExtension: "csv", subdirectory: "Fixtures") + ) + let csvContent = try String(contentsOf: csvURL) + let provider = CSVDataProvider(csvContent: csvContent, metric: metric) + let pipeline = TimeseriesPipeline( + provider: provider, + config: config, + metricName: metric, + batchSize: 5 + ) + return try pipeline.processAll() + } + + private func processMetric(_ metric: TimeseriesName, batchIndex: Int) throws -> String { + let results = try runPipeline(metric: metric) + let jsonString = String(data: results[batchIndex], encoding: .utf8)! + return maskUUIDs(jsonString) + } + + private func loadFixture(_ name: String) throws -> String { + let url = try XCTUnwrap( + Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures") + ) + return try String(contentsOf: url).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func maskUUIDs(_ string: String) -> String { + let pattern = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + let regex = try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) + let range = NSRange(string.startIndex..., in: string) + return regex.stringByReplacingMatches( + in: string, + range: range, + withTemplate: "00000000-0000-0000-0000-000000000000" + ) + } +} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json index c435273d8d..2b7cfe0bef 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json @@ -1 +1 @@ -{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000001000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":12.5,"timestamp":1700000001000000000},{"data_point_value":14.199999999999999,"timestamp":1700000002000000000},{"data_point_value":11.800000000000001,"timestamp":1700000003000000000},{"data_point_value":16.300000000000001,"timestamp":1700000004000000000},{"data_point_value":13.699999999999999,"timestamp":1700000005000000000}],"end":1700000005000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","start":1700000001000000000},"type":"timeseries"} \ No newline at end of file +{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000001000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":12.5,"timestamp":1700000001000000000},{"data_point_value":14.2,"timestamp":1700000002000000000},{"data_point_value":11.8,"timestamp":1700000003000000000},{"data_point_value":16.3,"timestamp":1700000004000000000},{"data_point_value":13.7,"timestamp":1700000005000000000}],"end":1700000005000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","start":1700000001000000000},"type":"timeseries"} \ No newline at end of file diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json index 1cc8e8eec2..8be6322e13 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json @@ -1 +1 @@ -{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000006000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":18.100000000000001,"timestamp":1700000006000000000},{"data_point_value":22.399999999999999,"timestamp":1700000007000000000},{"data_point_value":19.600000000000001,"timestamp":1700000008000000000},{"data_point_value":15.9,"timestamp":1700000009000000000},{"data_point_value":13.199999999999999,"timestamp":1700000010000000000}],"end":1700000010000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","start":1700000006000000000},"type":"timeseries"} \ No newline at end of file +{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000006000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":18.1,"timestamp":1700000006000000000},{"data_point_value":22.4,"timestamp":1700000007000000000},{"data_point_value":19.6,"timestamp":1700000008000000000},{"data_point_value":15.9,"timestamp":1700000009000000000},{"data_point_value":13.2,"timestamp":1700000010000000000}],"end":1700000010000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","start":1700000006000000000},"type":"timeseries"} \ No newline at end of file From d02d977c2f3a86fae12522aca078b95767d32ccf Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 13:41:12 +0200 Subject: [PATCH 014/102] add skip-sample error handling tests for gap behavior --- .../SkipSampleTests.swift | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift new file mode 100644 index 0000000000..edd7548445 --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import DatadogTimeseries + +final class SkipSampleTests: XCTestCase { + private let config = TimeseriesConfig( + applicationId: "app-id", + sessionId: "session-id", + sessionType: "user", + source: "ios", + service: nil, + version: nil + ) + + func testGapInCSVProducesFewerDataPoints() throws { + // 5 rows but only 3 are memory_usage (gap at timestamps 2 and 4) + let csv = """ + timestamp,metric,value + 1000000000,memory_usage,100 + 2000000000,cpu_usage,10 + 3000000000,memory_usage,300 + 4000000000,cpu_usage,20 + 5000000000,memory_usage,500 + """ + + let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) + let pipeline = TimeseriesPipeline( + provider: provider, + config: config, + metricName: .memoryUsage, + batchSize: 5 + ) + + let results = try pipeline.processAll() + XCTAssertEqual(results.count, 1) // 3 samples < batchSize 5 → 1 remaining batch + + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: results[0]) as? [String: Any]) + let ts = try XCTUnwrap(json["timeseries"] as? [String: Any]) + let data = try XCTUnwrap(ts["data"] as? [[String: Any]]) + + XCTAssertEqual(data.count, 3, "Only 3 memory_usage samples, gap reflected") + XCTAssertEqual(data[0]["timestamp"] as? Int64, 1000000000) + XCTAssertEqual(data[1]["timestamp"] as? Int64, 3000000000) // gap: 2s jumped + XCTAssertEqual(data[2]["timestamp"] as? Int64, 5000000000) + } + + func testMalformedRowsSkipped() throws { + let csv = """ + timestamp,metric,value + 1000000000,memory_usage,100 + not_a_number,memory_usage,bad + 3000000000,memory_usage,300 + """ + + let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) + let pipeline = TimeseriesPipeline( + provider: provider, + config: config, + metricName: .memoryUsage, + batchSize: 5 + ) + + let results = try pipeline.processAll() + XCTAssertEqual(results.count, 1) + + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: results[0]) as? [String: Any]) + let ts = try XCTUnwrap(json["timeseries"] as? [String: Any]) + let data = try XCTUnwrap(ts["data"] as? [[String: Any]]) + + XCTAssertEqual(data.count, 2, "Malformed row skipped") + } + + func testTimestampsReflectGap() throws { + let csv = """ + timestamp,metric,value + 1000000000,memory_usage,100 + 5000000000,memory_usage,500 + """ + + let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) + let pipeline = TimeseriesPipeline( + provider: provider, + config: config, + metricName: .memoryUsage, + batchSize: 5 + ) + + let results = try pipeline.processAll() + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: results[0]) as? [String: Any]) + let ts = try XCTUnwrap(json["timeseries"] as? [String: Any]) + + XCTAssertEqual(ts["start"] as? Int64, 1000000000) + XCTAssertEqual(ts["end"] as? Int64, 5000000000) + } +} From afe039711a6837996e375473385d87cd0608ea8f Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 13:41:31 +0200 Subject: [PATCH 015/102] remove placeholder files after full test suite is in place --- .../Tests/DatadogTimeseriesTests/Fixtures/.gitkeep | 0 .../Tests/DatadogTimeseriesTests/SmokeTests.swift | 10 ---------- 2 files changed, 10 deletions(-) delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/.gitkeep delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/SmokeTests.swift diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/.gitkeep b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/SmokeTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/SmokeTests.swift deleted file mode 100644 index d8df032d21..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/SmokeTests.swift +++ /dev/null @@ -1,10 +0,0 @@ -import XCTest -@testable import DatadogTimeseries - -final class SmokeTests: XCTestCase { - func testPackageCompiles() { - let sample = Sample(timestamp: 1_000_000_000, value: 42.0) - XCTAssertEqual(sample.timestamp, 1_000_000_000) - XCTAssertEqual(sample.value, 42.0) - } -} From 11412dc0f7df7f5435a154b996b706bfff537f64 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 14:57:17 +0200 Subject: [PATCH 016/102] fix pod rules --- plans/git-rules-pod.md | 1 - 1 file changed, 1 deletion(-) diff --git a/plans/git-rules-pod.md b/plans/git-rules-pod.md index 161782f60d..1b4da55c30 100644 --- a/plans/git-rules-pod.md +++ b/plans/git-rules-pod.md @@ -19,7 +19,6 @@ - **Single-line commit message**, starting with a **verb** in the imperative form. - **No JIRA prefix** — there are no individual tickets during the pod. - **No co-author lines** — Barbora is the sole commit author. -- **No signed commits required** — skip GPG/SSH signing for speed. - Commit frequently — small, logical units of work. The agent should commit after each completed task or meaningful step. - No description lines unless explicitly requested. From bab85a6619f0d05af47f923df5fcaa48ae13633c Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 14 Apr 2026 16:01:23 +0200 Subject: [PATCH 017/102] add plan 2 for platform-agnostic integration wiring --- plans/2-plan-platform-integration.md | 182 +++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 plans/2-plan-platform-integration.md diff --git a/plans/2-plan-platform-integration.md b/plans/2-plan-platform-integration.md new file mode 100644 index 0000000000..0941df3729 --- /dev/null +++ b/plans/2-plan-platform-integration.md @@ -0,0 +1,182 @@ +# PLAN.md — Platform Integration Wiring (Plan 2) + +**Date:** 2026-04-14 +**Epic:** RUM-13949 +**Pod:** AI-first Performance Timeseries +**Author:** Barbora Plasovska + +--- + +## Purpose + +This plan tells an agent how to wire the Plan 1 business logic into any SDK platform (iOS, Android, React Native, etc.). It is **platform-agnostic** — described in prose with hints. The agent is told "implement Plan 2 for [platform]" and figures out the platform-specific code. + +### Relationship to Plan 1 + +- **Plan 1** = standalone business logic (batcher → builder → encoder). Produces RUM timeseries JSON events from timestamped samples. Verified with CSV input → JSON output exact match. +- **Plan 2 (this plan)** = wire Plan 1's logic into a real SDK. Read real metrics, run on a timer, plug into the session lifecycle, send output to the upload pipeline. + +Plan 1 is a **reference implementation**, not a library to import. The agent must: +- Read `plans/1-plan-business-logic.md` for architecture and design decisions +- Read `DatadogTimeseries/` source code for exact logic (batcher, builder, encoder, timestamp handling) +- **Rewrite** the logic into the target SDK's code style, module structure, and conventions +- Event models must come from the SDK's own model generation system, not copied from Plan 1 + +After Plan 2 is implemented for a platform: +- Remove the `DatadogTimeseries/` standalone package from the repo — it was a validation tool, not a permanent artifact +- Move the test fixtures (CSV input, expected JSON) into the platform SDK's test directory + +--- + +## Decisions Log (carried from Plan 1 + IPCIVR) + +### Design decisions from Plan 1 (must be followed) + +| Decision | Detail | +|----------|--------| +| Batcher is metric-agnostic | One batcher per metric, it just accumulates samples. Metric name is assigned by the builder, not the batcher. | +| Event builder owns metric name | Builder receives `metricName` when building an event. Batcher doesn't know what metric it's batching. | +| `date` field = first sample timestamp in ms | `start` timestamp (nanoseconds) divided by 1,000,000. | +| Timestamps | Event-level `date` in milliseconds. Everything inside `timeseries` (start, end, data point timestamps) in nanoseconds. | +| `_dd.format_version` = 2 | Constant. | +| `session.type` = "user" | Constant for now. | +| `type` = "timeseries" | Constant. | +| Explicit CodingKeys / field mapping | All JSON keys are snake_case. No automatic conversion — explicit mapping to avoid edge cases like `_dd`. | +| Two metrics for MVP | `memory_usage` and `cpu_usage`. Closed enum with documented extension path. | +| Skip failed samples | If a metric read fails, skip it (leave a gap). Don't crash, don't retry. | + +### Plan 2 decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Reuse mode | Rewrite into SDK | Plan 1 is reference. Agent rewrites using SDK patterns and conventions. | +| Plan structure | 3 phases | (1) Metrics flowing, (2) Session lifecycle, (3) Upload pipeline. Each independently verifiable. | +| Detail level | Requirements + hints | State what to achieve, add hints like "look at VitalReaders" to point the agent in the right direction. | +| Verification | Integration tests with mocks | Mock boundaries, trigger manually, verify JSON output. Follow SDK's existing test patterns. | +| Background handling | Future concern | Not addressed in Plan 2. MVP: collect only while app is active. | +| E2E staging validation | Outside scope | Plan 2 stops at "JSON handed to upload pipeline". | +| Config flag | TODO, default opt-in | Add a config flag, default disabled. Decision pending, easy to flip. | +| Fixtures after removal | Move to platform tests | Copy CSV/JSON fixtures from Plan 1 into the SDK's test directory. | + +--- + +## TODO Placeholders (blocked decisions) + +These are not yet decided. Implement with the stated defaults; they are designed to be easy to change. + +| Item | Default | What might change | Impact of change | +|------|---------|-------------------|-----------------| +| Schema | Current staging schema (numeric-only `data_point_value`) | Polymorphic value schema (A/B/C) — waiting on William/backend results | Event model struct changes. Regenerate from `rum-events-format`. | +| Batch size | 30 (30 seconds at 1Hz) | Backend may recommend different size | One constant to change. | +| Collection interval | 1 second | Could change based on performance feedback | One constant to change. | +| Config flag default | `false` (opt-in) | Team may decide always-on | One boolean default to flip. | + +--- + +## Phase 1 — Get metrics flowing + +### What to do + +1. Find the SDK's existing metric/vital readers for memory and CPU +2. Create a collector component that: + - Runs a 1-second periodic timer on a background thread + - Each tick: reads memory and CPU from the existing readers + - Creates a `Sample(timestamp_nanoseconds, value)` for each metric + - Feeds each sample into a batcher (one batcher per metric, metric-agnostic) + - When a batcher is ready to flush (buffer >= batch size), builds a timeseries event via the builder and encodes it to JSON +3. If a metric read fails, skip it (leave a gap in the data). Do not crash or retry. + +### Hints + +- Look for existing periodic collection patterns in the SDK (e.g. vital readers, performance monitors). Follow the same threading, timer, and lifecycle patterns. +- If no existing metric collection pattern exists, create a minimal background timer that reads metrics and feeds the pipeline. Keep it simple. +- The timer should not block the main thread. Buffer access must be thread-safe. + +### Done when + +- A test exists that: mocks the metric reader to return known values, triggers collection manually (no real timer wait), and verifies that correctly-shaped JSON events are produced. +- The JSON output matches the expected structure from Plan 1 (same fields, same timestamp precision, same constants). + +--- + +## Phase 2 — Add session lifecycle + +### What to do + +4. Wire the collector to the RUM session lifecycle: + - **Start** collecting when a new RUM session begins + - **Stop** collecting when the session ends (timeout, max duration, or explicit stop) + - **Flush remaining** on stop: flush any samples left in both batchers, even if below batch size. Don't lose the tail. +5. On session renewal (old session ends, new one starts): stop old collector, create new one. No gap, no overlap. + +### Hints + +- Look for the session scope/manager in the SDK. Look at how other session-scoped components are created and destroyed. Follow the same pattern. +- Look at how the SDK handles session timeout (e.g. 15 min inactivity) and max session duration (e.g. 4 hours). The collector should respond to the same signals. + +### Done when + +- A test exists that: simulates session start → collection → session stop, and verifies that (a) events are produced during the session, (b) remaining samples are flushed on stop, (c) no events are produced after stop. + +--- + +## Phase 3 — Connect to upload pipeline + +### What to do + +6. Hand the encoded JSON events to the SDK's existing event writer/upload pipeline +7. The events should flow through the same path as other RUM events (storage → upload → backend) +8. Add a configuration flag to the SDK's RUM configuration (default: disabled / opt-in). When disabled, the collector is not created. + +### Hints + +- Look at how other RUM event types (views, actions, errors) are written to the pipeline. Follow the same writer pattern. +- For the config flag, look for existing boolean feature flags in the RUM configuration (e.g. `trackFrustrations`, `trackBackgroundEvents`). Follow the same pattern. + +### Done when + +- A test exists that: enables the config flag, starts a session, triggers collection, and verifies that events reach the writer/upload layer (mocked at the writer boundary). +- The config flag defaults to disabled. When disabled, no collector is created, no timer runs, no overhead. +- All existing SDK tests still pass (no regressions). + +--- + +## Verification Strategy + +### What to test + +1. **Phase 1**: Given known metric values → collector produces correctly-shaped JSON events +2. **Phase 2**: Session start triggers collection, session stop flushes and stops, no events after stop +3. **Phase 3**: Events reach the upload pipeline, config flag gates the feature, no regressions + +### How to test + +- Mock the metric readers to return deterministic values +- Trigger the timer/collection manually (inject a manual trigger instead of waiting for real seconds) +- Mock the writer to capture output and verify it +- Look at how existing collectors/features are tested in the SDK. Follow the same mocking approach and test utilities. +- Copy Plan 1's test fixtures (CSV input, expected JSON) into the platform's test directory for reference + +--- + +## How to use this plan + +``` +Agent prompt: + +"Implement Plan 2 for [iOS / Android / React Native]. + +1. Read plans/1-plan-business-logic.md for design decisions +2. Read DatadogTimeseries/ source code for exact business logic +3. Read this plan (plans/2-plan-platform-integration.md) for integration steps +4. Explore the target SDK to find existing patterns for: + - Metric/vital readers (memory, CPU) + - Periodic collection (timers, background threads) + - Session lifecycle (scope/manager, start/stop signals) + - Event writing (how RUM events reach the upload pipeline) + - RUM configuration flags + - Test patterns (mocking, test utilities) +5. Implement the 3 phases in order, following TDD +6. After implementation: remove DatadogTimeseries/ standalone package, + move test fixtures to the platform's test directory" +``` From 898b7970d45d6345754bcfa942475d515f31b2b6 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 15 Apr 2026 10:55:08 +0200 Subject: [PATCH 018/102] move plans and demo into pod/ directory --- pod/demo/demo.sh | 202 ++++++++++++++++++ {plans => pod/plans}/1-plan-business-logic.md | 0 .../plans}/2-plan-platform-integration.md | 0 {plans => pod/plans}/git-rules-pod.md | 0 4 files changed, 202 insertions(+) create mode 100755 pod/demo/demo.sh rename {plans => pod/plans}/1-plan-business-logic.md (100%) rename {plans => pod/plans}/2-plan-platform-integration.md (100%) rename {plans => pod/plans}/git-rules-pod.md (100%) diff --git a/pod/demo/demo.sh b/pod/demo/demo.sh new file mode 100755 index 0000000000..3116109a45 --- /dev/null +++ b/pod/demo/demo.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================ +# Performance Timeseries — Plan 1 Demo Script +# AI-first POD | Week 1 | April 18, 2026 +# ============================================================================ +# +# Configure these paths for your machine: +# +IOS_SDK_PATH="$HOME/go/src/github.com/DataDog/dd-sdk-ios" +ANDROID_SDK_PATH="$HOME/dd/sdks/dd-sdk-android" +JAVA_HOME_PATH="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +# +# ============================================================================ + +BOLD="\033[1m" +DIM="\033[2m" +GREEN="\033[32m" +CYAN="\033[36m" +YELLOW="\033[33m" +RESET="\033[0m" + +separator() { + echo "" + echo -e "${DIM}────────────────────────────────────────────────────────────${RESET}" + echo "" +} + +heading() { + echo -e "${BOLD}${CYAN}$1${RESET}" +} + +subheading() { + echo -e "${BOLD}$1${RESET}" +} + +narrate() { + echo -e "${DIM}$1${RESET}" +} + +pause() { + echo "" + read -r -p " [press enter to continue]" + echo "" +} + +# ============================================================================ +clear +echo "" +heading " PERFORMANCE TIMESERIES — Plan 1 Demo" +echo "" +narrate " AI-first POD | Sprint 1 | RUM-13949" +narrate " Barbora Plasovska | April 18, 2026" +separator + +heading "This Week's Focus" +echo "" +echo " Built a standalone, platform-agnostic pipeline that implements" +echo " the core business logic of the timeseries feature." +echo "" +echo " What the pipeline does:" +echo " Takes timestamped performance samples (memory, CPU — sampled every 1s)" +echo " and transforms them into complete RUM timeseries JSON events." +echo "" +echo " Samples --> Batcher --> Event Builder --> JSON Encoder --> RUM JSON" +echo "" +echo " Zero SDK dependencies — pure logic, tested on both iOS (Swift)" +echo " and Android (Kotlin) against the same expected fixtures." +echo " This is the foundation everything else builds on." +pause + +# ============================================================================ +separator +heading "1/4 iOS Pipeline (Swift)" +echo "" +narrate " Package: dd-sdk-ios/DatadogTimeseries/" +narrate " Running: swift test" +echo "" + +IOS_PACKAGE="$IOS_SDK_PATH/DatadogTimeseries" +if [ ! -d "$IOS_PACKAGE" ]; then + echo -e " ${YELLOW}Skipped — $IOS_PACKAGE not found${RESET}" +else + cd "$IOS_PACKAGE" + swift test 2>&1 | grep -E "Test Suite|test.*passed|test.*failed|All tests" | while IFS= read -r line; do + if echo "$line" | grep -q "passed"; then + echo -e " ${GREEN}$line${RESET}" + elif echo "$line" | grep -q "failed"; then + echo -e " ${YELLOW}$line${RESET}" + else + echo -e " $line" + fi + done +fi +pause + +# ============================================================================ +separator +heading "2/4 Android Pipeline (Kotlin)" +echo "" +narrate " Package: dd-sdk-android/DatadogTimeseries/" +narrate " Running: ./gradlew cleanTest test" +echo "" + +ANDROID_PACKAGE="$ANDROID_SDK_PATH/DatadogTimeseries" +if [ ! -d "$ANDROID_PACKAGE" ]; then + echo -e " ${YELLOW}Skipped — $ANDROID_PACKAGE not found${RESET}" +else + cd "$ANDROID_PACKAGE" + JAVA_HOME="$JAVA_HOME_PATH" ./gradlew cleanTest test 2>&1 | grep -E "PASSED|FAILED" | while IFS= read -r line; do + if echo "$line" | grep -q "PASSED"; then + echo -e " ${GREEN}$line${RESET}" + else + echo -e " ${YELLOW}$line${RESET}" + fi + done +fi +pause + +# ============================================================================ +separator +heading "3/4 Cross-Platform Fixture Match" +echo "" +echo " Both platforms verify against the SAME expected JSON fixtures." +echo " This proves the business logic produces identical RUM events" +echo " regardless of platform." +echo "" + +FIXTURE_IOS="$IOS_SDK_PATH/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json" +FIXTURE_ANDROID="$ANDROID_SDK_PATH/DatadogTimeseries/src/test/resources/fixtures/expected_memory_batch1.json" + +if [ -f "$FIXTURE_IOS" ] && [ -f "$FIXTURE_ANDROID" ]; then + subheading " Fixture: expected_memory_batch1.json" + echo "" + + if diff -q "$FIXTURE_IOS" "$FIXTURE_ANDROID" > /dev/null 2>&1; then + echo -e " ${GREEN}iOS and Android fixtures are identical${RESET}" + else + echo -e " ${YELLOW}Fixtures differ (check manually)${RESET}" + fi + + echo "" + subheading " Sample event (memory_usage, batch 1):" + echo "" + if command -v python3 > /dev/null 2>&1; then + python3 -m json.tool "$FIXTURE_IOS" 2>/dev/null | head -30 | sed 's/^/ /' + LINES=$(python3 -m json.tool "$FIXTURE_IOS" 2>/dev/null | wc -l) + if [ "$LINES" -gt 30 ]; then + narrate " ... ($(( LINES - 30 )) more lines)" + fi + else + cat "$FIXTURE_IOS" | sed 's/^/ /' + fi +else + narrate " (fixture files not found — skipping comparison)" +fi +pause + +# ============================================================================ +separator +heading "4/4 What This Gives Us" +echo "" +echo " The standalone pipeline is a verified reference implementation." +echo " Every component is tested in isolation AND end-to-end." +echo "" +subheading " Components:" +echo " CSVDataProvider — reads timestamped samples from CSV" +echo " TimeseriesBatcher — accumulates N samples, flushes when full" +echo " TimeseriesEventBuilder — samples + config --> full RUM envelope" +echo " TimeseriesEncoder — event --> deterministic JSON" +echo " TimeseriesPipeline — orchestrates the full flow" +echo "" +subheading " Test coverage:" +echo " iOS: 36 tests (byte-for-byte fixture match)" +echo " Android: 36 tests (structural JSON comparison)" +echo "" +subheading " Schema contract:" +echo " _dd.format_version = 2" +echo " type = \"timeseries\"" +echo " timeseries.name = memory_usage | cpu_usage" +echo " Event date in ms, data point timestamps in ns" +echo " Null fields omitted (service, version)" +pause + +# ============================================================================ +separator +heading "Next Steps" +echo "" +echo " Complete the end-to-end pipeline:" +echo " - Wire SDK business logic into the real iOS and Android SDKs" +echo " - Connect to backend so we can test the full pipeline" +echo " (SDK --> backend --> query)" +echo "" +echo " Once the full pipeline works end-to-end:" +echo " - Iterate on sampling rates, batching strategies, thresholds" +echo " - Measure real session size impact" +echo " - Tune based on backend query performance feedback" +separator +echo "" +narrate " End of demo." +echo "" diff --git a/plans/1-plan-business-logic.md b/pod/plans/1-plan-business-logic.md similarity index 100% rename from plans/1-plan-business-logic.md rename to pod/plans/1-plan-business-logic.md diff --git a/plans/2-plan-platform-integration.md b/pod/plans/2-plan-platform-integration.md similarity index 100% rename from plans/2-plan-platform-integration.md rename to pod/plans/2-plan-platform-integration.md diff --git a/plans/git-rules-pod.md b/pod/plans/git-rules-pod.md similarity index 100% rename from plans/git-rules-pod.md rename to pod/plans/git-rules-pod.md From 837665f7e4a5cdcbda3fb536d8d5aee2aa1588be Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 15 Apr 2026 10:58:12 +0200 Subject: [PATCH 019/102] rename demo script to 01-demo-18-04 --- pod/demo/{demo.sh => 01-demo-18-04.sh} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pod/demo/{demo.sh => 01-demo-18-04.sh} (100%) diff --git a/pod/demo/demo.sh b/pod/demo/01-demo-18-04.sh similarity index 100% rename from pod/demo/demo.sh rename to pod/demo/01-demo-18-04.sh From 34bb2ca46e08e800badae1b793fd012325d3c825 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 16 Apr 2026 11:56:19 +0200 Subject: [PATCH 020/102] add SampleFilter protocol and filter implementations --- .../Filters/DeadbandFilter.swift | 45 +++++++++++++ .../Filters/PassThroughFilter.swift | 20 ++++++ .../Filters/SampleFilter.swift | 28 ++++++++ .../Filters/WindowAggregateFilter.swift | 67 +++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Filters/DeadbandFilter.swift create mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Filters/PassThroughFilter.swift create mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Filters/SampleFilter.swift create mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Filters/WindowAggregateFilter.swift diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Filters/DeadbandFilter.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Filters/DeadbandFilter.swift new file mode 100644 index 0000000000..f1b9135b69 --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Filters/DeadbandFilter.swift @@ -0,0 +1,45 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation + +/// Suppresses samples whose value has not changed meaningfully since the last emitted sample. +/// Use for slow-changing metrics like memory usage and battery level. +public final class DeadbandFilter: SampleFilter { + private let threshold: Double + private let heartbeatInterval: Int64? + + private var lastEmittedValue: Double? + private var lastEmittedTimestamp: Int64? + + public init(threshold: Double, heartbeatInterval: Int64? = nil) { + self.threshold = threshold + self.heartbeatInterval = heartbeatInterval + } + + public func process(_ sample: Sample) -> [Sample] { + guard let lastValue = lastEmittedValue, let lastTimestamp = lastEmittedTimestamp else { + lastEmittedValue = sample.value + lastEmittedTimestamp = sample.timestamp + return [sample] + } + + let valueChanged = abs(sample.value - lastValue) >= threshold + let heartbeatDue = heartbeatInterval.map { sample.timestamp - lastTimestamp >= $0 } ?? false + + if valueChanged || heartbeatDue { + lastEmittedValue = sample.value + lastEmittedTimestamp = sample.timestamp + return [sample] + } + + return [] + } + + public func flush() -> [Sample] { + return [] + } +} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Filters/PassThroughFilter.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Filters/PassThroughFilter.swift new file mode 100644 index 0000000000..7ecec5ffa5 --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Filters/PassThroughFilter.swift @@ -0,0 +1,20 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation + +/// Passes every sample through unchanged. Represents the baseline pipeline behaviour with no sampling strategy applied. +public final class PassThroughFilter: SampleFilter { + public init() {} + + public func process(_ sample: Sample) -> [Sample] { + return [sample] + } + + public func flush() -> [Sample] { + return [] + } +} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Filters/SampleFilter.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Filters/SampleFilter.swift new file mode 100644 index 0000000000..afa95dd87b --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Filters/SampleFilter.swift @@ -0,0 +1,28 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation + +/// A stateful filter that processes samples from a provider before they are forwarded to the batcher. +/// +/// Filters are class-only (reference semantics) because they maintain internal state across calls. +public protocol SampleFilter: AnyObject { + /// Called for each sample emitted by the provider. + /// + /// - Parameter sample: The incoming sample to process. + /// - Returns: The samples to forward to the batcher. Return an empty array to suppress the sample, + /// return `[sample]` to forward it unchanged, or return multiple samples to expand it. + func process(_ sample: Sample) -> [Sample] + + /// Called once when the provider is exhausted, signalling end-of-stream. + /// + /// Use this method to flush any internally buffered samples that have not yet been forwarded. + /// Most filters return an empty array here. Aggregating filters (e.g. `WindowAggregateFilter`) + /// use this to emit the final partial window that would otherwise be held back. + /// + /// - Returns: Any remaining samples that should be forwarded to the batcher. + func flush() -> [Sample] +} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Filters/WindowAggregateFilter.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Filters/WindowAggregateFilter.swift new file mode 100644 index 0000000000..05e37099d7 --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Filters/WindowAggregateFilter.swift @@ -0,0 +1,67 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation + +public enum AggregateFunction { + case avg, min, max, last +} + +/// Aggregates samples into fixed time windows and emits one value per window. +/// Use for noisy continuous metrics like CPU usage and frame rate. +public final class WindowAggregateFilter: SampleFilter { + private let windowDuration: Int64 + private let function: AggregateFunction + + private var windowStart: Int64? + private var buffer: [Sample] = [] + + public init(windowDuration: Int64, function: AggregateFunction = .max) { + self.windowDuration = windowDuration + self.function = function + } + + public func process(_ sample: Sample) -> [Sample] { + guard let start = windowStart else { + windowStart = sample.timestamp + buffer.append(sample) + return [] + } + + if sample.timestamp - start >= windowDuration { + let aggregateSample = Sample(timestamp: start, value: aggregate(buffer)) + windowStart = sample.timestamp + buffer = [sample] + return [aggregateSample] + } + + buffer.append(sample) + return [] + } + + public func flush() -> [Sample] { + guard !buffer.isEmpty else { + return [] + } + + let aggregateSample = Sample(timestamp: windowStart!, value: aggregate(buffer)) + buffer = [] + return [aggregateSample] + } + + private func aggregate(_ samples: [Sample]) -> Double { + switch function { + case .avg: + return samples.reduce(0.0) { $0 + $1.value } / Double(samples.count) + case .min: + return samples.min(by: { $0.value < $1.value })!.value + case .max: + return samples.max(by: { $0.value < $1.value })!.value + case .last: + return samples.last!.value + } + } +} From 7e480e5bbb251a5e0088e590a8029e7dc1b83ed7 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 16 Apr 2026 11:56:26 +0200 Subject: [PATCH 021/102] wire SampleFilter into TimeseriesPipeline and expose public API --- .../Core/TimeseriesConfig.swift | 23 +++++--- .../DataProvider/CSVDataProvider.swift | 6 +-- .../DataProvider/DataProvider.swift | 2 +- .../DatadogTimeseries/Models/Sample.swift | 11 ++-- .../Models/TimeseriesEvent.swift | 52 +++++++++---------- .../Models/TimeseriesName.swift | 2 +- .../TimeseriesPipeline.swift | 43 ++++++++------- 7 files changed, 76 insertions(+), 63 deletions(-) diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift index 7b8c8c2617..ec8e9083a1 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift @@ -1,10 +1,19 @@ import Foundation -struct TimeseriesConfig { - let applicationId: String - let sessionId: String - let sessionType: String - let source: String - let service: String? - let version: String? +public struct TimeseriesConfig { + public let applicationId: String + public let sessionId: String + public let sessionType: String + public let source: String + public let service: String? + public let version: String? + + public init(applicationId: String, sessionId: String, sessionType: String, source: String, service: String?, version: String?) { + self.applicationId = applicationId + self.sessionId = sessionId + self.sessionType = sessionType + self.source = source + self.service = service + self.version = version + } } diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift index 7283c7918c..19afa4a465 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift @@ -1,10 +1,10 @@ import Foundation -class CSVDataProvider: DataProvider { +public class CSVDataProvider: DataProvider { private var samples: [Sample] private var index: Int = 0 - init(csvContent: String, metric: TimeseriesName) { + public init(csvContent: String, metric: TimeseriesName) { var parsed: [Sample] = [] let lines = csvContent.components(separatedBy: "\n") @@ -25,7 +25,7 @@ class CSVDataProvider: DataProvider { self.samples = parsed } - func read() -> Sample? { + public func read() -> Sample? { guard index < samples.count else { return nil } let sample = samples[index] index += 1 diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift index 9a0ec4d0f2..235f82a3be 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift @@ -1,5 +1,5 @@ import Foundation -protocol DataProvider { +public protocol DataProvider { func read() -> Sample? } diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift index d08b65329d..df1348be2e 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift @@ -1,9 +1,14 @@ import Foundation /// A single timestamped performance sample. -struct Sample { +public struct Sample { /// Timestamp in nanoseconds. - let timestamp: Int64 + public let timestamp: Int64 /// Metric value (e.g. bytes for memory, percent for CPU). - let value: Double + public let value: Double + + public init(timestamp: Int64, value: Double) { + self.timestamp = timestamp + self.value = value + } } diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift index fb0f1901c2..d7ba7c77a1 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift @@ -1,15 +1,15 @@ import Foundation -struct TimeseriesEvent: Codable { - let dd: DD - let application: Application - let date: Int64 - let session: Session - let source: String - let type: String - let service: String? - let version: String? - let timeseries: Timeseries +public struct TimeseriesEvent: Codable { + public let dd: DD + public let application: Application + public let date: Int64 + public let session: Session + public let source: String + public let type: String + public let service: String? + public let version: String? + public let timeseries: Timeseries enum CodingKeys: String, CodingKey { case dd = "_dd" @@ -23,34 +23,34 @@ struct TimeseriesEvent: Codable { case timeseries } - struct DD: Codable { - let formatVersion: Int + public struct DD: Codable { + public let formatVersion: Int enum CodingKeys: String, CodingKey { case formatVersion = "format_version" } } - struct Application: Codable { - let id: String + public struct Application: Codable { + public let id: String } - struct Session: Codable { - let id: String - let type: String + public struct Session: Codable { + public let id: String + public let type: String } - struct Timeseries: Codable { - let id: String - let name: TimeseriesName - let start: Int64 - let end: Int64 - let data: [DataPoint] + public struct Timeseries: Codable { + public let id: String + public let name: TimeseriesName + public let start: Int64 + public let end: Int64 + public let data: [DataPoint] } - struct DataPoint: Codable { - let timestamp: Int64 - let dataPointValue: Double + public struct DataPoint: Codable { + public let timestamp: Int64 + public let dataPointValue: Double enum CodingKeys: String, CodingKey { case timestamp diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift index d377a4204c..1c168d8438 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift @@ -1,6 +1,6 @@ import Foundation -enum TimeseriesName: String, Codable { +public enum TimeseriesName: String, Codable { case memoryUsage = "memory_usage" case cpuUsage = "cpu_usage" } diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift b/DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift index 6d2da7a31c..7b46fb3f1a 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift @@ -1,47 +1,46 @@ import Foundation -struct TimeseriesPipeline { +public struct TimeseriesPipeline { private let provider: DataProvider private let config: TimeseriesConfig private let metricName: TimeseriesName private let batchSize: Int + private let filter: SampleFilter - init(provider: DataProvider, config: TimeseriesConfig, metricName: TimeseriesName, batchSize: Int = 30) { + public init(provider: DataProvider, config: TimeseriesConfig, metricName: TimeseriesName, batchSize: Int = 30, filter: SampleFilter = PassThroughFilter()) { self.provider = provider self.config = config self.metricName = metricName self.batchSize = batchSize + self.filter = filter } - func processAll() throws -> [Data] { + public func processAll() throws -> [Data] { let batcher = TimeseriesBatcher(batchSize: batchSize) let builder = TimeseriesEventBuilder(config: config) let encoder = TimeseriesEncoder() - var results: [Data] = [] - while let sample = provider.read() { - batcher.add(sample) - if batcher.shouldFlush() { - let batch = batcher.flush() - let event = builder.build( - samples: batch, - name: metricName, - eventId: UUID().uuidString.lowercased() - ) - let data = try encoder.encode(event) - results.append(data) + func processSamples(_ samples: [Sample]) throws { + for sample in samples { + batcher.add(sample) + if batcher.shouldFlush() { + let batch = batcher.flush() + let event = builder.build(samples: batch, name: metricName, eventId: UUID().uuidString.lowercased()) + results.append(try encoder.encode(event)) + } } } + while let raw = provider.read() { + try processSamples(filter.process(raw)) + } + + try processSamples(filter.flush()) + if let remaining = batcher.flushRemaining() { - let event = builder.build( - samples: remaining, - name: metricName, - eventId: UUID().uuidString.lowercased() - ) - let data = try encoder.encode(event) - results.append(data) + let event = builder.build(samples: remaining, name: metricName, eventId: UUID().uuidString.lowercased()) + results.append(try encoder.encode(event)) } return results From 53562ed65a2306d123f72cb011ecf3ec40b04976 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 16 Apr 2026 11:56:30 +0200 Subject: [PATCH 022/102] add DatadogTimeseriesRunner executable target --- DatadogTimeseries/Package.swift | 5 + .../DatadogTimeseriesRunner/main.swift | 209 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 DatadogTimeseries/Sources/DatadogTimeseriesRunner/main.swift diff --git a/DatadogTimeseries/Package.swift b/DatadogTimeseries/Package.swift index c34c78a702..b5759d9ba6 100644 --- a/DatadogTimeseries/Package.swift +++ b/DatadogTimeseries/Package.swift @@ -16,6 +16,11 @@ let package = Package( dependencies: [], path: "Sources/DatadogTimeseries" ), + .executableTarget( + name: "DatadogTimeseriesRunner", + dependencies: ["DatadogTimeseries"], + path: "Sources/DatadogTimeseriesRunner" + ), .testTarget( name: "DatadogTimeseriesTests", dependencies: ["DatadogTimeseries"], diff --git a/DatadogTimeseries/Sources/DatadogTimeseriesRunner/main.swift b/DatadogTimeseries/Sources/DatadogTimeseriesRunner/main.swift new file mode 100644 index 0000000000..28f47aa59e --- /dev/null +++ b/DatadogTimeseries/Sources/DatadogTimeseriesRunner/main.swift @@ -0,0 +1,209 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogTimeseries + +// MARK: - Argument parsing + +struct RunnerArgs { + var fixturePath: String = "" + var outputDir: String = "" + var threshold: Double = 1_000_000 + var heartbeat: Int64 = 30 + var windowSeconds: Int64 = 5 + var aggregate: AggregateFunction = .max +} + +func parseArgs() -> RunnerArgs { + var args = RunnerArgs() + var i = 1 + let argv = CommandLine.arguments + while i < argv.count { + switch argv[i] { + case "--fixture-path": + i += 1; args.fixturePath = argv[i] + case "--output-dir": + i += 1; args.outputDir = argv[i] + case "--threshold": + i += 1; args.threshold = Double(argv[i]) ?? args.threshold + case "--heartbeat": + i += 1; args.heartbeat = Int64(argv[i]) ?? args.heartbeat + case "--window": + i += 1; args.windowSeconds = Int64(argv[i]) ?? args.windowSeconds + case "--aggregate": + i += 1 + switch argv[i] { + case "avg": args.aggregate = .avg + case "min": args.aggregate = .min + case "last": args.aggregate = .last + default: args.aggregate = .max + } + default: + break + } + i += 1 + } + return args +} + +// MARK: - Pipeline helpers + +struct PipelineResult { + let events: [Data] + var eventCount: Int { events.count } + var dataPointCount: Int { + let decoder = JSONDecoder() + return events.compactMap { data -> Int? in + let event = try? decoder.decode(TimeseriesEvent.self, from: data) + return event?.timeseries.data.count + }.reduce(0, +) + } +} + +func runPipeline(csvContent: String, metric: TimeseriesName, filter: SampleFilter) throws -> PipelineResult { + let config = TimeseriesConfig( + applicationId: "runner-app-id", + sessionId: "runner-session-id", + sessionType: "user", + source: "ios", + service: nil, + version: nil + ) + let provider = CSVDataProvider(csvContent: csvContent, metric: metric) + let pipeline = TimeseriesPipeline(provider: provider, config: config, metricName: metric, filter: filter) + let events = try pipeline.processAll() + return PipelineResult(events: events) +} + +// MARK: - Output + +struct MetricStats: Codable { + let eventCount: Int + let dataPointCount: Int +} + +struct FilterStats: Codable { + let memory: MetricStats + let cpu: MetricStats +} + +struct RunnerOutput: Codable { + let passthrough: FilterStats + let deadband: FilterStats + let window: FilterStats +} + +// MARK: - Main + +let args = parseArgs() + +guard !args.fixturePath.isEmpty, !args.outputDir.isEmpty else { + fputs("Error: --fixture-path and --output-dir are required\n", stderr) + exit(1) +} + +let csvContent: String +do { + csvContent = try String(contentsOfFile: args.fixturePath, encoding: .utf8) +} catch { + fputs("Error reading fixture: \(error)\n", stderr) + exit(1) +} + +// heartbeat interval in nanoseconds (fixture timestamps are in nanoseconds) +let heartbeatNs = args.heartbeat * 1_000_000_000 +let windowNs = args.windowSeconds * 1_000_000_000 + +let filters: [(name: String, filter: SampleFilter)] = [ + ("passthrough", PassThroughFilter()), + ("deadband", DeadbandFilter(threshold: args.threshold, heartbeatInterval: heartbeatNs)), + ("window", WindowAggregateFilter(windowDuration: windowNs, function: args.aggregate)), +] + +var allResults: [(name: String, memory: PipelineResult, cpu: PipelineResult)] = [] + +for entry in filters { + // Re-instantiate per-metric because filters are stateful + let memFilter: SampleFilter + let cpuFilter: SampleFilter + switch entry.name { + case "deadband": + memFilter = DeadbandFilter(threshold: args.threshold, heartbeatInterval: heartbeatNs) + cpuFilter = DeadbandFilter(threshold: args.threshold, heartbeatInterval: heartbeatNs) + case "window": + memFilter = WindowAggregateFilter(windowDuration: windowNs, function: args.aggregate) + cpuFilter = WindowAggregateFilter(windowDuration: windowNs, function: args.aggregate) + default: + memFilter = PassThroughFilter() + cpuFilter = PassThroughFilter() + } + + do { + let memResult = try runPipeline(csvContent: csvContent, metric: .memoryUsage, filter: memFilter) + let cpuResult = try runPipeline(csvContent: csvContent, metric: .cpuUsage, filter: cpuFilter) + allResults.append((name: entry.name, memory: memResult, cpu: cpuResult)) + } catch { + fputs("Error running \(entry.name) pipeline: \(error)\n", stderr) + exit(1) + } +} + +// Write ndjson output files +let fm = FileManager.default +try? fm.createDirectory(atPath: args.outputDir, withIntermediateDirectories: true) + +for entry in allResults { + for (metricName, result) in [("memory", entry.memory), ("cpu", entry.cpu)] { + let filename = "\(entry.name)_\(metricName).ndjson" + let path = (args.outputDir as NSString).appendingPathComponent(filename) + let lines = result.events.compactMap { String(data: $0, encoding: .utf8) } + let content = lines.joined(separator: "\n") + (lines.isEmpty ? "" : "\n") + try content.write(toFile: path, atomically: true, encoding: .utf8) + } +} + +// Build JSON summary output +func statsFor(name: String) -> FilterStats { + let entry = allResults.first { $0.name == name }! + return FilterStats( + memory: MetricStats(eventCount: entry.memory.eventCount, dataPointCount: entry.memory.dataPointCount), + cpu: MetricStats(eventCount: entry.cpu.eventCount, dataPointCount: entry.cpu.dataPointCount) + ) +} + +let output = RunnerOutput( + passthrough: statsFor(name: "passthrough"), + deadband: statsFor(name: "deadband"), + window: statsFor(name: "window") +) + +// Also emit first events for each filter as "first_event_" keys +// We emit everything as a single JSON object to stdout +struct FullOutput: Codable { + let stats: RunnerOutput + let firstEvents: [String: String] +} + +var firstEvents: [String: String] = [:] +for entry in allResults { + if let firstData = entry.memory.events.first, let str = String(data: firstData, encoding: .utf8) { + firstEvents["\(entry.name)_memory"] = str + } + if let firstData = entry.cpu.events.first, let str = String(data: firstData, encoding: .utf8) { + firstEvents["\(entry.name)_cpu"] = str + } +} + +let fullOutput = FullOutput(stats: output, firstEvents: firstEvents) +let encoder = JSONEncoder() +encoder.outputFormatting = [.prettyPrinted, .sortedKeys] +if let jsonData = try? encoder.encode(fullOutput), let jsonStr = String(data: jsonData, encoding: .utf8) { + print(jsonStr) +} else { + fputs("Error encoding output JSON\n", stderr) + exit(1) +} From be54262072632d638a0d217c77643f1a8713fd2e Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 16 Apr 2026 11:56:34 +0200 Subject: [PATCH 023/102] add filter unit tests and realistic 60-second fixture --- .../Filters/DeadbandFilterTests.swift | 94 ++++++++++ .../Filters/FilterComparisonTests.swift | 166 ++++++++++++++++++ .../Filters/PassThroughFilterTests.swift | 36 ++++ .../Filters/WindowAggregateFilterTests.swift | 98 +++++++++++ .../Fixtures/input_realistic_60s.csv | 121 +++++++++++++ 5 files changed, 515 insertions(+) create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/DeadbandFilterTests.swift create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/PassThroughFilterTests.swift create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/WindowAggregateFilterTests.swift create mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/DeadbandFilterTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/DeadbandFilterTests.swift new file mode 100644 index 0000000000..ab06c61da0 --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/DeadbandFilterTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import DatadogTimeseries + +final class DeadbandFilterTests: XCTestCase { + func testAlwaysEmitsFirstSample() { + let filter = DeadbandFilter(threshold: 10.0) + let sample = Sample(timestamp: 1_000_000_000, value: 50.0) + + let result = filter.process(sample) + + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].value, 50.0) + } + + func testSuppressesSampleBelowThreshold() { + let filter = DeadbandFilter(threshold: 10.0) + _ = filter.process(Sample(timestamp: 1_000_000_000, value: 50.0)) + + let result = filter.process(Sample(timestamp: 2_000_000_000, value: 55.0)) + + XCTAssertTrue(result.isEmpty) + } + + func testEmitsAtExactThreshold() { + let filter = DeadbandFilter(threshold: 10.0) + _ = filter.process(Sample(timestamp: 1_000_000_000, value: 50.0)) + + let result = filter.process(Sample(timestamp: 2_000_000_000, value: 60.0)) + + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].value, 60.0) + } + + func testEmitsOnNegativeDelta() { + let filter = DeadbandFilter(threshold: 10.0) + _ = filter.process(Sample(timestamp: 1_000_000_000, value: 50.0)) + + let result = filter.process(Sample(timestamp: 2_000_000_000, value: 35.0)) + + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].value, 35.0) + } + + func testReferencesLastEmittedNotLastSeen() { + let filter = DeadbandFilter(threshold: 10.0) + // Emit at 50 + _ = filter.process(Sample(timestamp: 1_000_000_000, value: 50.0)) + // Suppress 54 (diff from 50 = 4) + let suppress1 = filter.process(Sample(timestamp: 2_000_000_000, value: 54.0)) + // Suppress 58 (diff from 50 = 8, not from 54) + let suppress2 = filter.process(Sample(timestamp: 3_000_000_000, value: 58.0)) + // Emit 61 (diff from 50 = 11) + let emit = filter.process(Sample(timestamp: 4_000_000_000, value: 61.0)) + + XCTAssertTrue(suppress1.isEmpty) + XCTAssertTrue(suppress2.isEmpty) + XCTAssertEqual(emit.count, 1) + XCTAssertEqual(emit[0].value, 61.0) + } + + func testHeartbeatFiresAfterSilenceInterval() { + let heartbeat: Int64 = 5_000_000_000 // 5s + let filter = DeadbandFilter(threshold: 10.0, heartbeatInterval: heartbeat) + // Emit first sample at t=0 + _ = filter.process(Sample(timestamp: 0, value: 50.0)) + // Suppress at t=3s (value barely moved, not past heartbeat) + let suppress = filter.process(Sample(timestamp: 3_000_000_000, value: 51.0)) + // Emit at t=6s (heartbeat due, even though value barely moved) + let emit = filter.process(Sample(timestamp: 6_000_000_000, value: 52.0)) + + XCTAssertTrue(suppress.isEmpty) + XCTAssertEqual(emit.count, 1) + XCTAssertEqual(emit[0].value, 52.0) + } + + func testNoHeartbeatWithoutIntervalConfigured() { + let filter = DeadbandFilter(threshold: 10.0) + _ = filter.process(Sample(timestamp: 0, value: 50.0)) + + // 60s later, value barely moved — no heartbeat configured + let result = filter.process(Sample(timestamp: 60_000_000_000, value: 51.0)) + + XCTAssertTrue(result.isEmpty) + } + + func testFlushReturnsNothing() { + let filter = DeadbandFilter(threshold: 10.0) + _ = filter.process(Sample(timestamp: 1_000_000_000, value: 50.0)) + + let result = filter.flush() + + XCTAssertTrue(result.isEmpty) + } +} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift new file mode 100644 index 0000000000..af6155547e --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift @@ -0,0 +1,166 @@ +import XCTest +@testable import DatadogTimeseries + +final class FilterComparisonTests: XCTestCase { + private let config = TimeseriesConfig( + applicationId: "test-app", + sessionId: "test-session", + sessionType: "user", + source: "ios", + service: nil, + version: nil + ) + + // MARK: - PassThroughFilter + + func testPassThroughEmitsAllSamples() throws { + let count = try totalDataPoints(filter: PassThroughFilter(), metric: .memoryUsage) + XCTAssertEqual(count, 60) + } + + func testPassThroughCPUEmitsAllSamples() throws { + let count = try totalDataPoints(filter: PassThroughFilter(), metric: .cpuUsage) + XCTAssertEqual(count, 60) + } + + // MARK: - DeadbandFilter + + func testDeadbandReducesMemorySamples() throws { + let count = try totalDataPoints(filter: DeadbandFilter(threshold: 1_000_000), metric: .memoryUsage) + XCTAssertLessThan(count, 60) + XCTAssertGreaterThanOrEqual(count, 1) + } + + func testDeadbandCapturesAllocationJumps() throws { + // First sample always emitted + allocation jumps of 1-2 MB each cross the 1 MB threshold + let count = try totalDataPoints(filter: DeadbandFilter(threshold: 1_000_000), metric: .memoryUsage) + XCTAssertGreaterThanOrEqual(count, 3) + } + + // MARK: - WindowAggregateFilter + + func testWindowAggregateReducesCPUSamples() throws { + // 60 samples at 1s intervals / 5s window = 12 windows + let count = try totalDataPoints( + filter: WindowAggregateFilter(windowDuration: 5_000_000_000, function: .max), + metric: .cpuUsage + ) + XCTAssertEqual(count, 12) + } + + func testWindowAggregateTimestampIsWindowStart() throws { + let csvURL = try XCTUnwrap( + Bundle.module.url(forResource: "input_realistic_60s", withExtension: "csv", subdirectory: "Fixtures") + ) + let csvContent = try String(contentsOf: csvURL) + + let filter = WindowAggregateFilter(windowDuration: 5_000_000_000, function: .max) + let provider = CSVDataProvider(csvContent: csvContent, metric: .cpuUsage) + let pipeline = TimeseriesPipeline( + provider: provider, + config: config, + metricName: .cpuUsage, + batchSize: 100, + filter: filter + ) + + let results = try pipeline.processAll() + let firstBatch = try XCTUnwrap(results.first) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: firstBatch) as? [String: Any]) + let timeseries = try XCTUnwrap(json["timeseries"] as? [String: Any]) + let data = try XCTUnwrap(timeseries["data"] as? [[String: Any]]) + let firstPoint = try XCTUnwrap(data.first) + let firstTimestamp = try XCTUnwrap(firstPoint["timestamp"] as? Int64) + + // First window starts at the first sample timestamp (nanoseconds) + XCTAssertEqual(firstTimestamp, 1_700_000_001_000_000_000) + } + + // MARK: - JSON validity across all filters + + func testAllFiltersProduceValidJSON() throws { + let filters: [SampleFilter] = [ + PassThroughFilter(), + DeadbandFilter(threshold: 1_000_000), + WindowAggregateFilter(windowDuration: 5_000_000_000, function: .max), + ] + + for filter in filters { + let csvURL = try XCTUnwrap( + Bundle.module.url(forResource: "input_realistic_60s", withExtension: "csv", subdirectory: "Fixtures") + ) + let csvContent = try String(contentsOf: csvURL) + let provider = CSVDataProvider(csvContent: csvContent, metric: .memoryUsage) + let pipeline = TimeseriesPipeline( + provider: provider, + config: config, + metricName: .memoryUsage, + batchSize: 100, + filter: filter + ) + + let results = try pipeline.processAll() + + for data in results { + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(json["type"] as? String, "timeseries") + XCTAssertNotNil(json["_dd"]) + let timeseries = try XCTUnwrap(json["timeseries"] as? [String: Any]) + let points = try XCTUnwrap(timeseries["data"] as? [[String: Any]]) + XCTAssertFalse(points.isEmpty) + } + } + } + + // MARK: - Regression guard + + func testPipelineDefaultIsPassThrough() throws { + // Creates pipeline WITHOUT passing filter arg — verifies default PassThroughFilter behaviour + let csvURL = try XCTUnwrap( + Bundle.module.url(forResource: "input_realistic_60s", withExtension: "csv", subdirectory: "Fixtures") + ) + let csvContent = try String(contentsOf: csvURL) + let provider = CSVDataProvider(csvContent: csvContent, metric: .memoryUsage) + let pipeline = TimeseriesPipeline( + provider: provider, + config: config, + metricName: .memoryUsage, + batchSize: 100 + ) + + let results = try pipeline.processAll() + let count = try dataPointCount(in: results) + XCTAssertEqual(count, 60) + } + + // MARK: - Helpers + + private func totalDataPoints(filter: SampleFilter, metric: TimeseriesName) throws -> Int { + let csvURL = try XCTUnwrap( + Bundle.module.url(forResource: "input_realistic_60s", withExtension: "csv", subdirectory: "Fixtures") + ) + let csvContent = try String(contentsOf: csvURL) + let provider = CSVDataProvider(csvContent: csvContent, metric: metric) + let pipeline = TimeseriesPipeline( + provider: provider, + config: config, + metricName: metric, + batchSize: 100, + filter: filter + ) + + let results = try pipeline.processAll() + return try dataPointCount(in: results) + } + + private func dataPointCount(in results: [Data]) throws -> Int { + var total = 0 + for data in results { + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let timeseries = try XCTUnwrap(json["timeseries"] as? [String: Any]) + let points = try XCTUnwrap(timeseries["data"] as? [[String: Any]]) + total += points.count + } + return total + } +} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/PassThroughFilterTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/PassThroughFilterTests.swift new file mode 100644 index 0000000000..641b64f215 --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/PassThroughFilterTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import DatadogTimeseries + +final class PassThroughFilterTests: XCTestCase { + func testPassesEverySampleThrough() { + let filter = PassThroughFilter() + let sample = Sample(timestamp: 1_000_000_000, value: 42.0) + + let result = filter.process(sample) + + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].timestamp, sample.timestamp) + XCTAssertEqual(result[0].value, sample.value) + } + + func testPassesAllConsecutiveSamples() { + let filter = PassThroughFilter() + let samples = [ + Sample(timestamp: 1_000_000_000, value: 10.0), + Sample(timestamp: 2_000_000_000, value: 20.0), + Sample(timestamp: 3_000_000_000, value: 30.0), + ] + + let result = samples.flatMap { filter.process($0) } + + XCTAssertEqual(result.count, 3) + } + + func testFlushReturnsEmptyArray() { + let filter = PassThroughFilter() + + let result = filter.flush() + + XCTAssertTrue(result.isEmpty) + } +} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/WindowAggregateFilterTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/WindowAggregateFilterTests.swift new file mode 100644 index 0000000000..1398bd0f65 --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/WindowAggregateFilterTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import DatadogTimeseries + +final class WindowAggregateFilterTests: XCTestCase { + private let windowDuration: Int64 = 3_000_000_000 // 3s + + func testDoesNotEmitUntilWindowCloses() { + let filter = WindowAggregateFilter(windowDuration: windowDuration) + let result1 = filter.process(Sample(timestamp: 0, value: 10.0)) + let result2 = filter.process(Sample(timestamp: 1_000_000_000, value: 20.0)) + + XCTAssertTrue(result1.isEmpty) + XCTAssertTrue(result2.isEmpty) + } + + func testEmitsWhenWindowCloses() { + let filter = WindowAggregateFilter(windowDuration: windowDuration) + _ = filter.process(Sample(timestamp: 0, value: 10.0)) + _ = filter.process(Sample(timestamp: 1_000_000_000, value: 20.0)) + // 3rd sample at 4s crosses the 3s boundary + let result = filter.process(Sample(timestamp: 4_000_000_000, value: 30.0)) + + XCTAssertEqual(result.count, 1) + } + + func testEmittedTimestampIsStartOfWindow() { + let filter = WindowAggregateFilter(windowDuration: windowDuration) + let firstTimestamp: Int64 = 1_000_000_000 + _ = filter.process(Sample(timestamp: firstTimestamp, value: 10.0)) + _ = filter.process(Sample(timestamp: 2_000_000_000, value: 20.0)) + let result = filter.process(Sample(timestamp: 5_000_000_000, value: 30.0)) + + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].timestamp, firstTimestamp) + } + + func testFlushEmitsPartialWindow() { + let filter = WindowAggregateFilter(windowDuration: windowDuration) + _ = filter.process(Sample(timestamp: 0, value: 10.0)) + _ = filter.process(Sample(timestamp: 1_000_000_000, value: 20.0)) + + let result = filter.flush() + + XCTAssertEqual(result.count, 1) + } + + func testFlushOnEmptyBufferReturnsNothing() { + let filter = WindowAggregateFilter(windowDuration: windowDuration) + + let result = filter.flush() + + XCTAssertTrue(result.isEmpty) + } + + func testMultipleWindowsEachEmitOnce() { + let filter = WindowAggregateFilter(windowDuration: windowDuration) + _ = filter.process(Sample(timestamp: 0, value: 10.0)) + // First window closes + let result1 = filter.process(Sample(timestamp: 3_000_000_000, value: 20.0)) + // Second window closes + let result2 = filter.process(Sample(timestamp: 6_000_000_000, value: 30.0)) + + XCTAssertEqual(result1.count, 1) + XCTAssertEqual(result2.count, 1) + } + + func testAvgAggregate() { + let filter = WindowAggregateFilter(windowDuration: windowDuration, function: .avg) + _ = filter.process(Sample(timestamp: 0, value: 10.0)) + _ = filter.process(Sample(timestamp: 1_000_000_000, value: 30.0)) + let result = filter.process(Sample(timestamp: 4_000_000_000, value: 0.0)) + + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].value, 20.0, accuracy: 0.001) + } + + func testMinAggregate() { + let filter = WindowAggregateFilter(windowDuration: windowDuration, function: .min) + _ = filter.process(Sample(timestamp: 0, value: 50.0)) + _ = filter.process(Sample(timestamp: 1_000_000_000, value: 20.0)) + _ = filter.process(Sample(timestamp: 2_000_000_000, value: 80.0)) + let result = filter.process(Sample(timestamp: 4_000_000_000, value: 0.0)) + + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].value, 20.0, accuracy: 0.001) + } + + func testMaxAggregate() { + let filter = WindowAggregateFilter(windowDuration: windowDuration, function: .max) + _ = filter.process(Sample(timestamp: 0, value: 50.0)) + _ = filter.process(Sample(timestamp: 1_000_000_000, value: 20.0)) + _ = filter.process(Sample(timestamp: 2_000_000_000, value: 80.0)) + let result = filter.process(Sample(timestamp: 4_000_000_000, value: 0.0)) + + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].value, 80.0, accuracy: 0.001) + } +} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv new file mode 100644 index 0000000000..bdfed3994b --- /dev/null +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv @@ -0,0 +1,121 @@ +timestamp,metric,value +1700000001000000000,memory_usage,31000500 +1700000001000000000,cpu_usage,5.0 +1700000002000000000,memory_usage,31001300 +1700000002000000000,cpu_usage,7.5 +1700000003000000000,memory_usage,31002500 +1700000003000000000,cpu_usage,6.0 +1700000004000000000,memory_usage,31004000 +1700000004000000000,cpu_usage,8.5 +1700000005000000000,memory_usage,31004700 +1700000005000000000,cpu_usage,5.5 +1700000006000000000,memory_usage,31005700 +1700000006000000000,cpu_usage,9.0 +1700000007000000000,memory_usage,31007700 +1700000007000000000,cpu_usage,5.0 +1700000008000000000,memory_usage,31008300 +1700000008000000000,cpu_usage,7.5 +1700000009000000000,memory_usage,31008800 +1700000009000000000,cpu_usage,6.0 +1700000010000000000,memory_usage,31009600 +1700000010000000000,cpu_usage,8.5 +1700000011000000000,memory_usage,31010800 +1700000011000000000,cpu_usage,5.5 +1700000012000000000,memory_usage,32512300 +1700000012000000000,cpu_usage,9.0 +1700000013000000000,memory_usage,32513000 +1700000013000000000,cpu_usage,5.0 +1700000014000000000,memory_usage,32514000 +1700000014000000000,cpu_usage,7.5 +1700000015000000000,memory_usage,32516000 +1700000015000000000,cpu_usage,65.0 +1700000016000000000,memory_usage,32516600 +1700000016000000000,cpu_usage,72.0 +1700000017000000000,memory_usage,32517100 +1700000017000000000,cpu_usage,58.0 +1700000018000000000,memory_usage,32517900 +1700000018000000000,cpu_usage,6.0 +1700000019000000000,memory_usage,32519100 +1700000019000000000,cpu_usage,8.5 +1700000020000000000,memory_usage,32520600 +1700000020000000000,cpu_usage,5.5 +1700000021000000000,memory_usage,32521300 +1700000021000000000,cpu_usage,9.0 +1700000022000000000,memory_usage,32522300 +1700000022000000000,cpu_usage,5.0 +1700000023000000000,memory_usage,32524300 +1700000023000000000,cpu_usage,7.5 +1700000024000000000,memory_usage,32524900 +1700000024000000000,cpu_usage,6.0 +1700000025000000000,memory_usage,32525400 +1700000025000000000,cpu_usage,8.5 +1700000026000000000,memory_usage,32526200 +1700000026000000000,cpu_usage,5.5 +1700000027000000000,memory_usage,32527400 +1700000027000000000,cpu_usage,9.0 +1700000028000000000,memory_usage,32528900 +1700000028000000000,cpu_usage,5.0 +1700000029000000000,memory_usage,32529600 +1700000029000000000,cpu_usage,7.5 +1700000030000000000,memory_usage,34530600 +1700000030000000000,cpu_usage,6.0 +1700000031000000000,memory_usage,34532600 +1700000031000000000,cpu_usage,8.5 +1700000032000000000,memory_usage,34533200 +1700000032000000000,cpu_usage,5.5 +1700000033000000000,memory_usage,34533700 +1700000033000000000,cpu_usage,9.0 +1700000034000000000,memory_usage,34534500 +1700000034000000000,cpu_usage,5.0 +1700000035000000000,memory_usage,34535700 +1700000035000000000,cpu_usage,80.0 +1700000036000000000,memory_usage,34537200 +1700000036000000000,cpu_usage,75.0 +1700000037000000000,memory_usage,34537900 +1700000037000000000,cpu_usage,68.0 +1700000038000000000,memory_usage,34538900 +1700000038000000000,cpu_usage,7.5 +1700000039000000000,memory_usage,34540900 +1700000039000000000,cpu_usage,6.0 +1700000040000000000,memory_usage,33741500 +1700000040000000000,cpu_usage,8.5 +1700000041000000000,memory_usage,33742000 +1700000041000000000,cpu_usage,5.5 +1700000042000000000,memory_usage,33742800 +1700000042000000000,cpu_usage,9.0 +1700000043000000000,memory_usage,33744000 +1700000043000000000,cpu_usage,5.0 +1700000044000000000,memory_usage,33745500 +1700000044000000000,cpu_usage,7.5 +1700000045000000000,memory_usage,33746200 +1700000045000000000,cpu_usage,6.0 +1700000046000000000,memory_usage,33747200 +1700000046000000000,cpu_usage,8.5 +1700000047000000000,memory_usage,33749200 +1700000047000000000,cpu_usage,5.5 +1700000048000000000,memory_usage,33749800 +1700000048000000000,cpu_usage,9.0 +1700000049000000000,memory_usage,33750300 +1700000049000000000,cpu_usage,5.0 +1700000050000000000,memory_usage,34751100 +1700000050000000000,cpu_usage,7.5 +1700000051000000000,memory_usage,34752300 +1700000051000000000,cpu_usage,6.0 +1700000052000000000,memory_usage,34753800 +1700000052000000000,cpu_usage,8.5 +1700000053000000000,memory_usage,34754500 +1700000053000000000,cpu_usage,5.5 +1700000054000000000,memory_usage,34755500 +1700000054000000000,cpu_usage,9.0 +1700000055000000000,memory_usage,34757500 +1700000055000000000,cpu_usage,55.0 +1700000056000000000,memory_usage,34758100 +1700000056000000000,cpu_usage,62.0 +1700000057000000000,memory_usage,34758600 +1700000057000000000,cpu_usage,50.0 +1700000058000000000,memory_usage,34759400 +1700000058000000000,cpu_usage,5.0 +1700000059000000000,memory_usage,34760600 +1700000059000000000,cpu_usage,7.5 +1700000060000000000,memory_usage,34762100 +1700000060000000000,cpu_usage,6.0 From 3ad2bf7a2f175e23d318d320b5fe982e2bf505e4 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 16 Apr 2026 11:56:38 +0200 Subject: [PATCH 024/102] add fixture generator and pipeline runner scripts --- DatadogTimeseries/Scripts/generate-fixture.py | 116 ++++++++++++ DatadogTimeseries/Scripts/run-pipeline.sh | 165 ++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 DatadogTimeseries/Scripts/generate-fixture.py create mode 100755 DatadogTimeseries/Scripts/run-pipeline.sh diff --git a/DatadogTimeseries/Scripts/generate-fixture.py b/DatadogTimeseries/Scripts/generate-fixture.py new file mode 100644 index 0000000000..0c00c7f0ef --- /dev/null +++ b/DatadogTimeseries/Scripts/generate-fixture.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +""" +Generates a realistic 60-sample CSV fixture for DatadogTimeseries tests. + +Output: Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv + +CSV format: timestamp,metric,value +- 60 seconds of data (t=1 to t=60), interleaved memory then cpu per second +- 120 data rows total + 1 header row + +Memory shape: +- Base: 31_000_000 bytes (~31MB) +- Slow drift: deterministic +500..+2000 bytes/s (cycle of 8 fixed offsets) +- Allocation jumps at t=12 (+1_500_000), t=30 (+2_000_000), t=50 (+1_000_000) +- Deallocation at t=40 (-800_000) + +CPU shape: +- Baseline: cycling through [5.0, 7.5, 6.0, 8.5, 5.5, 9.0] +- Burst at t=15..17: [65.0, 72.0, 58.0] +- Burst at t=35..37: [80.0, 75.0, 68.0] +- Burst at t=55..57: [55.0, 62.0, 50.0] +""" + +import os + +BASE_TIMESTAMP = 1700000001000000000 +NS_PER_SECOND = 1_000_000_000 + +OUTPUT_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "Tests", "DatadogTimeseriesTests", "Fixtures", "input_realistic_60s.csv" +) + +# Deterministic per-second drift values (cycle of 8, repeating) +DRIFT_CYCLE = [500, 800, 1200, 1500, 700, 1000, 2000, 600] + +# Allocation events: {second -> delta} +ALLOC_EVENTS = { + 12: +1_500_000, + 30: +2_000_000, + 40: -800_000, + 50: +1_000_000, +} + +# CPU baseline cycling values +CPU_BASELINE = [5.0, 7.5, 6.0, 8.5, 5.5, 9.0] + +# CPU burst overrides: {second -> value} +CPU_BURSTS = { + 15: 65.0, + 16: 72.0, + 17: 58.0, + 35: 80.0, + 36: 75.0, + 37: 68.0, + 55: 55.0, + 56: 62.0, + 57: 50.0, +} + + +def generate_rows(): + rows = [] + memory = 31_000_000 + baseline_index = 0 + + for i, second in enumerate(range(1, 61)): + timestamp = BASE_TIMESTAMP + (second - 1) * NS_PER_SECOND + + # --- Memory --- + drift = DRIFT_CYCLE[i % len(DRIFT_CYCLE)] + memory += drift + if second in ALLOC_EVENTS: + memory += ALLOC_EVENTS[second] + rows.append((timestamp, "memory_usage", memory)) + + # --- CPU --- + if second in CPU_BURSTS: + cpu = CPU_BURSTS[second] + else: + cpu = CPU_BASELINE[baseline_index % len(CPU_BASELINE)] + baseline_index += 1 + + # Format cpu: no trailing zeros for whole numbers, keep one decimal otherwise + if cpu == int(cpu): + cpu_str = f"{int(cpu)}.0" + else: + cpu_str = str(cpu) + + rows.append((timestamp, "cpu_usage", cpu_str)) + + return rows + + +def main(): + rows = generate_rows() + + os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) + + with open(OUTPUT_PATH, "w") as f: + f.write("timestamp,metric,value\n") + for timestamp, metric, value in rows: + f.write(f"{timestamp},{metric},{value}\n") + + data_rows = len(rows) + print(f"Generated: {OUTPUT_PATH}") + print(f"Data rows: {data_rows} (expected 120)") + + # Quick sanity check on first few rows + print("\nFirst 6 rows:") + for row in rows[:6]: + print(f" {row[0]},{row[1]},{row[2]}") + + +if __name__ == "__main__": + main() diff --git a/DatadogTimeseries/Scripts/run-pipeline.sh b/DatadogTimeseries/Scripts/run-pipeline.sh new file mode 100755 index 0000000000..c884672e53 --- /dev/null +++ b/DatadogTimeseries/Scripts/run-pipeline.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# run-pipeline.sh — Run the DatadogTimeseries filter pipeline against the 60s fixture, +# print a comparison table, and write per-filter NDJSON output files. +# +# Usage: +# ./scripts/run-pipeline.sh [options] +# +# Options: +# --filter passthrough|deadband|window Filter to pretty-print the first event from (default: passthrough) +# --threshold Deadband threshold in bytes (default: 1000000) +# --heartbeat Deadband heartbeat interval in seconds (default: 30) +# --window Window aggregate duration in seconds (default: 5) +# --aggregate max|avg|min|last Window aggregate function (default: max) + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +FILTER="passthrough" +THRESHOLD=1000000 +HEARTBEAT=30 +WINDOW=5 +AGGREGATE="max" + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --filter) FILTER="$2"; shift 2 ;; + --threshold) THRESHOLD="$2"; shift 2 ;; + --heartbeat) HEARTBEAT="$2"; shift 2 ;; + --window) WINDOW="$2"; shift 2 ;; + --aggregate) AGGREGATE="$2"; shift 2 ;; + *) echo "Unknown argument: $1" >&2; exit 1 ;; + esac +done + +# --------------------------------------------------------------------------- +# Resolve package root (the directory containing Package.swift) +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PKG_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +FIXTURE_PATH="$PKG_ROOT/Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv" +OUTPUT_DIR="$PKG_ROOT/output" + +# --------------------------------------------------------------------------- +# Clear output directory +# --------------------------------------------------------------------------- +echo "Clearing output/ directory..." +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +# --------------------------------------------------------------------------- +# Run tests +# --------------------------------------------------------------------------- +echo "" +echo "Running swift test..." +cd "$PKG_ROOT" +if ! swift test 2>&1; then + echo "" >&2 + echo "ERROR: swift test failed. Aborting." >&2 + exit 1 +fi +echo "All tests passed." + +# --------------------------------------------------------------------------- +# Run runner and capture JSON output to a temp file +# --------------------------------------------------------------------------- +echo "" +echo "Running pipeline across all filters..." +RUNNER_TMP="$(mktemp /tmp/ts-runner-output.XXXXXX.json)" +trap 'rm -f "$RUNNER_TMP"' EXIT + +swift run DatadogTimeseriesRunner -- \ + --fixture-path "$FIXTURE_PATH" \ + --output-dir "$OUTPUT_DIR" \ + --threshold "$THRESHOLD" \ + --heartbeat "$HEARTBEAT" \ + --window "$WINDOW" \ + --aggregate "$AGGREGATE" > "$RUNNER_TMP" + +# --------------------------------------------------------------------------- +# Parse stats and print comparison table +# --------------------------------------------------------------------------- +python3 - "$RUNNER_TMP" <<'PYEOF' +import json, sys + +with open(sys.argv[1]) as f: + data = json.load(f) + +stats = data["stats"] +filters = ["passthrough", "deadband", "window"] +labels = {"passthrough": "PassThrough", "deadband": "Deadband", "window": "WindowAggregate"} + +pt_dp_mem = stats["passthrough"]["memory"]["dataPointCount"] +pt_dp_cpu = stats["passthrough"]["cpu"]["dataPointCount"] +pt_total = pt_dp_mem + pt_dp_cpu + +rows = [] +for f in filters: + ev_mem = stats[f]["memory"]["eventCount"] + dp_mem = stats[f]["memory"]["dataPointCount"] + ev_cpu = stats[f]["cpu"]["eventCount"] + dp_cpu = stats[f]["cpu"]["dataPointCount"] + total_dp = dp_mem + dp_cpu + reduction = round((1.0 - total_dp / pt_total) * 100, 1) if pt_total > 0 else 0.0 + rows.append((labels[f], ev_mem, dp_mem, ev_cpu, dp_cpu, reduction)) + +col_w = [20, 13, 13, 13, 13, 13] +header = ( + f"{'Filter':<{col_w[0]}}" + f"{'Events(mem)':>{col_w[1]}}" + f"{'Points(mem)':>{col_w[2]}}" + f"{'Events(cpu)':>{col_w[3]}}" + f"{'Points(cpu)':>{col_w[4]}}" + f"{'Reduction %':>{col_w[5]}}" +) +sep = "-" * sum(col_w) + +print("") +print("Filter comparison (fixture: input_realistic_60s.csv)") +print(sep) +print(header) +print(sep) +for (label, em, dm, ec, dc, red) in rows: + print( + f"{label:<{col_w[0]}}" + f"{em:>{col_w[1]}}" + f"{dm:>{col_w[2]}}" + f"{ec:>{col_w[3]}}" + f"{dc:>{col_w[4]}}" + f"{str(red) + '%':>{col_w[5]}}" + ) +print(sep) +PYEOF + +# --------------------------------------------------------------------------- +# List written output files +# --------------------------------------------------------------------------- +echo "" +echo "Output files written to output/:" +ls -1 "$OUTPUT_DIR" + +# --------------------------------------------------------------------------- +# Pretty-print first event from selected filter (memory metric) +# --------------------------------------------------------------------------- +python3 - "$RUNNER_TMP" "$FILTER" <<'PYEOF' +import json, sys + +with open(sys.argv[1]) as f: + data = json.load(f) + +selected_filter = sys.argv[2] +key = f"{selected_filter}_memory" +raw = data.get("firstEvents", {}).get(key, "") +print(f"\nFirst event from filter '{selected_filter}' (memory_usage):") +if raw: + parsed = json.loads(raw) + print(json.dumps(parsed, indent=2, sort_keys=True)) +else: + print("(no event)") +PYEOF From 18038f7cc6e6f8548d5fb774f3a91640cdd7bdda Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 16 Apr 2026 11:56:43 +0200 Subject: [PATCH 025/102] add plan 3 for sampling filters research --- pod/plans/3-plan-sampling-filters.md | 256 +++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 pod/plans/3-plan-sampling-filters.md diff --git a/pod/plans/3-plan-sampling-filters.md b/pod/plans/3-plan-sampling-filters.md new file mode 100644 index 0000000000..6d72145bd6 --- /dev/null +++ b/pod/plans/3-plan-sampling-filters.md @@ -0,0 +1,256 @@ +# Plan 3 — Sampling Filters for DatadogTimeseries Standalone Package + +**Date:** 2026-04-15 +**Epic:** RUM-13949 +**Phase:** Post-MVP research — compare sampling strategies before Plan 2 integration + +--- + +## Goal + +Add pluggable `SampleFilter` protocol to the standalone `DatadogTimeseries` Swift package with 2 concrete implementations (PassThrough, Deadband, WindowAggregate). Wire it into `TimeseriesPipeline`, generate a realistic 60-sample fixture, and provide a runner script that compares all strategies side by side. + +This is a **research tool**, not a production feature. The filters and fixture generated here inform which strategy carries into Plan 2 (SDK integration). The JSON output from each filter feeds into the real backend pipeline. + +--- + +## Architecture + +The filter slots between `DataProvider` and `TimeseriesBatcher` inside `TimeseriesPipeline.processAll()`: + +``` +DataProvider → [SampleFilter] → TimeseriesBatcher → EventBuilder → Encoder +``` + +Protocol shape (class-only for clean mutable state): + +```swift +protocol SampleFilter: AnyObject { + func process(_ sample: Sample) -> [Sample] // 0 = suppress, 1+ = forward + func flush() -> [Sample] // emit buffered state at end of stream +} +``` + +Default filter is `PassThroughFilter()` — existing tests are unaffected. + +--- + +## Decisions Log + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| SampleFilter type | Class-only protocol (AnyObject) | Filters are stateful. Reference semantics avoid inout/wrapper complexity. | +| TransitionFilter | **Dropped from this plan** | Not meaningful for continuous metrics (memory, CPU). Build later for thermal/battery state. | +| Fixture size | 60 samples (1 min at 1 Hz) | Small enough to inspect, large enough to show meaningful filter differences. | +| Fixture shape | Realistic: slow memory growth + CPU spikes | Matches real-world patterns. 3 allocation jumps, 3 CPU bursts. | +| Fixture generator | Python/shell script | Simpler than standalone Swift script (can't import the package). | +| Filter scope | Any filter on any metric | One --filter arg applies to both memory and CPU. Flexible for research. | +| Deadband threshold | Configurable via --threshold | Lets you experiment without code changes. | +| Deadband heartbeat | Configurable via --heartbeat | Important so backend can distinguish "stable value" from "collection stopped". | +| Window duration | Configurable via --window (seconds) | Try 5s vs 10s vs 30s without code changes. | +| Aggregate function | Configurable via --aggregate max\|avg\|min\|last | All 4 are useful; max for spike detection, avg for baseline. | +| Table aggregate | Uses --aggregate arg for window row | Comparison table reflects the active configuration. | +| JSON output | All 3 filters written every run | Script runs all 3 internally; no reason to discard any result. | +| Output cleanup | Clear output/ at start of each run | Prevents accidentally curling a stale file from a previous run. | +| Pipeline default | PassThroughFilter() | Existing E2E fixture tests are unchanged. New filter tests are additive. | +| Script location | DatadogTimeseries/scripts/ | Self-contained next to the code. Goes away naturally with the standalone package. | + +--- + +## Tasks + +### Task 1 — `SampleFilter` protocol +**File:** `Sources/DatadogTimeseries/Filters/SampleFilter.swift` + +Protocol definition. Class-only (`AnyObject`). Two methods: `process` and `flush`. Include inline doc explaining when each is called. + +--- + +### Task 2 — `PassThroughFilter` +**File:** `Sources/DatadogTimeseries/Filters/PassThroughFilter.swift` + +Returns `[sample]` always, `flush()` returns `[]`. Makes the current implicit pipeline behaviour explicit. Zero logic. + +--- + +### Task 3 — `DeadbandFilter` +**File:** `Sources/DatadogTimeseries/Filters/DeadbandFilter.swift` + +```swift +final class DeadbandFilter: SampleFilter { + init(threshold: Double, heartbeatInterval: Int64? = nil) +} +``` + +State: `lastEmittedValue: Double?`, `lastEmittedTimestamp: Int64?` + +Logic: +- Always emit the first sample +- Emit if `abs(current.value - lastEmitted.value) >= threshold` +- Emit if `heartbeatInterval != nil && (current.timestamp - lastEmitted.timestamp) >= heartbeatInterval` +- `flush()` returns `[]` (no internal buffer) + +--- + +### Task 4 — `WindowAggregateFilter` +**File:** `Sources/DatadogTimeseries/Filters/WindowAggregateFilter.swift` + +```swift +enum AggregateFunction { case avg, min, max, last } + +final class WindowAggregateFilter: SampleFilter { + init(windowDuration: Int64, function: AggregateFunction = .max) +} +``` + +State: `windowStart: Int64?`, `buffer: [Sample]` + +Logic: +- Accumulate samples in buffer +- When `sample.timestamp - windowStart >= windowDuration`: flush window, emit one aggregate sample, start new window +- `flush()`: emit aggregate of remaining buffer (partial window at end of stream) +- Emitted sample timestamp = `windowStart` +- Aggregate: avg = mean, min = minimum, max = maximum, last = last sample's value + +--- + +### Task 5 — Update `TimeseriesPipeline` +**File:** `Sources/DatadogTimeseries/TimeseriesPipeline.swift` + +```swift +init( + provider: DataProvider, + config: TimeseriesConfig, + metricName: TimeseriesName, + batchSize: Int = 30, + filter: SampleFilter = PassThroughFilter() +) +``` + +Update `processAll()`: +``` +while let raw = provider.read() { + let filtered = filter.process(raw) + for sample in filtered { batcher.add + maybe flush } +} +// After provider exhausted: +for sample in filter.flush() { batcher.add + maybe flush } +// Then existing batcher.flushRemaining() tail +``` + +--- + +### Task 6 — Realistic 60-sample fixture generator +**File:** `DatadogTimeseries/scripts/generate-fixture.py` + +Generates `Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv`. + +Memory shape (60 rows): +- Base: 31_000_000 bytes (~31MB) +- Slow drift: +1_000–3_000 bytes/s (noise) +- 3 allocation jumps: +1_500_000 bytes at ~t=12s, +2_000_000 at ~t=30s, +1_000_000 at ~t=50s +- One deallocation: -500_000 at ~t=40s + +CPU shape (60 rows): +- Baseline: 5–15% (random noise in range) +- 3 bursts: 50–80% for 3–5 seconds at ~t=15s, ~t=35s, ~t=55s + +CSV format: same as existing fixture (`timestamp,metric,value`), timestamps at 1s intervals starting from `1700000001000000000`. + +--- + +### Task 7 — Unit tests per filter +**Files:** +- `Tests/DatadogTimeseriesTests/Filters/PassThroughFilterTests.swift` +- `Tests/DatadogTimeseriesTests/Filters/DeadbandFilterTests.swift` +- `Tests/DatadogTimeseriesTests/Filters/WindowAggregateFilterTests.swift` + +**PassThroughFilterTests:** passes all samples, flush returns empty. + +**DeadbandFilterTests:** +- Always emits first sample +- Suppresses sample below threshold +- Emits at exact threshold +- Emits on negative delta +- References last *emitted* value, not last *seen* +- Heartbeat fires after silence interval +- No heartbeat without interval configured +- Flush returns nothing + +**WindowAggregateFilterTests:** +- Does not emit until window closes +- Emits when window closes (timestamp = window start) +- flush() emits partial window +- flush() on empty buffer returns nothing +- Multiple windows each emit once +- Each aggregate function (avg, min, max, last) produces correct value +- Realistic CPU scenario: 10 samples, 5s window, max → 2 aggregate events with correct max values + +--- + +### Task 8 — `FilterComparisonTests` +**File:** `Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift` + +Uses `input_realistic_60s.csv` (60 samples). + +Tests: +- PassThrough on memory/CPU: 60 data points each +- Deadband (threshold=1_000_000) on memory: fewer than 60 data points, includes first sample +- Deadband (threshold=1_000_000) on CPU: tests that spikes cross threshold +- Window (5s, max) on CPU: exactly 12 data points (60s / 5s) +- Window (5s, max) on CPU: max values correctly capture burst peaks +- All filters produce valid JSON (type="timeseries", required fields present) +- Pipeline default (no filter arg) = PassThrough behaviour (regression guard) + +--- + +### Task 9 — Runner script +**File:** `DatadogTimeseries/scripts/run-pipeline.sh` + +```bash +./scripts/run-pipeline.sh [--filter passthrough|deadband|window] + [--threshold ] # deadband threshold (default: 1000000) + [--heartbeat ] # deadband heartbeat (default: 30) + [--window ] # window duration (default: 5) + [--aggregate max|avg|min|last] # window function (default: max) +``` + +**Behaviour:** + +1. Clear `output/` directory +2. Run `swift test` — exit on failure +3. Run all 3 filters against `input_realistic_60s.csv` for both memory and CPU using the provided params (deadband uses --threshold/--heartbeat, window uses --window/--aggregate) +4. Print comparison table: + +``` +┌─────────────────┬───────────────────────────────┬───────────────────────────────┐ +│ Filter │ Memory │ CPU │ +│ │ events │ data pts │ reduction │ events │ data pts │ reduction │ +├─────────────────┼────────┼──────────┼────────────┼────────┼──────────┼────────────┤ +│ passthrough │ 6 │ 60 │ -- │ 6 │ 60 │ -- │ +│ deadband │ 2 │ 18 │ 70% │ 4 │ 32 │ 47% │ +│ window (max,5s) │ 2 │ 12 │ 80% │ 2 │ 12 │ 80% │ +└─────────────────┴────────┴──────────┴────────────┴────────┴──────────┴────────────┘ +``` + +5. Write all 3 filters' output to `output/`: + - `output/passthrough_memory.ndjson`, `output/passthrough_cpu.ndjson` + - `output/deadband_memory.ndjson`, `output/deadband_cpu.ndjson` + - `output/window_memory.ndjson`, `output/window_cpu.ndjson` +6. Pretty-print first event from the `--filter` selection (default: all 3) + +**Script is self-contained** — uses `swift` CLI to run a small Swift driver program that imports `DatadogTimeseries` and runs the pipeline, piping output back to the shell script for display. + +--- + +## Verification Strategy + +See Phase 4 output (to be determined). + +--- + +## What this is NOT + +- Not a production feature — the standalone package gets removed after Plan 2 +- Not a backend integration — the JSON files are ready to curl, but curling is manual +- `TransitionFilter` is intentionally out of scope — build when enum-like metrics are added From b42366fe6268c5ebf2359e01f19a82f8aeba5521 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 16 Apr 2026 11:56:47 +0200 Subject: [PATCH 026/102] update demo script with sampling strategies section --- pod/demo/01-demo-18-04.sh | 243 +++++++++++++++++++++++++++----------- 1 file changed, 176 insertions(+), 67 deletions(-) diff --git a/pod/demo/01-demo-18-04.sh b/pod/demo/01-demo-18-04.sh index 3116109a45..9e44cc33f8 100755 --- a/pod/demo/01-demo-18-04.sh +++ b/pod/demo/01-demo-18-04.sh @@ -2,7 +2,7 @@ set -euo pipefail # ============================================================================ -# Performance Timeseries — Plan 1 Demo Script +# Performance Timeseries — Plan 1 + Sampling Demo # AI-first POD | Week 1 | April 18, 2026 # ============================================================================ # @@ -45,16 +45,31 @@ pause() { echo "" } +IOS_PACKAGE="$IOS_SDK_PATH/DatadogTimeseries" + # ============================================================================ clear echo "" -heading " PERFORMANCE TIMESERIES — Plan 1 Demo" +heading " PERFORMANCE TIMESERIES — Week 1" echo "" narrate " AI-first POD | Sprint 1 | RUM-13949" narrate " Barbora Plasovska | April 18, 2026" separator -heading "This Week's Focus" +heading "This Week" +echo "" +echo " 1. Built and verified the business logic pipeline — a standalone," +echo " platform-agnostic package that takes timestamped performance samples" +echo " and produces complete RUM timeseries JSON events." +echo " Tested on both iOS (Swift) and Android (Kotlin)." +echo "" +echo " 2. Researched sampling strategies and algorithms — how much data can" +echo " we cut without losing meaningful signal." +pause + +# ============================================================================ +separator +heading "1/2 Business Logic Pipeline" echo "" echo " Built a standalone, platform-agnostic pipeline that implements" echo " the core business logic of the timeseries feature." @@ -70,37 +85,25 @@ echo " and Android (Kotlin) against the same expected fixtures." echo " This is the foundation everything else builds on." pause -# ============================================================================ separator -heading "1/4 iOS Pipeline (Swift)" -echo "" -narrate " Package: dd-sdk-ios/DatadogTimeseries/" -narrate " Running: swift test" +narrate " iOS — dd-sdk-ios/DatadogTimeseries/" echo "" -IOS_PACKAGE="$IOS_SDK_PATH/DatadogTimeseries" if [ ! -d "$IOS_PACKAGE" ]; then echo -e " ${YELLOW}Skipped — $IOS_PACKAGE not found${RESET}" else cd "$IOS_PACKAGE" - swift test 2>&1 | grep -E "Test Suite|test.*passed|test.*failed|All tests" | while IFS= read -r line; do - if echo "$line" | grep -q "passed"; then - echo -e " ${GREEN}$line${RESET}" - elif echo "$line" | grep -q "failed"; then - echo -e " ${YELLOW}$line${RESET}" - else - echo -e " $line" - fi - done + TEST_OUTPUT=$(swift test 2>&1) + PASSED=$(echo "$TEST_OUTPUT" | grep -E "Executed [0-9]+ tests" | tail -1 || true) + if [ -n "$PASSED" ]; then + echo -e " ${GREEN}✓ $PASSED${RESET}" + else + echo -e " ${GREEN}✓ Tests passed${RESET}" + fi fi -pause -# ============================================================================ -separator -heading "2/4 Android Pipeline (Kotlin)" echo "" -narrate " Package: dd-sdk-android/DatadogTimeseries/" -narrate " Running: ./gradlew cleanTest test" +narrate " Android — dd-sdk-android/DatadogTimeseries/" echo "" ANDROID_PACKAGE="$ANDROID_SDK_PATH/DatadogTimeseries" @@ -108,32 +111,31 @@ if [ ! -d "$ANDROID_PACKAGE" ]; then echo -e " ${YELLOW}Skipped — $ANDROID_PACKAGE not found${RESET}" else cd "$ANDROID_PACKAGE" - JAVA_HOME="$JAVA_HOME_PATH" ./gradlew cleanTest test 2>&1 | grep -E "PASSED|FAILED" | while IFS= read -r line; do - if echo "$line" | grep -q "PASSED"; then - echo -e " ${GREEN}$line${RESET}" + GRADLE_OUTPUT=$(JAVA_HOME="$JAVA_HOME_PATH" ./gradlew cleanTest test 2>&1) + PASSED_COUNT=$(echo "$GRADLE_OUTPUT" | grep -c " PASSED" || true) + FAILED_COUNT=$(echo "$GRADLE_OUTPUT" | grep -c " FAILED" || true) + if [ "$PASSED_COUNT" -gt 0 ]; then + echo -e " ${GREEN}✓ Executed $PASSED_COUNT tests, with $FAILED_COUNT failures${RESET}" + else + SUMMARY=$(echo "$GRADLE_OUTPUT" | grep -E "[0-9]+ tests completed" | tail -1 || true) + if [ -n "$SUMMARY" ]; then + echo -e " ${GREEN}✓ $SUMMARY${RESET}" else - echo -e " ${YELLOW}$line${RESET}" + echo -e " ${GREEN}✓ Tests passed${RESET}" fi - done + fi fi pause -# ============================================================================ separator -heading "3/4 Cross-Platform Fixture Match" -echo "" echo " Both platforms verify against the SAME expected JSON fixtures." -echo " This proves the business logic produces identical RUM events" -echo " regardless of platform." +echo " Same business logic → identical RUM events regardless of platform." echo "" FIXTURE_IOS="$IOS_SDK_PATH/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json" FIXTURE_ANDROID="$ANDROID_SDK_PATH/DatadogTimeseries/src/test/resources/fixtures/expected_memory_batch1.json" if [ -f "$FIXTURE_IOS" ] && [ -f "$FIXTURE_ANDROID" ]; then - subheading " Fixture: expected_memory_batch1.json" - echo "" - if diff -q "$FIXTURE_IOS" "$FIXTURE_ANDROID" > /dev/null 2>&1; then echo -e " ${GREEN}iOS and Android fixtures are identical${RESET}" else @@ -159,43 +161,150 @@ pause # ============================================================================ separator -heading "4/4 What This Gives Us" -echo "" -echo " The standalone pipeline is a verified reference implementation." -echo " Every component is tested in isolation AND end-to-end." -echo "" -subheading " Components:" -echo " CSVDataProvider — reads timestamped samples from CSV" -echo " TimeseriesBatcher — accumulates N samples, flushes when full" -echo " TimeseriesEventBuilder — samples + config --> full RUM envelope" -echo " TimeseriesEncoder — event --> deterministic JSON" -echo " TimeseriesPipeline — orchestrates the full flow" -echo "" -subheading " Test coverage:" -echo " iOS: 36 tests (byte-for-byte fixture match)" -echo " Android: 36 tests (structural JSON comparison)" -echo "" -subheading " Schema contract:" -echo " _dd.format_version = 2" -echo " type = \"timeseries\"" -echo " timeseries.name = memory_usage | cpu_usage" -echo " Event date in ms, data point timestamps in ns" -echo " Null fields omitted (service, version)" +heading "2/2 Sampling Strategies" +echo "" +echo " As of right now, we collect one sample per second." +echo "" +echo " For a 30-minute session:" +echo " memory_usage → 1,800 data points" +echo " cpu_usage → 1,800 data points" +echo " total → 3,600 data points per session" +echo "" +echo " Most of that data is redundant." +echo " Memory barely moves when the app is idle." +echo " CPU is noisy — individual spikes matter, not every tick." +echo "" +echo " Can we send less data without losing meaningful signal?" +pause + +separator +subheading " Three Strategies" +echo "" +echo " 1. PassThrough (current baseline)" +echo " Every sample is forwarded. 60 samples in → 60 data points out." +echo " No intelligence — maximum data, maximum cost." +echo "" +echo " 2. Deadband" +echo " Only emit a sample when the value has changed by more than a threshold." +echo " Memory sits at 31MB for 10 seconds → send nothing." +echo " Memory jumps to 33MB → emit." +echo " Good for: memory (slow-changing, allocation-driven)" +echo "" +echo " 3. Window Aggregate" +echo " Collapse a time window into one representative value (max, avg, min)." +echo " 10 CPU samples over 5 seconds → emit the peak." +echo " Good for: CPU (noisy, burst-driven)" +pause + +separator +narrate " Running all 3 against a 60-second fixture (60 samples per metric)..." +echo "" + +if [ ! -d "$IOS_PACKAGE" ]; then + echo -e " ${YELLOW}Skipped — $IOS_PACKAGE not found${RESET}" + pause +else + cd "$IOS_PACKAGE" + + FIXTURE="$IOS_PACKAGE/Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv" + OUTPUT_DIR="$IOS_PACKAGE/output" + rm -rf "$OUTPUT_DIR" && mkdir -p "$OUTPUT_DIR" + + RUNNER_TMP="$(mktemp /tmp/ts-runner-output.XXXXXX.json)" + trap 'rm -f "$RUNNER_TMP"' EXIT + + swift run DatadogTimeseriesRunner -- \ + --fixture-path "$FIXTURE" \ + --output-dir "$OUTPUT_DIR" \ + --threshold 1000000 \ + --heartbeat 30 \ + --window 5 \ + --aggregate max > "$RUNNER_TMP" 2>/dev/null + + echo -e " ${GREEN}✓ Pipeline complete${RESET}" + echo "" + + python3 - "$RUNNER_TMP" <<'PYEOF' +import json, sys + +with open(sys.argv[1]) as f: + data = json.load(f) + +stats = data["stats"] +filters = ["passthrough", "deadband", "window"] +labels = {"passthrough": "PassThrough", "deadband": "Deadband (1MB)", "window": "Window (max, 5s)"} + +pt_mem = stats["passthrough"]["memory"]["dataPointCount"] +pt_cpu = stats["passthrough"]["cpu"]["dataPointCount"] +pt_total = pt_mem + pt_cpu + +col_w = [20, 13, 13, 13, 13, 13] +header = ( + f" {'Filter':<{col_w[0]}}" + f"{'Events(mem)':>{col_w[1]}}" + f"{'Points(mem)':>{col_w[2]}}" + f"{'Events(cpu)':>{col_w[3]}}" + f"{'Points(cpu)':>{col_w[4]}}" + f"{'Reduction':>{col_w[5]}}" +) +sep = " " + "-" * sum(col_w) + +print(header) +print(sep) +for f in filters: + em = stats[f]["memory"]["eventCount"] + dm = stats[f]["memory"]["dataPointCount"] + ec = stats[f]["cpu"]["eventCount"] + dc = stats[f]["cpu"]["dataPointCount"] + total = dm + dc + pct = round((1.0 - total / pt_total) * 100, 1) if pt_total > 0 else 0.0 + reduction = "--" if f == "passthrough" else f"{pct}%" + print( + f" {labels[f]:<{col_w[0]}}" + f"{em:>{col_w[1]}}" + f"{dm:>{col_w[2]}}" + f"{ec:>{col_w[3]}}" + f"{dc:>{col_w[4]}}" + f"{reduction:>{col_w[5]}}" + ) +PYEOF + + echo "" + narrate " Filtered output is backend-ready — same schema, fewer data points." + echo "" + + python3 - "$RUNNER_TMP" <<'PYEOF' +import json, sys + +with open(sys.argv[1]) as f: + data = json.load(f) + +raw = data.get("firstEvents", {}).get("deadband_memory", "") +if raw: + parsed = json.loads(raw) + ts_data = parsed.get("timeseries", {}).get("data", []) + values_mb = [f"{round(p['data_point_value'] / 1e6, 1)}MB" for p in ts_data] + print(f" Deadband memory → {values_mb}") + print(f" Only the allocation jumps. Flat stretches between them: dropped.") +PYEOF + +fi pause # ============================================================================ separator heading "Next Steps" echo "" -echo " Complete the end-to-end pipeline:" -echo " - Wire SDK business logic into the real iOS and Android SDKs" -echo " - Connect to backend so we can test the full pipeline" -echo " (SDK --> backend --> query)" +echo " Integrate the pipeline into the SDK:" +echo " - Replace CSVDataProvider with real VitalMemoryReader / VitalCPUReader" +echo " - Wire into RUM session lifecycle and the upload pipeline" +echo " - Test end-to-end: SDK → backend → query" echo "" -echo " Once the full pipeline works end-to-end:" -echo " - Iterate on sampling rates, batching strategies, thresholds" -echo " - Measure real session size impact" -echo " - Tune based on backend query performance feedback" +echo " Two open decisions to resolve along the way:" +echo " Schema — which data_point format does the backend adopt?" +echo " (A: typed fields / B: single scalar / C: compound)" +echo " Sampling — per-metric strategy or one for all?" +echo " e.g. Deadband for memory, Window for CPU" separator echo "" narrate " End of demo." From 5c60967150cf2a544453273630835ada0e8d1de4 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 20 Apr 2026 13:54:04 +0200 Subject: [PATCH 027/102] add plan 4 for iOS SDK integration --- pod/plans/4-plan-ios-sdk-integration.md | 211 ++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 pod/plans/4-plan-ios-sdk-integration.md diff --git a/pod/plans/4-plan-ios-sdk-integration.md b/pod/plans/4-plan-ios-sdk-integration.md new file mode 100644 index 0000000000..fcba0c2849 --- /dev/null +++ b/pod/plans/4-plan-ios-sdk-integration.md @@ -0,0 +1,211 @@ +# Plan 4 — iOS SDK Integration + +**Branch:** `feature/timeseries` +**Scope:** Wire the timeseries pipeline into `DatadogRUM` so real memory and CPU samples flow through the SDK's upload infrastructure. + +--- + +## Context + +The standalone `DatadogTimeseries` package (Plan 1 + 3) is complete and committed. It has: +- `Sample`, `TimeseriesEvent`, `TimeseriesConfig`, `TimeseriesName` +- `TimeseriesPipeline` with `processAll()` (batch/CSV mode) +- `SampleFilter` protocol + PassThrough / Deadband / Window implementations +- Current schema: `{ "data_point_value": 42.0 }` (Schema B) + +`DatadogRUM` already has (on `feature/timeseries` / develop): +- `VitalMemoryReader` — reads `phys_footprint` via `task_info()` +- `VitalCPUReader` — reads CPU ticks via `host_statistics()` +- `VitalInfoSampler` — timer-driven, aggregates vitals stats for RUM view events +- `RUMScopeDependencies.vitalsReaders: VitalsReaders?` +- `RUMSessionScope` — session lifecycle hook point +- `RUMFeature` — initialises `VitalsReaders` from `vitalsUpdateFrequency` config + +Marie's prototype (`feature/timeseries-prototype`) implements `MemoryTimeseriesCollector` as reference — single metric, Schema B, batch size 5, tied to `RUMSessionScope`. We follow the same pattern for both metrics with Schema C. + +--- + +## Decisions + +- **Schema C** — DataPoint encodes as `{ "timestamp": ..., "memory_usage": 42.0 }` or `{ "timestamp": ..., "cpu_usage": 5.2 }`. One named field per data point, named after the metric. +- **No cross-package import** — `DatadogRUM` does not import `DatadogTimeseries`. Avoids SPM dependency between a standalone experimental package and the production SDK. Standalone package stays for demo/runner use only. +- **PassThrough filter only** — no sampling for this integration. Deadband/Window deferred. +- **Batch size: 30** — matches the standalone package default, ~30 seconds of data per event. +- **Sampling interval: 1s** — reuse the existing `VitalInfoSampler` timer or a dedicated 1s timer. +- **Collection scope: session** — start on session start, stop on session end, flush remaining buffer. +- **Opt-in via RUM config flag** — `RUM.Configuration.enableTimeseries: Bool = false`. +- **Upload: existing RUM Writer** — no new storage scope or upload worker. + +--- + +## Schema C DataPoint Encoding + +**Before (Schema B):** +```json +{ "timestamp": 1714000000000000000, "data_point_value": 38052032.0 } +``` + +**After (Schema C):** +```json +{ "timestamp": 1714000000000000000, "memory_usage": 38052032.0 } +{ "timestamp": 1714000000000000000, "cpu_usage": 5.2 } +``` + +Implementation: `DataPoint` uses a dynamic `CodingKey` so the value field name comes from the metric: + +```swift +struct TimeseriesDataPoint: Encodable { + let timestamp: Int64 + let metricName: String // "memory_usage" or "cpu_usage" + let value: Double + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: DynamicCodingKey.self) + try container.encode(timestamp, forKey: .init("timestamp")) + try container.encode(value, forKey: .init(metricName)) + } +} +``` + +--- + +## Architecture + +``` +RUM.Configuration.enableTimeseries = true + │ + ▼ +RUMFeature.init + → creates TimeseriesSessionCollector(memoryReader:, cpuReader:, writer:, config:) + → injects into RUMScopeDependencies + │ + ▼ +RUMSessionScope.init + → dependencies.timeseriesCollector?.start() + │ + ▼ +Timer @ 1s: + → memoryReader.readVitalData() → Sample → memoryBuffer.append + → cpuReader.readVitalData() → Sample → cpuBuffer.append + → if buffer.count >= batchSize: flush(metric, buffer) → Writer.write(event) + │ +RUMSessionScope ends + → dependencies.timeseriesCollector?.stop() (flushes remaining) +``` + +--- + +## Tasks + +### Phase 1 — Schema C in standalone package + +1. **Update `TimeseriesEvent.DataPoint`** in `DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift` + - Replace `dataPointValue: Double` (CodingKey `data_point_value`) with dynamic encoding + - Add `DynamicCodingKey` helper + - `DataPoint` becomes `{ timestamp, metricName, value }` — encodes value under `metricName` + +2. **Update `TimeseriesEventBuilder`** to pass the metric name when creating DataPoints + +3. **Update fixture files** (`expected_memory_batch1.json`, `expected_cpu_batch1.json`) to Schema C format + +4. **Update `DatadogTimeseriesRunner`** — output still valid after schema change + +5. **Run standalone tests** — all must pass after schema migration + +### Phase 2 — Timeseries infrastructure in DatadogRUM + +6. **Create `DatadogRUM/Sources/Timeseries/TimeseriesDataPoint.swift`** + - `TimeseriesDataPoint` struct with dynamic CodingKey encoding (Schema C) + - `DynamicCodingKey` helper + +7. **Create `DatadogRUM/Sources/Timeseries/TimeseriesRUMEvent.swift`** + - `TimeseriesRUMEvent: Encodable` — full event envelope + - Fields: `_dd`, `application`, `session`, `source`, `type`, `service`, `version`, `date`, `timeseries` + - `timeseries`: `{ id, name, start, end, data: [TimeseriesDataPoint] }` + - `name`: `"memory_usage"` or `"cpu_usage"` (raw string, not enum — avoid coupling) + +8. **Create `DatadogRUM/Sources/Timeseries/TimeseriesEventBuilder.swift`** + - `TimeseriesEventBuilder` — builds `TimeseriesRUMEvent` from a `[TimestampedSample]` buffer + - Takes session context (app ID, session ID, session type, source, service, version) + - Assigns `start` / `end` from first / last sample timestamp + +9. **Create `DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift`** + - `TimeseriesSessionCollector` — manages two metric streams (memory + CPU) + - `init(memoryReader:cpuReader:batchSize:writer:context:)` + - `start()` — starts 1s Timer on a background DispatchQueue + - `stop()` — invalidates timer, flushes remaining buffers + - Timer handler: read both vitals, append to respective buffer, flush if at batch size + - `flush(metric:buffer:)` — builds event via `TimeseriesEventBuilder`, writes via `Writer` + - Thread-safe: all buffer access on dedicated serial queue + +### Phase 3 — Wire into RUM + +10. **Update `RUM.Configuration`** (`DatadogRUM/Sources/RUMConfiguration.swift`) + - Add `public var enableTimeseries: Bool = false` + +11. **Update `RUMScopeDependencies`** (`DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift`) + - Add `timeseriesCollector: TimeseriesSessionCollector?` + +12. **Update `RUMFeature.init`** (`DatadogRUM/Sources/Feature/RUMFeature.swift`) + - If `configuration.enableTimeseries && vitalsReaders != nil`: + - Create `TimeseriesSessionCollector` with memory + CPU readers and the feature's writer + - Inject into `RUMScopeDependencies` + +13. **Update `RUMSessionScope`** (`DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift`) + - In `init`: call `dependencies.timeseriesCollector?.start(with: context)` + - On session end: call `dependencies.timeseriesCollector?.stop()` + +### Phase 4 — Tests + +14. **`DatadogTimeseriesTests`** (standalone package): + - Update encoding tests to assert Schema C field names + - Verify fixture JSON matches Schema C format + +15. **`DatadogRUMTests`** (SDK): + - `TimeseriesDataPointTests` — Schema C encoding, dynamic field name + - `TimeseriesEventBuilderTests` — correct envelope, timestamps, metric name + - `TimeseriesSessionCollectorTests` — batching, flush on stop, thread safety + - `RUMSessionScopeTests` — collector started/stopped with session (mock collector) + +--- + +## What is NOT in scope + +- Deadband / Window filters (Plan 3 deferred) +- Android integration (parallel track) +- Schema registry / rum-events-format changes (backend not ready) +- Per-session size limiting / data cap enforcement (experiments running in parallel) +- Custom metrics beyond memory_usage and cpu_usage + +--- + +## Files to create + +| File | Purpose | +|------|---------| +| `DatadogRUM/Sources/Timeseries/TimeseriesDataPoint.swift` | Schema C DataPoint + DynamicCodingKey | +| `DatadogRUM/Sources/Timeseries/TimeseriesRUMEvent.swift` | Full event envelope | +| `DatadogRUM/Sources/Timeseries/TimeseriesEventBuilder.swift` | Event builder | +| `DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift` | Session-level collector | +| `DatadogRUM/Tests/DatadogRUMTests/Timeseries/TimeseriesDataPointTests.swift` | Schema C encoding tests | +| `DatadogRUM/Tests/DatadogRUMTests/Timeseries/TimeseriesEventBuilderTests.swift` | Builder tests | +| `DatadogRUM/Tests/DatadogRUMTests/Timeseries/TimeseriesSessionCollectorTests.swift` | Collector tests | + +## Files to modify + +| File | Change | +|------|--------| +| `DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift` | Schema C DataPoint | +| `DatadogRUM/Sources/RUMConfiguration.swift` | Add `enableTimeseries` flag | +| `DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift` | Add `timeseriesCollector` | +| `DatadogRUM/Sources/Feature/RUMFeature.swift` | Create + inject collector | +| `DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift` | Start/stop collector | +| Fixture JSON files | Schema C format | + +--- + +## Open Questions + +- Does `TimeseriesSessionCollector` need access to `DatadogContext` for `source`, `service`, `version`? (Yes — pass at `start(with:)` or `init`) +- Should `enableTimeseries` only activate if `vitalsUpdateFrequency != nil`? (Yes — guard at collector creation) +- ~~What is the `type` field value for timeseries events in the RUM schema?~~ → **`"timeseries"`** (confirmed from `TimeseriesEventBuilder.build()` in both the standalone package and Marie's prototype) From 13ec25f5d8a1781b6d3b4b973e6d3d4b4948dfc3 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 20 Apr 2026 14:45:26 +0200 Subject: [PATCH 028/102] update plan 4 to use rum-events-format generated model instead of hand-written type --- pod/plans/4-plan-ios-sdk-integration.md | 82 +++++++++++++++---------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/pod/plans/4-plan-ios-sdk-integration.md b/pod/plans/4-plan-ios-sdk-integration.md index fcba0c2849..39ec22032e 100644 --- a/pod/plans/4-plan-ios-sdk-integration.md +++ b/pod/plans/4-plan-ios-sdk-integration.md @@ -95,8 +95,31 @@ RUMSessionScope ends --- +## How the Event Model Gets Into the SDK + +RUM event types in this SDK are **not hand-written**. The flow is: + +1. Schema defined as JSON Schema in the [`rum-events-format`](https://github.com/DataDog/rum-events-format) repo +2. `make rum-models-generate` runs a codegen tool → appends the generated Swift struct to `DatadogInternal/Sources/Models/RUM/RUMDataModels.swift` +3. The generated struct conforms to `RUMDataModel` (which is `Codable`) and uses explicit `CodingKeys` +4. `TimeseriesSessionCollector` uses this generated type when writing events + +**Blocking dependency:** The `rum-events-format` PR must be opened and merged, and `make rum-models-generate` must be run, before the collector can reference the final generated type. This is blocked until the backend confirms schema + the schema PR is reviewed. + +**For development on this branch (while blocked):** A temporary placeholder type `_TimeseriesEventPlaceholder` (prefixed `_`, internal only) lives in `DatadogRUM/Sources/Timeseries/` and is marked with `// FIXME(RUM-13949): replace with type generated from rum-events-format once schema is confirmed`. Once the schema is confirmed and the model is generated, this placeholder is deleted and its usages are replaced with the generated type. + +--- + ## Tasks +### Phase 0 — rum-events-format schema (PREREQUISITE — blocked on backend) + +0. **Open a PR in `rum-events-format`** defining the `TimeseriesEvent` JSON Schema + - Schema C shape: `{ "_dd", "application", "session", "source", "type", "service", "version", "date", "timeseries": { "id", "name", "start", "end", "data": [{ "timestamp", "": value }] } }` + - Each metric gets its own data point schema (static keys, not dynamic): `memory_usage` event uses a `DataPoint` with `memory_usage: Double`; `cpu_usage` event uses a `DataPoint` with `cpu_usage: Double` + - Once merged: run `make rum-models-generate` → generated structs appear in `RUMDataModels.swift` + - **Then:** remove the placeholder from Phase 2 / Task 7 and replace with the generated types + ### Phase 1 — Schema C in standalone package 1. **Update `TimeseriesEvent.DataPoint`** in `DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift` @@ -114,56 +137,48 @@ RUMSessionScope ends ### Phase 2 — Timeseries infrastructure in DatadogRUM -6. **Create `DatadogRUM/Sources/Timeseries/TimeseriesDataPoint.swift`** - - `TimeseriesDataPoint` struct with dynamic CodingKey encoding (Schema C) - - `DynamicCodingKey` helper - -7. **Create `DatadogRUM/Sources/Timeseries/TimeseriesRUMEvent.swift`** - - `TimeseriesRUMEvent: Encodable` — full event envelope +6. **Create `DatadogRUM/Sources/Timeseries/_TimeseriesEventPlaceholder.swift`** + - Temporary placeholder until Phase 0 is unblocked + - Contains `_TimeseriesRUMEvent: Encodable` — full event envelope mirroring the expected generated shape - Fields: `_dd`, `application`, `session`, `source`, `type`, `service`, `version`, `date`, `timeseries` - - `timeseries`: `{ id, name, start, end, data: [TimeseriesDataPoint] }` - - `name`: `"memory_usage"` or `"cpu_usage"` (raw string, not enum — avoid coupling) + - `timeseries`: `{ id, name, start, end, data: [DataPoint] }` + - `DataPoint` uses dynamic `CodingKey` encoding for Schema C (value under metric name) + - Marked with `// FIXME(RUM-13949): delete this file once type is generated from rum-events-format` -8. **Create `DatadogRUM/Sources/Timeseries/TimeseriesEventBuilder.swift`** - - `TimeseriesEventBuilder` — builds `TimeseriesRUMEvent` from a `[TimestampedSample]` buffer - - Takes session context (app ID, session ID, session type, source, service, version) - - Assigns `start` / `end` from first / last sample timestamp - -9. **Create `DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift`** +7. **Create `DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift`** - `TimeseriesSessionCollector` — manages two metric streams (memory + CPU) - `init(memoryReader:cpuReader:batchSize:writer:context:)` - `start()` — starts 1s Timer on a background DispatchQueue - `stop()` — invalidates timer, flushes remaining buffers - Timer handler: read both vitals, append to respective buffer, flush if at batch size - - `flush(metric:buffer:)` — builds event via `TimeseriesEventBuilder`, writes via `Writer` + - `flush(metric:buffer:)` — builds event using `_TimeseriesRUMEvent` (placeholder), writes via `FeatureScope.eventWriteContext { _, writer in writer.write(value: event) }` - Thread-safe: all buffer access on dedicated serial queue ### Phase 3 — Wire into RUM -10. **Update `RUM.Configuration`** (`DatadogRUM/Sources/RUMConfiguration.swift`) +8. **Update `RUM.Configuration`** (`DatadogRUM/Sources/RUMConfiguration.swift`) - Add `public var enableTimeseries: Bool = false` -11. **Update `RUMScopeDependencies`** (`DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift`) +9. **Update `RUMScopeDependencies`** (`DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift`) - Add `timeseriesCollector: TimeseriesSessionCollector?` -12. **Update `RUMFeature.init`** (`DatadogRUM/Sources/Feature/RUMFeature.swift`) +10. **Update `RUMFeature.init`** (`DatadogRUM/Sources/Feature/RUMFeature.swift`) - If `configuration.enableTimeseries && vitalsReaders != nil`: - - Create `TimeseriesSessionCollector` with memory + CPU readers and the feature's writer + - Create `TimeseriesSessionCollector` with memory + CPU readers and the feature scope - Inject into `RUMScopeDependencies` -13. **Update `RUMSessionScope`** (`DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift`) +11. **Update `RUMSessionScope`** (`DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift`) - In `init`: call `dependencies.timeseriesCollector?.start(with: context)` - On session end: call `dependencies.timeseriesCollector?.stop()` ### Phase 4 — Tests -14. **`DatadogTimeseriesTests`** (standalone package): +12. **`DatadogTimeseriesTests`** (standalone package): - Update encoding tests to assert Schema C field names - Verify fixture JSON matches Schema C format -15. **`DatadogRUMTests`** (SDK): - - `TimeseriesDataPointTests` — Schema C encoding, dynamic field name - - `TimeseriesEventBuilderTests` — correct envelope, timestamps, metric name +13. **`DatadogRUMTests`** (SDK): + - `_TimeseriesEventPlaceholderTests` — Schema C encoding, dynamic field name (matches expected generated shape) - `TimeseriesSessionCollectorTests` — batching, flush on stop, thread safety - `RUMSessionScopeTests` — collector started/stopped with session (mock collector) @@ -173,7 +188,7 @@ RUMSessionScope ends - Deadband / Window filters (Plan 3 deferred) - Android integration (parallel track) -- Schema registry / rum-events-format changes (backend not ready) +- Merging rum-events-format schema PR (blocked on backend confirmation — tracked as Phase 0) - Per-session size limiting / data cap enforcement (experiments running in parallel) - Custom metrics beyond memory_usage and cpu_usage @@ -183,12 +198,9 @@ RUMSessionScope ends | File | Purpose | |------|---------| -| `DatadogRUM/Sources/Timeseries/TimeseriesDataPoint.swift` | Schema C DataPoint + DynamicCodingKey | -| `DatadogRUM/Sources/Timeseries/TimeseriesRUMEvent.swift` | Full event envelope | -| `DatadogRUM/Sources/Timeseries/TimeseriesEventBuilder.swift` | Event builder | -| `DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift` | Session-level collector | -| `DatadogRUM/Tests/DatadogRUMTests/Timeseries/TimeseriesDataPointTests.swift` | Schema C encoding tests | -| `DatadogRUM/Tests/DatadogRUMTests/Timeseries/TimeseriesEventBuilderTests.swift` | Builder tests | +| `DatadogRUM/Sources/Timeseries/_TimeseriesEventPlaceholder.swift` | Temporary placeholder event type (delete once generated from rum-events-format) | +| `DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift` | Session-level collector (memory + CPU) | +| `DatadogRUM/Tests/DatadogRUMTests/Timeseries/_TimeseriesEventPlaceholderTests.swift` | Schema C encoding tests for placeholder type | | `DatadogRUM/Tests/DatadogRUMTests/Timeseries/TimeseriesSessionCollectorTests.swift` | Collector tests | ## Files to modify @@ -202,6 +214,14 @@ RUMSessionScope ends | `DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift` | Start/stop collector | | Fixture JSON files | Schema C format | +## Post-schema-confirmation cleanup (Phase 0 unblocked) + +When `rum-events-format` PR merges and `make rum-models-generate` runs: +1. Delete `DatadogRUM/Sources/Timeseries/_TimeseriesEventPlaceholder.swift` +2. Delete `DatadogRUM/Tests/DatadogRUMTests/Timeseries/_TimeseriesEventPlaceholderTests.swift` +3. Update `TimeseriesSessionCollector` to use the generated type from `RUMDataModels.swift` +4. Update `TimeseriesSessionCollectorTests` to reference the generated type + --- ## Open Questions From 1e661c713d57f09fbcc3425e3db28c9d0a420b79 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 20 Apr 2026 14:50:58 +0200 Subject: [PATCH 029/102] update plan 4 to use bplasovska/timeseries branch on rum-events-format, no PR needed --- pod/plans/4-plan-ios-sdk-integration.md | 54 ++++++++----------------- 1 file changed, 17 insertions(+), 37 deletions(-) diff --git a/pod/plans/4-plan-ios-sdk-integration.md b/pod/plans/4-plan-ios-sdk-integration.md index 39ec22032e..88bde92a90 100644 --- a/pod/plans/4-plan-ios-sdk-integration.md +++ b/pod/plans/4-plan-ios-sdk-integration.md @@ -104,21 +104,19 @@ RUM event types in this SDK are **not hand-written**. The flow is: 3. The generated struct conforms to `RUMDataModel` (which is `Codable`) and uses explicit `CodingKeys` 4. `TimeseriesSessionCollector` uses this generated type when writing events -**Blocking dependency:** The `rum-events-format` PR must be opened and merged, and `make rum-models-generate` must be run, before the collector can reference the final generated type. This is blocked until the backend confirms schema + the schema PR is reviewed. - -**For development on this branch (while blocked):** A temporary placeholder type `_TimeseriesEventPlaceholder` (prefixed `_`, internal only) lives in `DatadogRUM/Sources/Timeseries/` and is marked with `// FIXME(RUM-13949): replace with type generated from rum-events-format once schema is confirmed`. Once the schema is confirmed and the model is generated, this placeholder is deleted and its usages are replaced with the generated type. +Since we work off a feature branch on `rum-events-format` (`bplasovska/timeseries`) without needing a merged PR, the generated type is available on the iOS branch from the start of Phase 2. --- ## Tasks -### Phase 0 — rum-events-format schema (PREREQUISITE — blocked on backend) +### Phase 0 — rum-events-format schema -0. **Open a PR in `rum-events-format`** defining the `TimeseriesEvent` JSON Schema +0. **Create branch `bplasovska/timeseries` on `rum-events-format`** and define the `TimeseriesEvent` JSON Schema there (no PR needed) - Schema C shape: `{ "_dd", "application", "session", "source", "type", "service", "version", "date", "timeseries": { "id", "name", "start", "end", "data": [{ "timestamp", "": value }] } }` - - Each metric gets its own data point schema (static keys, not dynamic): `memory_usage` event uses a `DataPoint` with `memory_usage: Double`; `cpu_usage` event uses a `DataPoint` with `cpu_usage: Double` - - Once merged: run `make rum-models-generate` → generated structs appear in `RUMDataModels.swift` - - **Then:** remove the placeholder from Phase 2 / Task 7 and replace with the generated types + - Each metric gets its own data point schema (static keys): `memory_usage` event uses a `DataPoint` with `memory_usage: Double`; `cpu_usage` event uses a `DataPoint` with `cpu_usage: Double` + - Run `make rum-models-generate GIT_REF=bplasovska/timeseries` on the iOS branch → generated structs appear in `RUMDataModels.swift` + - **No placeholder needed:** since we can generate from the branch immediately, skip the `_TimeseriesEventPlaceholder.swift` approach ### Phase 1 — Schema C in standalone package @@ -137,49 +135,40 @@ RUM event types in this SDK are **not hand-written**. The flow is: ### Phase 2 — Timeseries infrastructure in DatadogRUM -6. **Create `DatadogRUM/Sources/Timeseries/_TimeseriesEventPlaceholder.swift`** - - Temporary placeholder until Phase 0 is unblocked - - Contains `_TimeseriesRUMEvent: Encodable` — full event envelope mirroring the expected generated shape - - Fields: `_dd`, `application`, `session`, `source`, `type`, `service`, `version`, `date`, `timeseries` - - `timeseries`: `{ id, name, start, end, data: [DataPoint] }` - - `DataPoint` uses dynamic `CodingKey` encoding for Schema C (value under metric name) - - Marked with `// FIXME(RUM-13949): delete this file once type is generated from rum-events-format` - -7. **Create `DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift`** +6. **Create `DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift`** - `TimeseriesSessionCollector` — manages two metric streams (memory + CPU) - `init(memoryReader:cpuReader:batchSize:writer:context:)` - `start()` — starts 1s Timer on a background DispatchQueue - `stop()` — invalidates timer, flushes remaining buffers - Timer handler: read both vitals, append to respective buffer, flush if at batch size - - `flush(metric:buffer:)` — builds event using `_TimeseriesRUMEvent` (placeholder), writes via `FeatureScope.eventWriteContext { _, writer in writer.write(value: event) }` + - `flush(metric:buffer:)` — builds event using the generated type from `RUMDataModels.swift`, writes via `FeatureScope.eventWriteContext { _, writer in writer.write(value: event) }` - Thread-safe: all buffer access on dedicated serial queue ### Phase 3 — Wire into RUM -8. **Update `RUM.Configuration`** (`DatadogRUM/Sources/RUMConfiguration.swift`) +7. **Update `RUM.Configuration`** (`DatadogRUM/Sources/RUMConfiguration.swift`) - Add `public var enableTimeseries: Bool = false` -9. **Update `RUMScopeDependencies`** (`DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift`) +8. **Update `RUMScopeDependencies`** (`DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift`) - Add `timeseriesCollector: TimeseriesSessionCollector?` -10. **Update `RUMFeature.init`** (`DatadogRUM/Sources/Feature/RUMFeature.swift`) +9. **Update `RUMFeature.init`** (`DatadogRUM/Sources/Feature/RUMFeature.swift`) - If `configuration.enableTimeseries && vitalsReaders != nil`: - Create `TimeseriesSessionCollector` with memory + CPU readers and the feature scope - Inject into `RUMScopeDependencies` -11. **Update `RUMSessionScope`** (`DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift`) +10. **Update `RUMSessionScope`** (`DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift`) - In `init`: call `dependencies.timeseriesCollector?.start(with: context)` - On session end: call `dependencies.timeseriesCollector?.stop()` ### Phase 4 — Tests -12. **`DatadogTimeseriesTests`** (standalone package): +11. **`DatadogTimeseriesTests`** (standalone package): - Update encoding tests to assert Schema C field names - Verify fixture JSON matches Schema C format -13. **`DatadogRUMTests`** (SDK): - - `_TimeseriesEventPlaceholderTests` — Schema C encoding, dynamic field name (matches expected generated shape) - - `TimeseriesSessionCollectorTests` — batching, flush on stop, thread safety +12. **`DatadogRUMTests`** (SDK): + - `TimeseriesSessionCollectorTests` — batching, flush on stop, thread safety, correct generated type used - `RUMSessionScopeTests` — collector started/stopped with session (mock collector) --- @@ -188,7 +177,7 @@ RUM event types in this SDK are **not hand-written**. The flow is: - Deadband / Window filters (Plan 3 deferred) - Android integration (parallel track) -- Merging rum-events-format schema PR (blocked on backend confirmation — tracked as Phase 0) +- Merging the `bplasovska/timeseries` branch into rum-events-format main (no PR needed for this phase) - Per-session size limiting / data cap enforcement (experiments running in parallel) - Custom metrics beyond memory_usage and cpu_usage @@ -198,9 +187,7 @@ RUM event types in this SDK are **not hand-written**. The flow is: | File | Purpose | |------|---------| -| `DatadogRUM/Sources/Timeseries/_TimeseriesEventPlaceholder.swift` | Temporary placeholder event type (delete once generated from rum-events-format) | | `DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift` | Session-level collector (memory + CPU) | -| `DatadogRUM/Tests/DatadogRUMTests/Timeseries/_TimeseriesEventPlaceholderTests.swift` | Schema C encoding tests for placeholder type | | `DatadogRUM/Tests/DatadogRUMTests/Timeseries/TimeseriesSessionCollectorTests.swift` | Collector tests | ## Files to modify @@ -208,20 +195,13 @@ RUM event types in this SDK are **not hand-written**. The flow is: | File | Change | |------|--------| | `DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift` | Schema C DataPoint | +| `DatadogInternal/Sources/Models/RUM/RUMDataModels.swift` | Generated — run `make rum-models-generate GIT_REF=bplasovska/timeseries` | | `DatadogRUM/Sources/RUMConfiguration.swift` | Add `enableTimeseries` flag | | `DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift` | Add `timeseriesCollector` | | `DatadogRUM/Sources/Feature/RUMFeature.swift` | Create + inject collector | | `DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift` | Start/stop collector | | Fixture JSON files | Schema C format | -## Post-schema-confirmation cleanup (Phase 0 unblocked) - -When `rum-events-format` PR merges and `make rum-models-generate` runs: -1. Delete `DatadogRUM/Sources/Timeseries/_TimeseriesEventPlaceholder.swift` -2. Delete `DatadogRUM/Tests/DatadogRUMTests/Timeseries/_TimeseriesEventPlaceholderTests.swift` -3. Update `TimeseriesSessionCollector` to use the generated type from `RUMDataModels.swift` -4. Update `TimeseriesSessionCollectorTests` to reference the generated type - --- ## Open Questions From 8c804b6c860f814eac2edf904c4d5fd0b34327c7 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 20 Apr 2026 15:17:50 +0200 Subject: [PATCH 030/102] update plan 4 with criticism findings: Schema C shape, two generated types, start(sessionID:), dedicated timer --- pod/plans/4-plan-ios-sdk-integration.md | 81 +++++++++++++++---------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/pod/plans/4-plan-ios-sdk-integration.md b/pod/plans/4-plan-ios-sdk-integration.md index 88bde92a90..80aefdf325 100644 --- a/pod/plans/4-plan-ios-sdk-integration.md +++ b/pod/plans/4-plan-ios-sdk-integration.md @@ -27,11 +27,15 @@ Marie's prototype (`feature/timeseries-prototype`) implements `MemoryTimeseriesC ## Decisions -- **Schema C** — DataPoint encodes as `{ "timestamp": ..., "memory_usage": 42.0 }` or `{ "timestamp": ..., "cpu_usage": 5.2 }`. One named field per data point, named after the metric. -- **No cross-package import** — `DatadogRUM` does not import `DatadogTimeseries`. Avoids SPM dependency between a standalone experimental package and the production SDK. Standalone package stays for demo/runner use only. +- **Schema C** — DataPoint is `{ "timestamp": ..., "data_point": { "memory_max": ..., "memory_percent": ... } }`. Nested `data_point` object with named metric fields. Static `CodingKeys` — compatible with code generation. +- **Two generated event types** — `RUMTimeseriesMemoryEvent` and `RUMTimeseriesCPUEvent` defined separately in rum-events-format, each with their own `DataPoint` struct. +- **memory_percent computed at sampling time** — `VitalMemoryReader` returns bytes; collector divides by `ProcessInfo.processInfo.physicalMemory * 100` to get percent. +- **No cross-package import** — `DatadogRUM` does not import `DatadogTimeseries`. Standalone package stays for demo/runner use only. - **PassThrough filter only** — no sampling for this integration. Deadband/Window deferred. -- **Batch size: 30** — matches the standalone package default, ~30 seconds of data per event. -- **Sampling interval: 1s** — reuse the existing `VitalInfoSampler` timer or a dedicated 1s timer. +- **Batch size: 30** — ~30 seconds of data per event. +- **Sampling interval: 1s** — dedicated `DispatchSourceTimer` (not reusing `VitalInfoSampler`, which runs at user-configured frequency). +- **Collector lifetime: single reusable instance** — created in `RUMFeature.init`, `start()` resets all state (buffers, timer) cleanly per session. +- **Session context injected at `start()`** — `RUMSessionScope` calls `collector.start(sessionID:applicationID:)` so the collector always has fresh context for the new session. - **Collection scope: session** — start on session start, stop on session end, flush remaining buffer. - **Opt-in via RUM config flag** — `RUM.Configuration.enableTimeseries: Bool = false`. - **Upload: existing RUM Writer** — no new storage scope or upload worker. @@ -47,22 +51,37 @@ Marie's prototype (`feature/timeseries-prototype`) implements `MemoryTimeseriesC **After (Schema C):** ```json -{ "timestamp": 1714000000000000000, "memory_usage": 38052032.0 } -{ "timestamp": 1714000000000000000, "cpu_usage": 5.2 } +{ + "timestamp": 1776690660041000000, + "data_point": { + "memory_max": 115456128.5, + "memory_percent": 76.8 + } +} ``` -Implementation: `DataPoint` uses a dynamic `CodingKey` so the value field name comes from the metric: +The value is wrapped in a nested `data_point` object with named metric fields. This uses **static `CodingKeys`** — fully compatible with code generation. + +**Memory data point fields:** +- `memory_max` — raw bytes from `VitalMemoryReader.readVitalData()` (`phys_footprint`) +- `memory_percent` — `memory_max / ProcessInfo.processInfo.physicalMemory * 100` + +**CPU data point fields (to confirm exact names with backend):** +- `cpu_usage` — CPU percentage from `VitalCPUReader.readVitalData()` +**Generated struct shape (from rum-events-format):** ```swift -struct TimeseriesDataPoint: Encodable { - let timestamp: Int64 - let metricName: String // "memory_usage" or "cpu_usage" - let value: Double - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: DynamicCodingKey.self) - try container.encode(timestamp, forKey: .init("timestamp")) - try container.encode(value, forKey: .init(metricName)) +// RUMDataModels.swift (generated) +public struct RUMTimeseriesMemoryEvent: RUMDataModel { + // ... envelope fields ... + public struct DataPoint: Codable { + public let timestamp: Int64 + public let dataPoint: MemoryDataPoint + public struct MemoryDataPoint: Codable { + public let memoryMax: Double + public let memoryPercent: Double + // CodingKeys: memory_max, memory_percent + } } } ``` @@ -112,20 +131,20 @@ Since we work off a feature branch on `rum-events-format` (`bplasovska/timeserie ### Phase 0 — rum-events-format schema -0. **Create branch `bplasovska/timeseries` on `rum-events-format`** and define the `TimeseriesEvent` JSON Schema there (no PR needed) - - Schema C shape: `{ "_dd", "application", "session", "source", "type", "service", "version", "date", "timeseries": { "id", "name", "start", "end", "data": [{ "timestamp", "": value }] } }` - - Each metric gets its own data point schema (static keys): `memory_usage` event uses a `DataPoint` with `memory_usage: Double`; `cpu_usage` event uses a `DataPoint` with `cpu_usage: Double` +0. **Create branch `bplasovska/timeseries` on `rum-events-format`** and define two JSON Schemas there (no PR needed) + - **`RUMTimeseriesMemoryEvent`**: envelope + `timeseries.data: [{ timestamp, data_point: { memory_max: Double, memory_percent: Double } }]` + - **`RUMTimeseriesCPUEvent`**: envelope + `timeseries.data: [{ timestamp, data_point: { cpu_usage: Double } }]` (confirm exact CPU field names with backend) + - Both share the same envelope shape: `{ _dd, application, session, source, type, service, version, date, timeseries: { id, name, start, end, data } }` - Run `make rum-models-generate GIT_REF=bplasovska/timeseries` on the iOS branch → generated structs appear in `RUMDataModels.swift` - - **No placeholder needed:** since we can generate from the branch immediately, skip the `_TimeseriesEventPlaceholder.swift` approach ### Phase 1 — Schema C in standalone package 1. **Update `TimeseriesEvent.DataPoint`** in `DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift` - - Replace `dataPointValue: Double` (CodingKey `data_point_value`) with dynamic encoding - - Add `DynamicCodingKey` helper - - `DataPoint` becomes `{ timestamp, metricName, value }` — encodes value under `metricName` + - Replace `dataPointValue: Double` (CodingKey `data_point_value`) with Schema C nested shape + - `DataPoint` becomes `{ timestamp: Int64, dataPoint: [String: Double] }` — encodes as `{ "timestamp": ..., "data_point": { "memory_max": ..., "memory_percent": ... } }` + - `dataPoint` is a `[String: Double]` dictionary (flexible for runner/demo use; the SDK uses generated typed structs) -2. **Update `TimeseriesEventBuilder`** to pass the metric name when creating DataPoints +2. **Update `TimeseriesEventBuilder`** to populate `dataPoint` dictionary with the correct metric keys per `TimeseriesName` 3. **Update fixture files** (`expected_memory_batch1.json`, `expected_cpu_batch1.json`) to Schema C format @@ -137,11 +156,11 @@ Since we work off a feature branch on `rum-events-format` (`bplasovska/timeserie 6. **Create `DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift`** - `TimeseriesSessionCollector` — manages two metric streams (memory + CPU) - - `init(memoryReader:cpuReader:batchSize:writer:context:)` - - `start()` — starts 1s Timer on a background DispatchQueue - - `stop()` — invalidates timer, flushes remaining buffers - - Timer handler: read both vitals, append to respective buffer, flush if at batch size - - `flush(metric:buffer:)` — builds event using the generated type from `RUMDataModels.swift`, writes via `FeatureScope.eventWriteContext { _, writer in writer.write(value: event) }` + - `init(memoryReader:cpuReader:batchSize:featureScope:)` + - `start(sessionID:applicationID:)` — resets buffers, starts dedicated 1s `DispatchSourceTimer` on a background serial queue + - `stop()` — cancels timer, flushes remaining buffers for both metrics + - Timer handler: read both vitals; for memory compute `memory_percent = bytes / ProcessInfo.processInfo.physicalMemory * 100`; append to respective buffer; flush if buffer.count >= batchSize + - `flush(metric:buffer:)` — builds `RUMTimeseriesMemoryEvent` or `RUMTimeseriesCPUEvent` from `RUMDataModels.swift`, writes via `featureScope.eventWriteContext { _, writer in writer.write(value: event) }` - Thread-safe: all buffer access on dedicated serial queue ### Phase 3 — Wire into RUM @@ -158,8 +177,8 @@ Since we work off a feature branch on `rum-events-format` (`bplasovska/timeserie - Inject into `RUMScopeDependencies` 10. **Update `RUMSessionScope`** (`DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift`) - - In `init`: call `dependencies.timeseriesCollector?.start(with: context)` - - On session end: call `dependencies.timeseriesCollector?.stop()` + - In `init`: call `dependencies.timeseriesCollector?.start(sessionID: sessionUUID.toRUMDataFormat, applicationID: dependencies.rumApplicationID)` + - On session end (at the existing expiry/stop call site, following Marie's prototype pattern): call `dependencies.timeseriesCollector?.stop()` ### Phase 4 — Tests From 1bfbcd3f27b1ef80c90fdf081abaca9679138558 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 20 Apr 2026 15:19:34 +0200 Subject: [PATCH 031/102] add verification strategy to plan 4 --- pod/plans/4-plan-ios-sdk-integration.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pod/plans/4-plan-ios-sdk-integration.md b/pod/plans/4-plan-ios-sdk-integration.md index 80aefdf325..219c69c169 100644 --- a/pod/plans/4-plan-ios-sdk-integration.md +++ b/pod/plans/4-plan-ios-sdk-integration.md @@ -192,6 +192,15 @@ Since we work off a feature branch on `rum-events-format` (`bplasovska/timeserie --- +## Verification Strategy + +After each phase: +1. **Unit tests** — `swift test --package-path DatadogTimeseries` (standalone) and `make test-ios SCHEME="DatadogRUM iOS"` (SDK) +2. **Runner script** — after Schema C changes, run `DatadogTimeseriesRunner` against the fixture CSV and assert the output JSON matches the Schema C `data_point` nested shape +3. **Linter** — `./tools/lint/run-linter.sh` after each new/modified file + +--- + ## What is NOT in scope - Deadband / Window filters (Plan 3 deferred) From e041eb772ced732324eab59f49ff44c3b1eee051 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 20 Apr 2026 15:27:54 +0200 Subject: [PATCH 032/102] generate RUMTimeseriesMemoryEvent and RUMTimeseriesCpuEvent from rum-events-format bplasovska/timeseries --- .../Sources/Models/RUM/RUMDataModels.swift | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift index 804b606681..591cefba05 100644 --- a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift +++ b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift @@ -5405,9 +5405,6 @@ public struct RUMTimeseriesCpuEvent: RUMDataModel { /// The percentage of sessions profiled public let profilingSampleRate: Double? - /// Session Replay experimental features enabled in the SDK configuration - public let sessionReplayExperimentalFeatures: [String]? - /// The percentage of sessions with RUM & Session Replay pricing tracked public let sessionReplaySampleRate: Double? @@ -5419,7 +5416,6 @@ public struct RUMTimeseriesCpuEvent: RUMDataModel { public enum CodingKeys: String, CodingKey { case profilingSampleRate = "profiling_sample_rate" - case sessionReplayExperimentalFeatures = "session_replay_experimental_features" case sessionReplaySampleRate = "session_replay_sample_rate" case sessionSampleRate = "session_sample_rate" case traceSampleRate = "trace_sample_rate" @@ -5429,19 +5425,16 @@ public struct RUMTimeseriesCpuEvent: RUMDataModel { /// /// - Parameters: /// - profilingSampleRate: The percentage of sessions profiled - /// - sessionReplayExperimentalFeatures: Session Replay experimental features enabled in the SDK configuration /// - sessionReplaySampleRate: The percentage of sessions with RUM & Session Replay pricing tracked /// - sessionSampleRate: The percentage of sessions tracked /// - traceSampleRate: The percentage of sessions with traced resources public init( profilingSampleRate: Double? = nil, - sessionReplayExperimentalFeatures: [String]? = nil, sessionReplaySampleRate: Double? = nil, sessionSampleRate: Double, traceSampleRate: Double? = nil ) { self.profilingSampleRate = profilingSampleRate - self.sessionReplayExperimentalFeatures = sessionReplayExperimentalFeatures self.sessionReplaySampleRate = sessionReplaySampleRate self.sessionSampleRate = sessionSampleRate self.traceSampleRate = traceSampleRate @@ -6009,9 +6002,6 @@ public struct RUMTimeseriesMemoryEvent: RUMDataModel { /// The percentage of sessions profiled public let profilingSampleRate: Double? - /// Session Replay experimental features enabled in the SDK configuration - public let sessionReplayExperimentalFeatures: [String]? - /// The percentage of sessions with RUM & Session Replay pricing tracked public let sessionReplaySampleRate: Double? @@ -6023,7 +6013,6 @@ public struct RUMTimeseriesMemoryEvent: RUMDataModel { public enum CodingKeys: String, CodingKey { case profilingSampleRate = "profiling_sample_rate" - case sessionReplayExperimentalFeatures = "session_replay_experimental_features" case sessionReplaySampleRate = "session_replay_sample_rate" case sessionSampleRate = "session_sample_rate" case traceSampleRate = "trace_sample_rate" @@ -6033,19 +6022,16 @@ public struct RUMTimeseriesMemoryEvent: RUMDataModel { /// /// - Parameters: /// - profilingSampleRate: The percentage of sessions profiled - /// - sessionReplayExperimentalFeatures: Session Replay experimental features enabled in the SDK configuration /// - sessionReplaySampleRate: The percentage of sessions with RUM & Session Replay pricing tracked /// - sessionSampleRate: The percentage of sessions tracked /// - traceSampleRate: The percentage of sessions with traced resources public init( profilingSampleRate: Double? = nil, - sessionReplayExperimentalFeatures: [String]? = nil, sessionReplaySampleRate: Double? = nil, sessionSampleRate: Double, traceSampleRate: Double? = nil ) { self.profilingSampleRate = profilingSampleRate - self.sessionReplayExperimentalFeatures = sessionReplayExperimentalFeatures self.sessionReplaySampleRate = sessionReplaySampleRate self.sessionSampleRate = sessionSampleRate self.traceSampleRate = traceSampleRate From 593f24347f9fbe2c647554b07fb17616f1000cfd Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 20 Apr 2026 15:31:39 +0200 Subject: [PATCH 033/102] migrate standalone package DataPoint to Schema C nested data_point format --- .../DatadogTimeseries/Core/TimeseriesEventBuilder.swift | 2 +- .../DatadogTimeseries/Models/TimeseriesEvent.swift | 4 ++-- .../Fixtures/expected_cpu_batch1.json | 2 +- .../Fixtures/expected_cpu_batch2.json | 2 +- .../Fixtures/expected_memory_batch1.json | 2 +- .../Fixtures/expected_memory_batch2.json | 2 +- .../DatadogTimeseriesTests/TimeseriesEncoderTests.swift | 6 +++--- .../TimeseriesEventBuilderTests.swift | 4 ++-- .../TimeseriesEventModelTests.swift | 9 +++++---- DatadogTimeseries/output/deadband_cpu.ndjson | 1 + DatadogTimeseries/output/deadband_memory.ndjson | 1 + DatadogTimeseries/output/passthrough_cpu.ndjson | 2 ++ DatadogTimeseries/output/passthrough_memory.ndjson | 2 ++ DatadogTimeseries/output/window_cpu.ndjson | 1 + DatadogTimeseries/output/window_memory.ndjson | 1 + 15 files changed, 25 insertions(+), 16 deletions(-) create mode 100644 DatadogTimeseries/output/deadband_cpu.ndjson create mode 100644 DatadogTimeseries/output/deadband_memory.ndjson create mode 100644 DatadogTimeseries/output/passthrough_cpu.ndjson create mode 100644 DatadogTimeseries/output/passthrough_memory.ndjson create mode 100644 DatadogTimeseries/output/window_cpu.ndjson create mode 100644 DatadogTimeseries/output/window_memory.ndjson diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift index 0d23a47e8b..855884ec27 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift @@ -15,7 +15,7 @@ struct TimeseriesEventBuilder { let dataPoints = samples.map { sample in TimeseriesEvent.DataPoint( timestamp: sample.timestamp, - dataPointValue: sample.value + dataPoint: [name.rawValue: sample.value] ) } diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift index d7ba7c77a1..196e12a191 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift @@ -50,11 +50,11 @@ public struct TimeseriesEvent: Codable { public struct DataPoint: Codable { public let timestamp: Int64 - public let dataPointValue: Double + public let dataPoint: [String: Double] enum CodingKeys: String, CodingKey { case timestamp - case dataPointValue = "data_point_value" + case dataPoint = "data_point" } } } diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json index 2b7cfe0bef..bb22d767eb 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json @@ -1 +1 @@ -{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000001000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":12.5,"timestamp":1700000001000000000},{"data_point_value":14.2,"timestamp":1700000002000000000},{"data_point_value":11.8,"timestamp":1700000003000000000},{"data_point_value":16.3,"timestamp":1700000004000000000},{"data_point_value":13.7,"timestamp":1700000005000000000}],"end":1700000005000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","start":1700000001000000000},"type":"timeseries"} \ No newline at end of file +{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000001000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point":{"cpu_usage":12.5},"timestamp":1700000001000000000},{"data_point":{"cpu_usage":14.2},"timestamp":1700000002000000000},{"data_point":{"cpu_usage":11.8},"timestamp":1700000003000000000},{"data_point":{"cpu_usage":16.3},"timestamp":1700000004000000000},{"data_point":{"cpu_usage":13.7},"timestamp":1700000005000000000}],"end":1700000005000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","start":1700000001000000000},"type":"timeseries"} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json index 8be6322e13..ea8f16e249 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json @@ -1 +1 @@ -{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000006000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":18.1,"timestamp":1700000006000000000},{"data_point_value":22.4,"timestamp":1700000007000000000},{"data_point_value":19.6,"timestamp":1700000008000000000},{"data_point_value":15.9,"timestamp":1700000009000000000},{"data_point_value":13.2,"timestamp":1700000010000000000}],"end":1700000010000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","start":1700000006000000000},"type":"timeseries"} \ No newline at end of file +{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000006000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point":{"cpu_usage":18.1},"timestamp":1700000006000000000},{"data_point":{"cpu_usage":22.4},"timestamp":1700000007000000000},{"data_point":{"cpu_usage":19.6},"timestamp":1700000008000000000},{"data_point":{"cpu_usage":15.9},"timestamp":1700000009000000000},{"data_point":{"cpu_usage":13.2},"timestamp":1700000010000000000}],"end":1700000010000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","start":1700000006000000000},"type":"timeseries"} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json index bd1bf0b3f2..83df9545f1 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json @@ -1 +1 @@ -{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000001000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":31233300,"timestamp":1700000001000000000},{"data_point_value":31245500,"timestamp":1700000002000000000},{"data_point_value":31300800,"timestamp":1700000003000000000},{"data_point_value":31289100,"timestamp":1700000004000000000},{"data_point_value":31350000,"timestamp":1700000005000000000}],"end":1700000005000000000,"id":"00000000-0000-0000-0000-000000000000","name":"memory_usage","start":1700000001000000000},"type":"timeseries"} \ No newline at end of file +{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000001000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point":{"memory_usage":31233300},"timestamp":1700000001000000000},{"data_point":{"memory_usage":31245500},"timestamp":1700000002000000000},{"data_point":{"memory_usage":31300800},"timestamp":1700000003000000000},{"data_point":{"memory_usage":31289100},"timestamp":1700000004000000000},{"data_point":{"memory_usage":31350000},"timestamp":1700000005000000000}],"end":1700000005000000000,"id":"00000000-0000-0000-0000-000000000000","name":"memory_usage","start":1700000001000000000},"type":"timeseries"} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json index 98d8b1e89e..418183c1fe 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json @@ -1 +1 @@ -{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000006000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":31420200,"timestamp":1700000006000000000},{"data_point_value":31510400,"timestamp":1700000007000000000},{"data_point_value":31498700,"timestamp":1700000008000000000},{"data_point_value":31550300,"timestamp":1700000009000000000},{"data_point_value":31600100,"timestamp":1700000010000000000}],"end":1700000010000000000,"id":"00000000-0000-0000-0000-000000000000","name":"memory_usage","start":1700000006000000000},"type":"timeseries"} \ No newline at end of file +{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000006000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point":{"memory_usage":31420200},"timestamp":1700000006000000000},{"data_point":{"memory_usage":31510400},"timestamp":1700000007000000000},{"data_point":{"memory_usage":31498700},"timestamp":1700000008000000000},{"data_point":{"memory_usage":31550300},"timestamp":1700000009000000000},{"data_point":{"memory_usage":31600100},"timestamp":1700000010000000000}],"end":1700000010000000000,"id":"00000000-0000-0000-0000-000000000000","name":"memory_usage","start":1700000006000000000},"type":"timeseries"} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift index 09cbe1b6c1..482e33f48b 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift @@ -21,9 +21,9 @@ final class TimeseriesEncoderTests: XCTestCase { let json = String(data: data, encoding: .utf8)! XCTAssertTrue(json.contains("\"format_version\"")) - XCTAssertTrue(json.contains("\"data_point_value\"")) + XCTAssertTrue(json.contains("\"data_point\"")) XCTAssertFalse(json.contains("\"formatVersion\"")) - XCTAssertFalse(json.contains("\"dataPointValue\"")) + XCTAssertFalse(json.contains("\"dataPoint\"")) } func testProducesValidJSON() throws { @@ -62,7 +62,7 @@ final class TimeseriesEncoderTests: XCTestCase { start: 1_000_000_000, end: 2_000_000_000, data: [ - TimeseriesEvent.DataPoint(timestamp: 1_000_000_000, dataPointValue: 42), + TimeseriesEvent.DataPoint(timestamp: 1_000_000_000, dataPoint: ["memory_usage": 42]), ] ) ) diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift index 84fc14f4b7..aab8e74d90 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift @@ -69,9 +69,9 @@ final class TimeseriesEventBuilderTests: XCTestCase { let event = builder.build(samples: samples, name: .memoryUsage, eventId: "id") XCTAssertEqual(event.timeseries.data[0].timestamp, 1000) - XCTAssertEqual(event.timeseries.data[0].dataPointValue, 42.5) + XCTAssertEqual(event.timeseries.data[0].dataPoint["memory_usage"], 42.5) XCTAssertEqual(event.timeseries.data[1].timestamp, 2000) - XCTAssertEqual(event.timeseries.data[1].dataPointValue, 99.9) + XCTAssertEqual(event.timeseries.data[1].dataPoint["memory_usage"], 99.9) } func testNilServiceAndVersion() { diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift index 7356e569ce..a7c56feefb 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift @@ -18,8 +18,8 @@ final class TimeseriesEventModelTests: XCTestCase { start: 1773055068831000000, end: 1773055082916000000, data: [ - TimeseriesEvent.DataPoint(timestamp: 1773055068831000000, dataPointValue: 38052032), - TimeseriesEvent.DataPoint(timestamp: 1773055069917000000, dataPointValue: 37970112), + TimeseriesEvent.DataPoint(timestamp: 1773055068831000000, dataPoint: ["memory_usage": 38052032]), + TimeseriesEvent.DataPoint(timestamp: 1773055069917000000, dataPoint: ["memory_usage": 37970112]), ] ) ) @@ -57,7 +57,8 @@ final class TimeseriesEventModelTests: XCTestCase { let dataPoints = try XCTUnwrap(ts["data"] as? [[String: Any]]) XCTAssertEqual(dataPoints.count, 2) XCTAssertEqual(dataPoints[0]["timestamp"] as? Int64, 1773055068831000000) - XCTAssertEqual(dataPoints[0]["data_point_value"] as? Double, 38052032) + let dp0 = try XCTUnwrap(dataPoints[0]["data_point"] as? [String: Any]) + XCTAssertEqual(dp0["memory_usage"] as? Double, 38052032) } func testTimeseriesEventOmitsNilServiceAndVersion() throws { @@ -76,7 +77,7 @@ final class TimeseriesEventModelTests: XCTestCase { start: 1000000000, end: 2000000000, data: [ - TimeseriesEvent.DataPoint(timestamp: 1000000000, dataPointValue: 55.3), + TimeseriesEvent.DataPoint(timestamp: 1000000000, dataPoint: ["cpu_usage": 55.3]), ] ) ) diff --git a/DatadogTimeseries/output/deadband_cpu.ndjson b/DatadogTimeseries/output/deadband_cpu.ndjson new file mode 100644 index 0000000000..d700cb29b5 --- /dev/null +++ b/DatadogTimeseries/output/deadband_cpu.ndjson @@ -0,0 +1 @@ +{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000001000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":5,"timestamp":1700000001000000000},{"data_point_value":8.5,"timestamp":1700000031000000000}],"end":1700000031000000000,"id":"42e7e1d8-4418-41c0-a500-1cdc32619cc0","name":"cpu_usage","start":1700000001000000000},"type":"timeseries"} diff --git a/DatadogTimeseries/output/deadband_memory.ndjson b/DatadogTimeseries/output/deadband_memory.ndjson new file mode 100644 index 0000000000..ed50efc1ad --- /dev/null +++ b/DatadogTimeseries/output/deadband_memory.ndjson @@ -0,0 +1 @@ +{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000001000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":31000500,"timestamp":1700000001000000000},{"data_point_value":32512300,"timestamp":1700000012000000000},{"data_point_value":34530600,"timestamp":1700000030000000000},{"data_point_value":34762100,"timestamp":1700000060000000000}],"end":1700000060000000000,"id":"952c53c4-bcaa-40a8-8d33-2fe8ee6a0582","name":"memory_usage","start":1700000001000000000},"type":"timeseries"} diff --git a/DatadogTimeseries/output/passthrough_cpu.ndjson b/DatadogTimeseries/output/passthrough_cpu.ndjson new file mode 100644 index 0000000000..f404d1148d --- /dev/null +++ b/DatadogTimeseries/output/passthrough_cpu.ndjson @@ -0,0 +1,2 @@ +{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000001000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":5,"timestamp":1700000001000000000},{"data_point_value":7.5,"timestamp":1700000002000000000},{"data_point_value":6,"timestamp":1700000003000000000},{"data_point_value":8.5,"timestamp":1700000004000000000},{"data_point_value":5.5,"timestamp":1700000005000000000},{"data_point_value":9,"timestamp":1700000006000000000},{"data_point_value":5,"timestamp":1700000007000000000},{"data_point_value":7.5,"timestamp":1700000008000000000},{"data_point_value":6,"timestamp":1700000009000000000},{"data_point_value":8.5,"timestamp":1700000010000000000},{"data_point_value":5.5,"timestamp":1700000011000000000},{"data_point_value":9,"timestamp":1700000012000000000},{"data_point_value":5,"timestamp":1700000013000000000},{"data_point_value":7.5,"timestamp":1700000014000000000},{"data_point_value":65,"timestamp":1700000015000000000},{"data_point_value":72,"timestamp":1700000016000000000},{"data_point_value":58,"timestamp":1700000017000000000},{"data_point_value":6,"timestamp":1700000018000000000},{"data_point_value":8.5,"timestamp":1700000019000000000},{"data_point_value":5.5,"timestamp":1700000020000000000},{"data_point_value":9,"timestamp":1700000021000000000},{"data_point_value":5,"timestamp":1700000022000000000},{"data_point_value":7.5,"timestamp":1700000023000000000},{"data_point_value":6,"timestamp":1700000024000000000},{"data_point_value":8.5,"timestamp":1700000025000000000},{"data_point_value":5.5,"timestamp":1700000026000000000},{"data_point_value":9,"timestamp":1700000027000000000},{"data_point_value":5,"timestamp":1700000028000000000},{"data_point_value":7.5,"timestamp":1700000029000000000},{"data_point_value":6,"timestamp":1700000030000000000}],"end":1700000030000000000,"id":"7161f959-327e-4c39-a990-c585462a0610","name":"cpu_usage","start":1700000001000000000},"type":"timeseries"} +{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000031000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":8.5,"timestamp":1700000031000000000},{"data_point_value":5.5,"timestamp":1700000032000000000},{"data_point_value":9,"timestamp":1700000033000000000},{"data_point_value":5,"timestamp":1700000034000000000},{"data_point_value":80,"timestamp":1700000035000000000},{"data_point_value":75,"timestamp":1700000036000000000},{"data_point_value":68,"timestamp":1700000037000000000},{"data_point_value":7.5,"timestamp":1700000038000000000},{"data_point_value":6,"timestamp":1700000039000000000},{"data_point_value":8.5,"timestamp":1700000040000000000},{"data_point_value":5.5,"timestamp":1700000041000000000},{"data_point_value":9,"timestamp":1700000042000000000},{"data_point_value":5,"timestamp":1700000043000000000},{"data_point_value":7.5,"timestamp":1700000044000000000},{"data_point_value":6,"timestamp":1700000045000000000},{"data_point_value":8.5,"timestamp":1700000046000000000},{"data_point_value":5.5,"timestamp":1700000047000000000},{"data_point_value":9,"timestamp":1700000048000000000},{"data_point_value":5,"timestamp":1700000049000000000},{"data_point_value":7.5,"timestamp":1700000050000000000},{"data_point_value":6,"timestamp":1700000051000000000},{"data_point_value":8.5,"timestamp":1700000052000000000},{"data_point_value":5.5,"timestamp":1700000053000000000},{"data_point_value":9,"timestamp":1700000054000000000},{"data_point_value":55,"timestamp":1700000055000000000},{"data_point_value":62,"timestamp":1700000056000000000},{"data_point_value":50,"timestamp":1700000057000000000},{"data_point_value":5,"timestamp":1700000058000000000},{"data_point_value":7.5,"timestamp":1700000059000000000},{"data_point_value":6,"timestamp":1700000060000000000}],"end":1700000060000000000,"id":"8dcbe4cf-3278-41d3-91c4-f682f7e7bdd6","name":"cpu_usage","start":1700000031000000000},"type":"timeseries"} diff --git a/DatadogTimeseries/output/passthrough_memory.ndjson b/DatadogTimeseries/output/passthrough_memory.ndjson new file mode 100644 index 0000000000..1dcbf1d6a9 --- /dev/null +++ b/DatadogTimeseries/output/passthrough_memory.ndjson @@ -0,0 +1,2 @@ +{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000001000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":31000500,"timestamp":1700000001000000000},{"data_point_value":31001300,"timestamp":1700000002000000000},{"data_point_value":31002500,"timestamp":1700000003000000000},{"data_point_value":31004000,"timestamp":1700000004000000000},{"data_point_value":31004700,"timestamp":1700000005000000000},{"data_point_value":31005700,"timestamp":1700000006000000000},{"data_point_value":31007700,"timestamp":1700000007000000000},{"data_point_value":31008300,"timestamp":1700000008000000000},{"data_point_value":31008800,"timestamp":1700000009000000000},{"data_point_value":31009600,"timestamp":1700000010000000000},{"data_point_value":31010800,"timestamp":1700000011000000000},{"data_point_value":32512300,"timestamp":1700000012000000000},{"data_point_value":32513000,"timestamp":1700000013000000000},{"data_point_value":32514000,"timestamp":1700000014000000000},{"data_point_value":32516000,"timestamp":1700000015000000000},{"data_point_value":32516600,"timestamp":1700000016000000000},{"data_point_value":32517100,"timestamp":1700000017000000000},{"data_point_value":32517900,"timestamp":1700000018000000000},{"data_point_value":32519100,"timestamp":1700000019000000000},{"data_point_value":32520600,"timestamp":1700000020000000000},{"data_point_value":32521300,"timestamp":1700000021000000000},{"data_point_value":32522300,"timestamp":1700000022000000000},{"data_point_value":32524300,"timestamp":1700000023000000000},{"data_point_value":32524900,"timestamp":1700000024000000000},{"data_point_value":32525400,"timestamp":1700000025000000000},{"data_point_value":32526200,"timestamp":1700000026000000000},{"data_point_value":32527400,"timestamp":1700000027000000000},{"data_point_value":32528900,"timestamp":1700000028000000000},{"data_point_value":32529600,"timestamp":1700000029000000000},{"data_point_value":34530600,"timestamp":1700000030000000000}],"end":1700000030000000000,"id":"3f409177-e549-4061-a6f2-6b04e54b6378","name":"memory_usage","start":1700000001000000000},"type":"timeseries"} +{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000031000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":34532600,"timestamp":1700000031000000000},{"data_point_value":34533200,"timestamp":1700000032000000000},{"data_point_value":34533700,"timestamp":1700000033000000000},{"data_point_value":34534500,"timestamp":1700000034000000000},{"data_point_value":34535700,"timestamp":1700000035000000000},{"data_point_value":34537200,"timestamp":1700000036000000000},{"data_point_value":34537900,"timestamp":1700000037000000000},{"data_point_value":34538900,"timestamp":1700000038000000000},{"data_point_value":34540900,"timestamp":1700000039000000000},{"data_point_value":33741500,"timestamp":1700000040000000000},{"data_point_value":33742000,"timestamp":1700000041000000000},{"data_point_value":33742800,"timestamp":1700000042000000000},{"data_point_value":33744000,"timestamp":1700000043000000000},{"data_point_value":33745500,"timestamp":1700000044000000000},{"data_point_value":33746200,"timestamp":1700000045000000000},{"data_point_value":33747200,"timestamp":1700000046000000000},{"data_point_value":33749200,"timestamp":1700000047000000000},{"data_point_value":33749800,"timestamp":1700000048000000000},{"data_point_value":33750300,"timestamp":1700000049000000000},{"data_point_value":34751100,"timestamp":1700000050000000000},{"data_point_value":34752300,"timestamp":1700000051000000000},{"data_point_value":34753800,"timestamp":1700000052000000000},{"data_point_value":34754500,"timestamp":1700000053000000000},{"data_point_value":34755500,"timestamp":1700000054000000000},{"data_point_value":34757500,"timestamp":1700000055000000000},{"data_point_value":34758100,"timestamp":1700000056000000000},{"data_point_value":34758600,"timestamp":1700000057000000000},{"data_point_value":34759400,"timestamp":1700000058000000000},{"data_point_value":34760600,"timestamp":1700000059000000000},{"data_point_value":34762100,"timestamp":1700000060000000000}],"end":1700000060000000000,"id":"c921ca6c-c752-45d4-a9a3-1e60ab2851f9","name":"memory_usage","start":1700000031000000000},"type":"timeseries"} diff --git a/DatadogTimeseries/output/window_cpu.ndjson b/DatadogTimeseries/output/window_cpu.ndjson new file mode 100644 index 0000000000..6a982deef3 --- /dev/null +++ b/DatadogTimeseries/output/window_cpu.ndjson @@ -0,0 +1 @@ +{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000001000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":8.5,"timestamp":1700000001000000000},{"data_point_value":9,"timestamp":1700000006000000000},{"data_point_value":65,"timestamp":1700000011000000000},{"data_point_value":72,"timestamp":1700000016000000000},{"data_point_value":9,"timestamp":1700000021000000000},{"data_point_value":9,"timestamp":1700000026000000000},{"data_point_value":80,"timestamp":1700000031000000000},{"data_point_value":75,"timestamp":1700000036000000000},{"data_point_value":9,"timestamp":1700000041000000000},{"data_point_value":9,"timestamp":1700000046000000000},{"data_point_value":55,"timestamp":1700000051000000000},{"data_point_value":62,"timestamp":1700000056000000000}],"end":1700000056000000000,"id":"12b169d2-6515-4187-8146-4571d286b88f","name":"cpu_usage","start":1700000001000000000},"type":"timeseries"} diff --git a/DatadogTimeseries/output/window_memory.ndjson b/DatadogTimeseries/output/window_memory.ndjson new file mode 100644 index 0000000000..7e7e31a033 --- /dev/null +++ b/DatadogTimeseries/output/window_memory.ndjson @@ -0,0 +1 @@ +{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000001000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":31004700,"timestamp":1700000001000000000},{"data_point_value":31009600,"timestamp":1700000006000000000},{"data_point_value":32516000,"timestamp":1700000011000000000},{"data_point_value":32520600,"timestamp":1700000016000000000},{"data_point_value":32525400,"timestamp":1700000021000000000},{"data_point_value":34530600,"timestamp":1700000026000000000},{"data_point_value":34535700,"timestamp":1700000031000000000},{"data_point_value":34540900,"timestamp":1700000036000000000},{"data_point_value":33746200,"timestamp":1700000041000000000},{"data_point_value":34751100,"timestamp":1700000046000000000},{"data_point_value":34757500,"timestamp":1700000051000000000},{"data_point_value":34762100,"timestamp":1700000056000000000}],"end":1700000056000000000,"id":"b80b567a-a2fe-46b0-963e-ba93aced3e8c","name":"memory_usage","start":1700000001000000000},"type":"timeseries"} From 9a0fa9dffe42155029975572272a8534f0cdbb33 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 20 Apr 2026 15:57:31 +0200 Subject: [PATCH 034/102] add TimeseriesSessionCollector and wire it into RUM session lifecycle --- Datadog/Datadog.xcodeproj/project.pbxproj | 335 ++++++++++++++++++ DatadogRUM/Sources/Feature/RUMFeature.swift | 9 + DatadogRUM/Sources/RUMConfiguration.swift | 11 + .../Scopes/RUMScopeDependencies.swift | 3 + .../RUMMonitor/Scopes/RUMSessionScope.swift | 10 +- .../TimeseriesSessionCollector.swift | 218 ++++++++++++ .../Scopes/RUMSessionScopeTests.swift | 98 +++++ .../TimeseriesSessionCollectorTests.swift | 247 +++++++++++++ .../Mocks/DatadogRUM/RUMFeatureMocks.swift | 2 + 9 files changed, 932 insertions(+), 1 deletion(-) create mode 100644 DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift create mode 100644 DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index a6ec8d0f59..b408a78d22 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -747,6 +747,9 @@ 61DA8CB828647A500074A606 /* InternalLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DA8CB728647A500074A606 /* InternalLoggerTests.swift */; }; 61DB33B225DEDFC200F7EA71 /* CustomObjcViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 61DB33B125DEDFC200F7EA71 /* CustomObjcViewController.m */; }; 61DCC8472C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DCC8462C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift */; }; + 61DCC8482C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DCC8462C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift */; }; + B51668F3A97DEAC2917B0F44 /* TimeseriesSessionCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */; }; + 21A5D61036FA5C28E71ED824 /* TimeseriesSessionCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */; }; 61DCC84E2C071DCD00CB59E5 /* TelemetryInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DCC84D2C071DCD00CB59E5 /* TelemetryInterceptor.swift */; }; 61E262D42EB2592C0041E70F /* DatadogFlags.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5BA8C2ED2E784B3C00B1DA80 /* DatadogFlags.framework */; }; 61E5333824B84EE2003D6C4E /* DebugRUMViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E5333724B84EE2003D6C4E /* DebugRUMViewController.swift */; }; @@ -1047,6 +1050,94 @@ D233E8002E4B588B00E7CFDE /* dd_pprof.h in Headers */ = {isa = PBXBuildFile; fileRef = D233E7FE2E4B588B00E7CFDE /* dd_pprof.h */; settings = {ATTRIBUTES = (Private, ); }; }; D233E8022E4B653F00E7CFDE /* dd_pprof.cpp in Sources */ = {isa = PBXBuildFile; fileRef = D233E8012E4B653F00E7CFDE /* dd_pprof.cpp */; }; D234613228B7713000055D4C /* FeatureContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D234613028B7712F00055D4C /* FeatureContextTests.swift */; }; + D23F8E5229DDCD28001CFAE8 /* UIViewControllerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F3CDA2251118FB00C816E5 /* UIViewControllerHandler.swift */; }; + D23F8E5329DDCD28001CFAE8 /* RUMCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63A24BF1A4B008053F2 /* RUMCommand.swift */; }; + D23F8E5429DDCD28001CFAE8 /* ValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611529A425E3DD51004F740E /* ValuePublisher.swift */; }; + D23F8E5529DDCD28001CFAE8 /* RUMEventSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61122ED325B1B84D00F9C7F5 /* RUMEventSanitizer.swift */; }; + D23F8E5729DDCD28001CFAE8 /* RUMScopeDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6122514727FDFF82004F5AE4 /* RUMScopeDependencies.swift */; }; + D23F8E5829DDCD28001CFAE8 /* VitalMemoryReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BBBCB0265E71C600943419 /* VitalMemoryReader.swift */; }; + D23F8EFF29DDCD28001CFAE8 /* TimeseriesSessionCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A2B3C4D5E6F7800000001 /* TimeseriesSessionCollector.swift */; }; + D23F8E5929DDCD28001CFAE8 /* WebViewEventReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2CBC26A294383F200134409 /* WebViewEventReceiver.swift */; }; + D23F8E5A29DDCD28001CFAE8 /* RUMResourceScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61494CB024C839460082C633 /* RUMResourceScope.swift */; }; + D23F8E5C29DDCD28001CFAE8 /* RUMApplicationScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63D24BF1B91008053F2 /* RUMApplicationScope.swift */; }; + D23F8E5D29DDCD28001CFAE8 /* SwiftUIViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D249859F2728042200B4F72D /* SwiftUIViewModifier.swift */; }; + D23F8E5E29DDCD28001CFAE8 /* VitalInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3FC3C0626526EFF00DEED9E /* VitalInfo.swift */; }; + D23F8E5F29DDCD28001CFAE8 /* UIApplicationSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6141014E251A57AF00E3C2D9 /* UIApplicationSwizzler.swift */; }; + D23F8E6029DDCD28001CFAE8 /* PerformanceMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = E179FB4D28F80A6400CC2698 /* PerformanceMetric.swift */; }; + D23F8E6129DDCD28001CFAE8 /* RUMConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D25FF2EA29CC6D6F0063802D /* RUMConfiguration.swift */; }; + D23F8E6429DDCD28001CFAE8 /* SwiftUIViewHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D24985A12728048B00B4F72D /* SwiftUIViewHandler.swift */; }; + D23F8E6529DDCD28001CFAE8 /* RUMFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = D25FF2E729CC6B680063802D /* RUMFeature.swift */; }; + D23F8E6629DDCD28001CFAE8 /* RUMDebugging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B22E5924F3E6B700DC26D2 /* RUMDebugging.swift */; }; + D23F8E6729DDCD28001CFAE8 /* RUMUUID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618DCFD624C7265300589570 /* RUMUUID.swift */; }; + D23F8E6829DDCD28001CFAE8 /* UIKitExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615F197B25B5A64B00BE14B5 /* UIKitExtensions.swift */; }; + D23F8E6929DDCD28001CFAE8 /* RUMContextAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2CBC26D294395A300134409 /* RUMContextAttributes.swift */; }; + D23F8E6B29DDCD28001CFAE8 /* RUMMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E5333524B84B43003D6C4E /* RUMMonitor.swift */; }; + D23F8E6C29DDCD28001CFAE8 /* RUMContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6156CB8D24DDA1B5008CB2B2 /* RUMContextProvider.swift */; }; + D23F8E6D29DDCD28001CFAE8 /* ViewIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF9A4425AC5DEA001058CC /* ViewIdentifier.swift */; }; + D23F8E6E29DDCD28001CFAE8 /* RUMViewsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2EFF3D22731822A00D09F33 /* RUMViewsHandler.swift */; }; + D23F8E6F29DDCD28001CFAE8 /* RequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D25FF2ED29CC73240063802D /* RequestBuilder.swift */; }; + D23F8E7029DDCD28001CFAE8 /* URLSessionRUMResourcesHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BCB11E29D30AF000737A9A /* URLSessionRUMResourcesHandler.swift */; }; + D23F8E7129DDCD28001CFAE8 /* RUMEventBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF281D24B8968D000B3D9B /* RUMEventBuilder.swift */; }; + D23F8E7229DDCD28001CFAE8 /* ErrorMessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D215ED6A29D2E1080046B721 /* ErrorMessageReceiver.swift */; }; + D23F8E7329DDCD28001CFAE8 /* SwiftUIActionModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29D5A4C273BF8B400A687C1 /* SwiftUIActionModifier.swift */; }; + D23F8E7429DDCD28001CFAE8 /* RUMCommandSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616CCE12250A1868009FED46 /* RUMCommandSubscriber.swift */; }; + D23F8E7529DDCD28001CFAE8 /* RUMUserActionScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61494CB924CB126F0082C633 /* RUMUserActionScope.swift */; }; + D23F8E7629DDCD28001CFAE8 /* RUMConnectivityInfoProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B0A4E24EBDC6B00A2A780 /* RUMConnectivityInfoProvider.swift */; }; + D23F8E7729DDCD28001CFAE8 /* UIKitRUMViewsPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F3CDA62512144600C816E5 /* UIKitRUMViewsPredicate.swift */; }; + D23F8E7829DDCD28001CFAE8 /* LongTaskObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E359F4D26CD518D001E25E9 /* LongTaskObserver.swift */; }; + D23F8E7A29DDCD28001CFAE8 /* SessionReplayDependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615950ED291C058F00470E0C /* SessionReplayDependency.swift */; }; + D23F8E7C29DDCD28001CFAE8 /* RUMOffViewEventsHandlingRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A614E7276B2BD000A06CE7 /* RUMOffViewEventsHandlingRule.swift */; }; + D23F8E7D29DDCD28001CFAE8 /* RUMScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63624BF191F008053F2 /* RUMScope.swift */; }; + D23F8E7E29DDCD28001CFAE8 /* CrashReportReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D236BE2729520FED00676E67 /* CrashReportReceiver.swift */; }; + D23F8E7F29DDCD28001CFAE8 /* UIViewControllerSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F3CDA42511190E00C816E5 /* UIViewControllerSwizzler.swift */; }; + D23F8E8029DDCD28001CFAE8 /* VitalInfoSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E9973F0268DF69500D8059B /* VitalInfoSampler.swift */; }; + D23F8E8129DDCD28001CFAE8 /* RUMViewScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C21124C5951400C0321C /* RUMViewScope.swift */; }; + D23F8E8229DDCD28001CFAE8 /* RUMSessionScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C20624C098FC00C0321C /* RUMSessionScope.swift */; }; + D23F8E8329DDCD28001CFAE8 /* RUMUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B0A4A24EBC43D00A2A780 /* RUMUser.swift */; }; + D23F8E8429DDCD28001CFAE8 /* UIKitRUMUserActionsPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F637AED12697404200516F32 /* UIKitRUMUserActionsPredicate.swift */; }; + D23F8E8529DDCD28001CFAE8 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FCA238271D896E0020286F /* SwiftUIExtensions.swift */; }; + D23F8E8629DDCD28001CFAE8 /* RUMDataModelsMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618715F824DC13A100FC0F69 /* RUMDataModelsMapping.swift */; }; + D23F8E8729DDCD28001CFAE8 /* RUMInstrumentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616CCE15250A467E009FED46 /* RUMInstrumentation.swift */; }; + D23F8E8829DDCD28001CFAE8 /* VitalCPUReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC8B5D92668197B000F7529 /* VitalCPUReader.swift */; }; + D23F8E8A29DDCD28001CFAE8 /* RUMEventsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 613E81EF25A740140084B751 /* RUMEventsMapper.swift */; }; + D23F8E8B29DDCD28001CFAE8 /* RUMContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63824BF19B4008053F2 /* RUMContext.swift */; }; + D23F8E8E29DDCD28001CFAE8 /* UIEventCommandFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6141015A251A601D00E3C2D9 /* UIEventCommandFactory.swift */; }; + D23F8E8F29DDCD28001CFAE8 /* RUMUUIDGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618DCFD824C7269500589570 /* RUMUUIDGenerator.swift */; }; + D23F8EA029DDCD38001CFAE8 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A614E9276B9D4C00A06CE7 /* RUMOffViewEventsHandlingRuleTests.swift */; }; + D23F8EA229DDCD38001CFAE8 /* RUMSessionScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C20824C0C75500C0321C /* RUMSessionScopeTests.swift */; }; + D23F8EA329DDCD38001CFAE8 /* RUMUserActionScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617CD0DC24CEDDD300B0B557 /* RUMUserActionScopeTests.swift */; }; + D23F8EA629DDCD38001CFAE8 /* RUMDeviceInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FD9FCE28534EBD00214BD9 /* RUMDeviceInfoTests.swift */; }; + D23F8EA829DDCD38001CFAE8 /* RUMResourceScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61494CB424C864680082C633 /* RUMResourceScopeTests.swift */; }; + D23F8EAC29DDCD38001CFAE8 /* RUMDataModelsMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618715FB24DC5F0800FC0F69 /* RUMDataModelsMappingTests.swift */; }; + D23F8EAD29DDCD38001CFAE8 /* RUMEventBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF282024B8981D000B3D9B /* RUMEventBuilderTests.swift */; }; + D23F8EAE29DDCD38001CFAE8 /* DDTAssertValidRUMUUID.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29A9FCB29DDBCC5005C54A4 /* DDTAssertValidRUMUUID.swift */; }; + D23F8EAF29DDCD38001CFAE8 /* RUMScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618DCFDE24C75FD300589570 /* RUMScopeTests.swift */; }; + D23F8EB029DDCD38001CFAE8 /* SessionReplayDependencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615950EA291C029700470E0C /* SessionReplayDependencyTests.swift */; }; + D23F8EB129DDCD38001CFAE8 /* RUMViewScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6198D27024C6E3B700493501 /* RUMViewScopeTests.swift */; }; + D23F8EB229DDCD38001CFAE8 /* ValuePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611529AD25E3E429004F740E /* ValuePublisherTests.swift */; }; + D23F8EB329DDCD38001CFAE8 /* ErrorMessageReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21C26ED28AFB65B005DD405 /* ErrorMessageReceiverTests.swift */; }; + D23F8EB429DDCD38001CFAE8 /* RUMApplicationScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617B953F24BF4DB300E6F443 /* RUMApplicationScopeTests.swift */; }; + D23F8EB629DDCD38001CFAE8 /* RUMViewsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29889C72734136200A4D1A9 /* RUMViewsHandlerTests.swift */; }; + D23F8EB829DDCD38001CFAE8 /* RUMActionsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615C3195251DD5080018781C /* RUMActionsHandlerTests.swift */; }; + D23F8EBA29DDCD38001CFAE8 /* ViewIdentifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C1510C25AC8C1B00362D4B /* ViewIdentifierTests.swift */; }; + D23F8EBE29DDCD38001CFAE8 /* WebViewEventReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E53889B2773C4B300A7DC42 /* WebViewEventReceiverTests.swift */; }; + D23F8EBF29DDCD38001CFAE8 /* URLSessionRUMResourcesHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BCB12129D34A5F00737A9A /* URLSessionRUMResourcesHandlerTests.swift */; }; + D23F8EC029DDCD38001CFAE8 /* RUMEventSanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61122EED25B1D75B00F9C7F5 /* RUMEventSanitizerTests.swift */; }; + D23F8EC129DDCD38001CFAE8 /* RUMEventsMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 613E81F625A743600084B751 /* RUMEventsMapperTests.swift */; }; + D23F8EC429DDCD38001CFAE8 /* RUMCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618715F624DC0CDE00FC0F69 /* RUMCommandTests.swift */; }; + D23F8EC629DDCD38001CFAE8 /* TestUtilities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D257953E298ABA65008A1BE5 /* TestUtilities.framework */; }; + D23F8EC729DDCD38001CFAE8 /* DatadogRUM.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D29A9F3429DD84AA005C54A4 /* DatadogRUM.framework */; }; + D23F8ECE29DDCD53001CFAE8 /* DatadogInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2DA2385298D57AA00C6C7E6 /* DatadogInternal.framework */; }; + D240680827CE6C9E00C04F44 /* ConsoleOutputInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61441C902461A648003D8BB8 /* ConsoleOutputInterceptor.swift */; }; + D240681E27CE6C9E00C04F44 /* ExampleAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61441C0424616DE9003D8BB8 /* ExampleAppDelegate.swift */; }; + D240682B27CE6C9E00C04F44 /* UIButton+Disabling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61441C912461A648003D8BB8 /* UIButton+Disabling.swift */; }; + D240682D27CE6C9E00C04F44 /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614CADD62510BAC000B93D2D /* Environment.swift */; }; + D240683D27CE6C9E00C04F44 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 61441C0D24616DEC003D8BB8 /* Assets.xcassets */; }; + D240685527CF5D0100C04F44 /* DatadogCore.framework in ⚙️ Embed Framework Dependencies */ = {isa = PBXBuildFile; fileRef = D2CB6ED127C50EAE00A62B57 /* DatadogCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D240685927CF5D0100C04F44 /* DatadogCrashReporting.framework in ⚙️ Embed Framework Dependencies */ = {isa = PBXBuildFile; fileRef = D2CB6FD127C5348200A62B57 /* DatadogCrashReporting.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D240686827CF642900C04F44 /* SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61776D4D273E6D9F00F93802 /* SwiftUI.swift */; }; + D240687127CF971C00C04F44 /* DatadogCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2CB6ED127C50EAE00A62B57 /* DatadogCore.framework */; }; + D240687227CF971C00C04F44 /* DatadogCrashReporting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2CB6FD127C5348200A62B57 /* DatadogCrashReporting.framework */; }; D240687B27CF982C00C04F44 /* DatadogCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61133B82242393DE00786299 /* DatadogCore.framework */; }; D240687C27CF982C00C04F44 /* DatadogCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 61133B82242393DE00786299 /* DatadogCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D240687D27CF982D00C04F44 /* DatadogCrashReporting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61B7885425C180CB002675B5 /* DatadogCrashReporting.framework */; }; @@ -1135,6 +1226,7 @@ D29A9F5929DD85BB005C54A4 /* RUMCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63A24BF1A4B008053F2 /* RUMCommand.swift */; }; D29A9F5A29DD85BB005C54A4 /* RUMScopeDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6122514727FDFF82004F5AE4 /* RUMScopeDependencies.swift */; }; D29A9F5B29DD85BB005C54A4 /* VitalMemoryReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BBBCB0265E71C600943419 /* VitalMemoryReader.swift */; }; + D29A9FFF29DD85BB005C54A4 /* TimeseriesSessionCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A2B3C4D5E6F7800000001 /* TimeseriesSessionCollector.swift */; }; D29A9F5C29DD85BB005C54A4 /* RUMSessionScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C20624C098FC00C0321C /* RUMSessionScope.swift */; }; D29A9F5D29DD85BB005C54A4 /* RUMCommandSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616CCE12250A1868009FED46 /* RUMCommandSubscriber.swift */; }; D29A9F5E29DD85BB005C54A4 /* UIKitRUMViewsPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F3CDA62512144600C816E5 /* UIKitRUMViewsPredicate.swift */; }; @@ -2875,6 +2967,8 @@ AA0001012A000007000A0001 /* UIScrollViewSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScrollViewSwizzlerTests.swift; sourceTree = ""; }; AB7890CD1234EF567890ABCD /* CALayerSnapshotOcclusionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CALayerSnapshotOcclusionTests.swift; sourceTree = ""; }; B3BBBCB0265E71C600943419 /* VitalMemoryReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VitalMemoryReader.swift; sourceTree = ""; }; + BB1A2B3C4D5E6F7800000001 /* TimeseriesSessionCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollector.swift; sourceTree = ""; }; + 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollectorTests.swift; sourceTree = ""; }; B3BBBCBB265E71D100943419 /* VitalMemoryReaderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VitalMemoryReaderTests.swift; sourceTree = ""; }; B3E46CAA2D91B3A400BABF66 /* NetworkContextProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkContextProvider.swift; sourceTree = ""; }; B3E46CAD2D91B3FC00BABF66 /* NetworkContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkContext.swift; sourceTree = ""; }; @@ -6176,6 +6270,22 @@ path = RUMVitals; sourceTree = ""; }; + BB1A2B3C4D5E6F7800000002 /* Timeseries */ = { + isa = PBXGroup; + children = ( + BB1A2B3C4D5E6F7800000001 /* TimeseriesSessionCollector.swift */, + ); + path = Timeseries; + sourceTree = ""; + }; + BD113E89E74339BD1ADB0EC3 /* Timeseries */ = { + isa = PBXGroup; + children = ( + 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */, + ); + path = Timeseries; + sourceTree = ""; + }; B3FC3C1226526F4100DEED9E /* RUMVitals */ = { isa = PBXGroup; children = ( @@ -6746,6 +6856,7 @@ 61C3E63124BF143C008053F2 /* RUMMonitor */, B3FC3C0426526EE900DEED9E /* RUMVitals */, 613E81EE25A73FB90084B751 /* Scrubbing */, + BB1A2B3C4D5E6F7800000002 /* Timeseries */, 6174D60E2BFDEA1F00EC7469 /* SDKMetrics */, D29A9F8B29DD860A005C54A4 /* Utils */, 618DCFD524C7264100589570 /* UUIDs */, @@ -6775,6 +6886,7 @@ 617B953B24BF4D7300E6F443 /* RUMMonitor */, 613E81F525A743470084B751 /* Scrubbing */, 6174D6182BFE447600EC7469 /* SDKMetrics */, + BD113E89E74339BD1ADB0EC3 /* Timeseries */, 61411B0E24EC15940012EAB2 /* Utils */, 67B718E85203992292E96407 /* Heatmaps */, ); @@ -9074,6 +9186,227 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D23F8E5129DDCD28001CFAE8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6167E6D42B7F8B3300C3CA2D /* AppHangsMonitor.swift in Sources */, + 615E2B8F2D39444300D85243 /* ViewEndedController.swift in Sources */, + D23F8E5229DDCD28001CFAE8 /* UIViewControllerHandler.swift in Sources */, + D23F8E5329DDCD28001CFAE8 /* RUMCommand.swift in Sources */, + D23F8E5429DDCD28001CFAE8 /* ValuePublisher.swift in Sources */, + D23F8E5529DDCD28001CFAE8 /* RUMEventSanitizer.swift in Sources */, + D23F8E5729DDCD28001CFAE8 /* RUMScopeDependencies.swift in Sources */, + D23F8E5829DDCD28001CFAE8 /* VitalMemoryReader.swift in Sources */, + D23F8EFF29DDCD28001CFAE8 /* TimeseriesSessionCollector.swift in Sources */, + 5B1D02852E8EB78800AB2391 /* FlagEvaluationReceiver.swift in Sources */, + 962900252D8351AB008DFE39 /* TopLevelReflector.swift in Sources */, + 6194B9342BB451DB00179430 /* FatalAppHangsHandler.swift in Sources */, + D23F8E5929DDCD28001CFAE8 /* WebViewEventReceiver.swift in Sources */, + 265496D32D81C5B10094B6E2 /* RUMAccount.swift in Sources */, + D253EE972B988CA90010B589 /* ViewCache.swift in Sources */, + 11EA5C252DC288DD00E8DFA2 /* RUM+objc.swift in Sources */, + D23F8E5A29DDCD28001CFAE8 /* RUMResourceScope.swift in Sources */, + D23F8E5C29DDCD28001CFAE8 /* RUMApplicationScope.swift in Sources */, + 3CFF4F982C09E64C006F191D /* WatchdogTerminationMonitor.swift in Sources */, + 61193AAF2CB54C7300C3CDF5 /* RUMActionsHandler.swift in Sources */, + 86DE9C5F2EB22417006DADE7 /* GraphQLResponse.swift in Sources */, + 114FFDE82E0031BA00330C91 /* SwiftUIRUMViewsPredicate+objc.swift in Sources */, + D23F8E5D29DDCD28001CFAE8 /* SwiftUIViewModifier.swift in Sources */, + D23F8E5E29DDCD28001CFAE8 /* VitalInfo.swift in Sources */, + D23F8E5F29DDCD28001CFAE8 /* UIApplicationSwizzler.swift in Sources */, + D23F8E6029DDCD28001CFAE8 /* PerformanceMetric.swift in Sources */, + D23F8E6129DDCD28001CFAE8 /* RUMConfiguration.swift in Sources */, + 61F930CC2BA213AC005F0EE2 /* AppHang.swift in Sources */, + 112877992D708D300082D11B /* VitalRefreshRateReader.swift in Sources */, + 1128779B2D708D300082D11B /* RenderLoopObserver.swift in Sources */, + 1128779C2D708D300082D11B /* ViewHitchesReader.swift in Sources */, + 61C713AB2A3B790B00FA735A /* Monitor.swift in Sources */, + D23F8E6429DDCD28001CFAE8 /* SwiftUIViewHandler.swift in Sources */, + 965497002D761E2B006428EE /* RUMView.swift in Sources */, + D23F8E6529DDCD28001CFAE8 /* RUMFeature.swift in Sources */, + D23F8E6629DDCD28001CFAE8 /* RUMDebugging.swift in Sources */, + 3C4CF9912C47BE07006DE1C0 /* MemoryWarningMonitor.swift in Sources */, + 864A707A2DDF742A00AC0619 /* AccessibilityInfo.swift in Sources */, + 9632900C2DF1F01400E9199E /* ModernSwiftUIComponentDetector.swift in Sources */, + D23F8E6729DDCD28001CFAE8 /* RUMUUID.swift in Sources */, + D23F8E6829DDCD28001CFAE8 /* UIKitExtensions.swift in Sources */, + 61C713A82A3B78F900FA735A /* RUMMonitorProtocol+Convenience.swift in Sources */, + D23F8E6929DDCD28001CFAE8 /* RUMContextAttributes.swift in Sources */, + 11E6D5A02E6B1468004D2F87 /* FirstFrameReader.swift in Sources */, + D23F8E6B29DDCD28001CFAE8 /* RUMMonitor.swift in Sources */, + D23F8E6C29DDCD28001CFAE8 /* RUMContextProvider.swift in Sources */, + 61DA6F6D2BB57E32009537E5 /* FatalErrorBuilder.swift in Sources */, + 619F1A242DEE493F003954BD /* LaunchReasonResolver.swift in Sources */, + D23F8E6D29DDCD28001CFAE8 /* ViewIdentifier.swift in Sources */, + 49D8C0B82AC5D2160075E427 /* RUM+Internal.swift in Sources */, + 96F70D512DDDBDA600D3736B /* SwiftUIComponentDetector.swift in Sources */, + D23F8E6E29DDCD28001CFAE8 /* RUMViewsHandler.swift in Sources */, + 61C713BA2A3C935C00FA735A /* RUM.swift in Sources */, + 3C0CB3462C19A1ED003B0E9B /* WatchdogTerminationReporter.swift in Sources */, + D23F8E6F29DDCD28001CFAE8 /* RequestBuilder.swift in Sources */, + 9629FFE12D81C348008DFE39 /* SwiftUIControllerType.swift in Sources */, + 1124D5362EA6D4390002E053 /* RUMAppLaunchManager.swift in Sources */, + 1124D5372EA6D4390002E053 /* RUMFeatureOperationManager.swift in Sources */, + D224430529E9588500274EC7 /* TelemetryReceiver.swift in Sources */, + D23F8E7029DDCD28001CFAE8 /* URLSessionRUMResourcesHandler.swift in Sources */, + 965497062D761FCB006428EE /* SwiftUIViewNameExtractor.swift in Sources */, + 11F55FDA2DCE183500DE4944 /* RUMDataModels+objc.swift in Sources */, + D23F8E7129DDCD28001CFAE8 /* RUMEventBuilder.swift in Sources */, + 118ACA3E2EEED247004E7F20 /* AppLaunchMetricController.swift in Sources */, + 118ACA3F2EEED247004E7F20 /* AppLaunchMetric.swift in Sources */, + D23F8E7229DDCD28001CFAE8 /* ErrorMessageReceiver.swift in Sources */, + 96F70D462DD793C400D3736B /* RUMAction.swift in Sources */, + D23F8E7329DDCD28001CFAE8 /* SwiftUIActionModifier.swift in Sources */, + D23F8E7429DDCD28001CFAE8 /* RUMCommandSubscriber.swift in Sources */, + 6194B92B2BB4116A00179430 /* RUMDataStore.swift in Sources */, + 6105C4F72CEBA7A100C4C5EE /* TNSMetric.swift in Sources */, + 6194B9312BB451C100179430 /* NonFatalAppHangsHandler.swift in Sources */, + D23F8E7529DDCD28001CFAE8 /* RUMUserActionScope.swift in Sources */, + 6167E6D72B7F8C3400C3CA2D /* AppHangsWatchdogThread.swift in Sources */, + 965497032D761EA3006428EE /* SwiftUIRUMViewsPredicate.swift in Sources */, + 61C713A42A3B78F900FA735A /* RUMMonitorProtocol.swift in Sources */, + 6174D6112BFDEA4600EC7469 /* SessionEndedMetric.swift in Sources */, + 3C0D5DED2A54405A00446CF9 /* RUMViewEventsFilter.swift in Sources */, + D23F8E7629DDCD28001CFAE8 /* RUMConnectivityInfoProvider.swift in Sources */, + D23F8E7729DDCD28001CFAE8 /* UIKitRUMViewsPredicate.swift in Sources */, + 261255882E2167E40015042B /* BaggageHeaderMerger.swift in Sources */, + 261255912E2167E40015042B /* HeaderProcessor.swift in Sources */, + 9629FFDE2D81C317008DFE39 /* SwiftUIViewPath.swift in Sources */, + 111201C12E93C13000375DA3 /* AppStateManager.swift in Sources */, + 111201C22E93C13000375DA3 /* AppStateInfo.swift in Sources */, + 61C713A62A3B78F900FA735A /* RUMMonitorProtocol+Internal.swift in Sources */, + D23F8E7829DDCD28001CFAE8 /* LongTaskObserver.swift in Sources */, + 615E2B962D425F5600D85243 /* ViewEndedMetric.swift in Sources */, + 864A707C2DDF743900AC0619 /* AccessibilityReader.swift in Sources */, + D23F8E7A29DDCD28001CFAE8 /* SessionReplayDependency.swift in Sources */, + 1124D52F2EA6D23C0002E053 /* StartupTypeHandler.swift in Sources */, + 9632900E2DF1F04200E9199E /* LegacySwiftUIComponentDetector.swift in Sources */, + 616F8C282BB1CD990061EA53 /* ProcessIdentifier.swift in Sources */, + D23F8E7C29DDCD28001CFAE8 /* RUMOffViewEventsHandlingRule.swift in Sources */, + D23F8E7D29DDCD28001CFAE8 /* RUMScope.swift in Sources */, + D23F8E7E29DDCD28001CFAE8 /* CrashReportReceiver.swift in Sources */, + D23F8E7F29DDCD28001CFAE8 /* UIViewControllerSwizzler.swift in Sources */, + D23F8E8029DDCD28001CFAE8 /* VitalInfoSampler.swift in Sources */, + D23F8E8129DDCD28001CFAE8 /* RUMViewScope.swift in Sources */, + 96F70D492DD79B3D00D3736B /* SwiftUIRUMActionsPredicate.swift in Sources */, + 11030D762D96EC5C00732D5F /* ViewHitchesMetric.swift in Sources */, + D2D748242DC0FF7E00C61353 /* FatalErrorContextNotifier.swift in Sources */, + D23F8E8229DDCD28001CFAE8 /* RUMSessionScope.swift in Sources */, + A7E6EA812D3146AD00997201 /* AnonymousIdentifierManager.swift in Sources */, + D23F8E8329DDCD28001CFAE8 /* RUMUser.swift in Sources */, + D23F8E8429DDCD28001CFAE8 /* UIKitRUMUserActionsPredicate.swift in Sources */, + 3C5CD8CE2C3ECB9400B12303 /* MemoryWarningReporter.swift in Sources */, + D23F8E8529DDCD28001CFAE8 /* SwiftUIExtensions.swift in Sources */, + 3CFF4F952C09E63C006F191D /* WatchdogTerminationChecker.swift in Sources */, + D23F8E8629DDCD28001CFAE8 /* RUMDataModelsMapping.swift in Sources */, + 618F2B042D146BB300A647C4 /* NetworkSettledResourcePredicate.swift in Sources */, + D23F8E8729DDCD28001CFAE8 /* RUMInstrumentation.swift in Sources */, + D23F8E8829DDCD28001CFAE8 /* VitalCPUReader.swift in Sources */, + D23F8E8A29DDCD28001CFAE8 /* RUMEventsMapper.swift in Sources */, + D23F8E8B29DDCD28001CFAE8 /* RUMContext.swift in Sources */, + 6174D6212C009C6300EC7469 /* SessionEndedMetricController.swift in Sources */, + 618F2B072D15922400A647C4 /* NextViewActionPredicate.swift in Sources */, + 6105C50A2CFA222400C4C5EE /* INVMetric.swift in Sources */, + D23F8E8E29DDCD28001CFAE8 /* UIEventCommandFactory.swift in Sources */, + AA0001012A000002000C0001 /* UIScrollViewDelegateProxy.swift in Sources */, + AA0001012A000001000C0001 /* RUMScrollHandler.swift in Sources */, + AA0001012A000003000C0001 /* UIScrollViewSwizzler.swift in Sources */, + AA0001012A000005000C0001 /* UIScrollViewHandler.swift in Sources */, + 11A2F24C2E70CC08006EDC52 /* FrameInfoProvider.swift in Sources */, + 11A2F24D2E70CC08006EDC52 /* MediaTimeProvider.swift in Sources */, + D23F8E8F29DDCD28001CFAE8 /* RUMUUIDGenerator.swift in Sources */, + 61DCC84F2C071DCD00CB59E5 /* TelemetryInterceptor.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D23F8E9F29DDCD38001CFAE8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6188697D2A4376F700E8996B /* RUMConfigurationTests.swift in Sources */, + 61DCC8482C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */, + B51668F3A97DEAC2917B0F44 /* TimeseriesSessionCollectorTests.swift in Sources */, + D23F8EA029DDCD38001CFAE8 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, + 61C4534B2C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift in Sources */, + D23F8EA229DDCD38001CFAE8 /* RUMSessionScopeTests.swift in Sources */, + 0904F9F52EE1DA6800ED9A22 /* UIKitExtensionsTests.swift in Sources */, + 3C4CF9992C47CC92006DE1C0 /* MemoryWarningMonitorTests.swift in Sources */, + D23F8EA329DDCD38001CFAE8 /* RUMUserActionScopeTests.swift in Sources */, + 864A70812DE092AD00AC0619 /* AccessibilityReaderTests.swift in Sources */, + 615B0F8C2BB33C2800E9ED6C /* AppHangsMonitorTests.swift in Sources */, + 61C713B42A3C3A0B00FA735A /* RUMMonitorProtocol+InternalTests.swift in Sources */, + D23F8EA629DDCD38001CFAE8 /* RUMDeviceInfoTests.swift in Sources */, + D23F8EA829DDCD38001CFAE8 /* RUMResourceScopeTests.swift in Sources */, + 3CFF4FA52C0E0FE9006F191D /* WatchdogTerminationCheckerTests.swift in Sources */, + 619F1A282DEF0B3F003954BD /* LaunchReasonResolverTests.swift in Sources */, + D23F8EAC29DDCD38001CFAE8 /* RUMDataModelsMappingTests.swift in Sources */, + D23F8EAD29DDCD38001CFAE8 /* RUMEventBuilderTests.swift in Sources */, + 61CE2E602BF2177100EC7D42 /* Monitor+GlobalAttributesTests.swift in Sources */, + 3CEC57782C16FDD80042B5F2 /* AppStateManagerTests.swift in Sources */, + D23F8EAE29DDCD38001CFAE8 /* DDTAssertValidRUMUUID.swift in Sources */, + D23F8EAF29DDCD38001CFAE8 /* RUMScopeTests.swift in Sources */, + 2612558A2E2167F10015042B /* BaggageHeaderMergerTests.swift in Sources */, + 261255942E2167F10015042B /* HeaderProcessorTests.swift in Sources */, + A7E6EA842D314A9B00997201 /* AnonymousIdentifierManagerTests.swift in Sources */, + D23F8EB029DDCD38001CFAE8 /* SessionReplayDependencyTests.swift in Sources */, + 61C713B72A3C600400FA735A /* RUMMonitorProtocol+ConvenienceTests.swift in Sources */, + D23F8EB129DDCD38001CFAE8 /* RUMViewScopeTests.swift in Sources */, + D224431029E977A100274EC7 /* TelemetryReceiverTests.swift in Sources */, + 96155A2A2D79A4E50029034E /* SwiftUIViewNameExtractorIntegrationTests.swift in Sources */, + 5B1D02952E8ED6C000AB2391 /* FlagEvaluationReceiverTests.swift in Sources */, + 3C4CF99C2C47DAA5006DE1C0 /* MemoryWarningMocks.swift in Sources */, + 3C43A3892C188975000BFB21 /* WatchdogTerminationMonitorTests.swift in Sources */, + D23F8EB229DDCD38001CFAE8 /* ValuePublisherTests.swift in Sources */, + 6174D61B2BFE449300EC7469 /* SessionEndedMetricTests.swift in Sources */, + 9654971E2D774060006428EE /* SwiftUIViewNameExtractorTests.swift in Sources */, + 61181CDD2BF35BC000632A7A /* FatalErrorContextNotifierTests.swift in Sources */, + 61C713BD2A3C95AD00FA735A /* RUMInstrumentationTests.swift in Sources */, + D23F8EB329DDCD38001CFAE8 /* ErrorMessageReceiverTests.swift in Sources */, + 61C713C12A3C9DAD00FA735A /* RequestBuilderTests.swift in Sources */, + D23F8EB429DDCD38001CFAE8 /* RUMApplicationScopeTests.swift in Sources */, + 6105C5152D0C584F00C4C5EE /* INVMetricTests.swift in Sources */, + D23F8EB629DDCD38001CFAE8 /* RUMViewsHandlerTests.swift in Sources */, + 61C713CB2A3DC22700FA735A /* RUMTests.swift in Sources */, + D23F8EB829DDCD38001CFAE8 /* RUMActionsHandlerTests.swift in Sources */, + AA0001012A000004000B0001 /* RUMScrollHandlerTests.swift in Sources */, + AA0001012A000006000B0001 /* UIScrollViewDelegateProxyTests.swift in Sources */, + AA0001012A000007000B0001 /* UIScrollViewSwizzlerTests.swift in Sources */, + AA0001012A000009000B0001 /* ThirdPartyDelegateProxy.swift in Sources */, + 61C713AE2A3B793E00FA735A /* RUMMonitorProtocolTests.swift in Sources */, + 6105C4FB2CEBD72600C4C5EE /* TNSMetricTests.swift in Sources */, + D23F8EBA29DDCD38001CFAE8 /* ViewIdentifierTests.swift in Sources */, + 96F70D582DDE253F00D3736B /* SwiftUIComponentDetectorTests.swift in Sources */, + D23F8EBE29DDCD38001CFAE8 /* WebViewEventReceiverTests.swift in Sources */, + D23F8EBF29DDCD38001CFAE8 /* URLSessionRUMResourcesHandlerTests.swift in Sources */, + 117ADDDA2EAA8A90008BD9D8 /* StartupTypeHandlerTests.swift in Sources */, + D23F8EC029DDCD38001CFAE8 /* RUMEventSanitizerTests.swift in Sources */, + D253EE9C2B98B37C0010B589 /* ViewCacheTests.swift in Sources */, + 6176C1732ABDBA2E00131A70 /* MonitorTests.swift in Sources */, + D23F8EC129DDCD38001CFAE8 /* RUMEventsMapperTests.swift in Sources */, + 6167E6DB2B8004A500C3CA2D /* AppHangsWatchdogThreadTests.swift in Sources */, + 3C0D5DEA2A543EA300446CF9 /* RUMViewEventsFilterTests.swift in Sources */, + 1124D5332EA6D4050002E053 /* RUMAppLaunchManagerTests.swift in Sources */, + 118ACA452EEEED24004E7F20 /* AppLaunchMetricControllerTests.swift in Sources */, + D23F8EC429DDCD38001CFAE8 /* RUMCommandTests.swift in Sources */, + 9678E2762E55CD200094B106 /* RUMFeatureOperationManagerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D24067FD27CE6C9E00C04F44 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1434A4672B7F8D880072E3BB /* DebugOTelTracingViewController.swift in Sources */, + D2F44FC3299BD5600074B0D9 /* UIViewController+KeyboardControlling.swift in Sources */, + D240680827CE6C9E00C04F44 /* ConsoleOutputInterceptor.swift in Sources */, + D240681E27CE6C9E00C04F44 /* ExampleAppDelegate.swift in Sources */, + D240682B27CE6C9E00C04F44 /* UIButton+Disabling.swift in Sources */, + D240682D27CE6C9E00C04F44 /* Environment.swift in Sources */, + D240686827CF642900C04F44 /* SwiftUI.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D257953A298ABA65008A1BE5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -9278,6 +9611,7 @@ D29A9F7F29DD85BB005C54A4 /* RUMEventSanitizer.swift in Sources */, D29A9F5A29DD85BB005C54A4 /* RUMScopeDependencies.swift in Sources */, D29A9F5B29DD85BB005C54A4 /* VitalMemoryReader.swift in Sources */, + D29A9FFF29DD85BB005C54A4 /* TimeseriesSessionCollector.swift in Sources */, 5B1D02862E8EB78800AB2391 /* FlagEvaluationReceiver.swift in Sources */, 962900242D8351AB008DFE39 /* TopLevelReflector.swift in Sources */, 6194B9332BB451DB00179430 /* FatalAppHangsHandler.swift in Sources */, @@ -9406,6 +9740,7 @@ files = ( 6188697C2A4376F700E8996B /* RUMConfigurationTests.swift in Sources */, 61DCC8472C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */, + 21A5D61036FA5C28E71ED824 /* TimeseriesSessionCollectorTests.swift in Sources */, D29A9FA629DDB483005C54A4 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, 61C4534A2C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift in Sources */, D29A9FBD29DDB483005C54A4 /* RUMSessionScopeTests.swift in Sources */, diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 96c33d7384..aef325a2ef 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -155,6 +155,15 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider telemetry: core.telemetry ) }, + timeseriesCollector: { + guard configuration.enableTimeseries, let vitalsReaders = configuration.vitalsUpdateFrequency.map({ + VitalsReaders(frequency: $0.timeInterval, telemetry: core.telemetry) + }) else { return nil } + return TimeseriesSessionCollector( + memoryReader: vitalsReaders.memory, + featureScope: featureScope + ) + }(), accessibilityReader: accessibilityReader, onSessionUpdate: onSessionUpdate, viewCache: ViewCache(dateProvider: configuration.dateProvider), diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index 71d351c807..b040dae502 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -328,6 +328,14 @@ extension RUM { /// Default: `false`. public var collectAccessibility: Bool + /// Enables collection of memory and CPU timeseries events. + /// + /// When enabled, memory footprint and CPU usage are sampled every second and uploaded as + /// timeseries events scoped to the RUM session. Requires `vitalsUpdateFrequency` to be set. + /// + /// Default: `false`. + public var enableTimeseries: Bool + /// Feature flags to preview features in RUM. public var featureFlags: FeatureFlags @@ -554,6 +562,7 @@ extension RUM.Configuration { /// - trackSlowFrames: Enables the collection of slow frames (view hitches). Default: `true`. /// - telemetrySampleRate: The sampling rate for SDK internal telemetry utilized by Datadog. Must be a value between `0` and `100`. Default: `20`. /// - collectAccessibility: Determines whether accessibility data should be collected and included in RUM view events. Default: `false`. + /// - enableTimeseries: Enables collection of memory and CPU timeseries events. Default: `false`. /// - featureFlags: Experimental feature flags. /// /// - Note: On watchOS, automatic UIKit and SwiftUI view/action tracking is unavailable. The predicate parameters will be ignored. @@ -589,6 +598,7 @@ extension RUM.Configuration { trackSlowFrames: Bool = true, telemetrySampleRate: SampleRate = 20, collectAccessibility: Bool = false, + enableTimeseries: Bool = false, featureFlags: FeatureFlags = .defaults ) { self.applicationID = applicationID @@ -618,6 +628,7 @@ extension RUM.Configuration { self.trackSlowFrames = trackSlowFrames self.telemetrySampleRate = telemetrySampleRate self.collectAccessibility = collectAccessibility + self.enableTimeseries = enableTimeseries self.featureFlags = featureFlags } #else diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift index 753964f080..b04d9c67b2 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift @@ -46,6 +46,7 @@ internal struct RUMScopeDependencies { let firstFrameReader: RenderLoopReader let viewHitchesReaderFactory: () -> (RenderLoopReader & ViewHitchesModel)? let vitalsReaders: VitalsReaders? + let timeseriesCollector: TimeseriesCollecting? let accessibilityReader: AccessibilityReading? let onSessionUpdate: RUM.SessionUpdater let viewCache: ViewCache @@ -88,6 +89,7 @@ internal struct RUMScopeDependencies { firstFrameReader: RenderLoopReader, viewHitchesReaderFactory: @escaping () -> (ViewHitchesModel & RenderLoopReader)?, vitalsReaders: VitalsReaders?, + timeseriesCollector: TimeseriesCollecting? = nil, accessibilityReader: AccessibilityReading?, onSessionUpdate: @escaping RUM.SessionUpdater, viewCache: ViewCache, @@ -117,6 +119,7 @@ internal struct RUMScopeDependencies { self.firstFrameReader = firstFrameReader self.viewHitchesReaderFactory = viewHitchesReaderFactory self.vitalsReaders = vitalsReaders + self.timeseriesCollector = timeseriesCollector self.accessibilityReader = accessibilityReader self.onSessionUpdate = onSessionUpdate self.viewCache = viewCache diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift index e80e5f7f0c..21eabc2fae 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift @@ -101,7 +101,9 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { /// Indicates whether the "ApplicationLaunch" view was active when the app entered the background. private var hadApplicationLaunchViewWhenEnteringBackground: Bool? = nil /// The reason why this session has ended or `nil` if it is still active. - private(set) var endReason: EndReason? + private(set) var endReason: EndReason? { + didSet { if endReason != nil { dependencies.timeseriesCollector?.stop() } } + } /// Counter to track the index of views in this session. Starts at 0 for the first view. private var nextViewIndex: Int = 0 @@ -168,6 +170,12 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { // Update fatal error context with recent RUM session state: dependencies.fatalErrorContext.sessionState = state + + dependencies.timeseriesCollector?.start( + sessionID: sessionUUID.rawValue.uuidString.lowercased(), + applicationID: dependencies.rumApplicationID, + sessionType: dependencies.sessionType + ) } /// Creates a new Session upon expiration of the previous one. diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift new file mode 100644 index 0000000000..13fea2a642 --- /dev/null +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -0,0 +1,218 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +/// Defines the interface for collecting timeseries data during a RUM session. +internal protocol TimeseriesCollecting: AnyObject { + func start(sessionID: String, applicationID: String, sessionType: RUMSessionType) + func stop() +} + +/// Collects memory and CPU samples at 1s intervals during a RUM session and flushes them +/// as `RUMTimeseriesMemoryEvent` / `RUMTimeseriesCpuEvent` batches via the RUM feature scope. +internal class TimeseriesSessionCollector: TimeseriesCollecting { + private let memoryReader: SamplingBasedVitalReader + private let cpuUsageProvider: () -> Double? + private let batchSize: Int + private let samplingInterval: TimeInterval + private let featureScope: FeatureScope + private let totalRAM: Double + + private var memoryBuffer: [RUMTimeseriesMemoryEvent.Timeseries.Data] = [] + private var cpuBuffer: [RUMTimeseriesCpuEvent.Timeseries.Data] = [] + private var sessionID: String = "" + private var applicationID: String = "" + private var sessionType: RUMSessionType = .user + private var timer: DispatchSourceTimer? + + /// All buffer mutations and timer events run on this queue. + private let queue = DispatchQueue(label: "com.datadoghq.timeseries-collector", qos: .utility) + + init( + memoryReader: SamplingBasedVitalReader, + featureScope: FeatureScope, + batchSize: Int = 30, + samplingInterval: TimeInterval = 1, + cpuUsageProvider: (() -> Double?)? = nil + ) { + self.memoryReader = memoryReader + self.batchSize = batchSize + self.samplingInterval = samplingInterval + self.featureScope = featureScope + self.totalRAM = Double(ProcessInfo.processInfo.physicalMemory) + self.cpuUsageProvider = cpuUsageProvider ?? { TimeseriesSessionCollector.processCPU() } + } + + /// Per-process CPU as a percentage (0–100+), summed across all app threads. + /// Separated into a static so it can be called from the init closure without capturing self. + private static func processCPU() -> Double? { + var threadsList: thread_act_array_t? + var threadsCount = mach_msg_type_number_t() + let kr = withUnsafeMutablePointer(to: &threadsList) { + $0.withMemoryRebound(to: thread_act_array_t?.self, capacity: 1) { + task_threads(mach_task_self_, $0, &threadsCount) + } + } + guard kr == KERN_SUCCESS, let threadsList = threadsList else { + return nil + } + defer { + vm_deallocate( + mach_task_self_, + vm_address_t(bitPattern: threadsList), + vm_size_t(Int(threadsCount) * MemoryLayout.stride) + ) + } + var total = 0.0 + for i in 0.. 0 ? bytes / totalRAM * 100 : 0 + let dataPoint = RUMTimeseriesMemoryEvent.Timeseries.Data( + dataPoint: .init(memoryMax: bytes, memoryPercent: memoryPercent), + timestamp: now + ) + memoryBuffer.append(dataPoint) + if memoryBuffer.count >= batchSize { + flushMemory() + } + } + + if let cpuUsage = cpuUsageProvider() { + let dataPoint = RUMTimeseriesCpuEvent.Timeseries.Data( + dataPoint: .init(cpuUsage: cpuUsage), + timestamp: now + ) + cpuBuffer.append(dataPoint) + if cpuBuffer.count >= batchSize { + flushCPU() + } + } + } + + private func flushMemory() { + guard !memoryBuffer.isEmpty else { + return + } + let batch = memoryBuffer + memoryBuffer = [] + let sessionID = self.sessionID + let applicationID = self.applicationID + let sessionType = self.sessionType + let start = batch[0].timestamp + let end = batch[batch.count - 1].timestamp + let eventID = UUID().uuidString.lowercased() + + featureScope.eventWriteContext { context, writer in + let event = RUMTimeseriesMemoryEvent( + dd: .init(), + application: .init(id: applicationID), + date: start / 1_000_000, + service: context.service, + session: .init(id: sessionID, type: sessionType), + source: .ios, + timeseries: .init( + data: batch, + end: end, + id: eventID, + name: "memory", + start: start + ), + version: context.version + ) + writer.write(value: event) + } + } + + private func flushCPU() { + guard !cpuBuffer.isEmpty else { + return + } + let batch = cpuBuffer + cpuBuffer = [] + let sessionID = self.sessionID + let applicationID = self.applicationID + let sessionType = self.sessionType + let start = batch[0].timestamp + let end = batch[batch.count - 1].timestamp + let eventID = UUID().uuidString.lowercased() + + featureScope.eventWriteContext { context, writer in + let event = RUMTimeseriesCpuEvent( + dd: .init(), + application: .init(id: applicationID), + date: start / 1_000_000, + service: context.service, + session: .init(id: sessionID, type: sessionType), + source: .ios, + timeseries: .init( + data: batch, + end: end, + id: eventID, + name: "cpu", + start: start + ), + version: context.version + ) + writer.write(value: event) + } + } +} diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift index 00c2053e7d..462a226eff 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift @@ -706,4 +706,102 @@ class RUMSessionScopeTests: XCTestCase { // Then XCTAssertTrue(result) } + + // MARK: - Timeseries collector lifecycle + + func testWhenSessionScopeIsCreated_itStartsTimeseriesCollector() { + // Given + let collector = TimeseriesCollectorSpy() + + // When + let _: RUMSessionScope = .mockWith( + parent: parent, + dependencies: .mockWith(timeseriesCollector: collector) + ) + + // Then + XCTAssertEqual(collector.startCallCount, 1) + XCTAssertEqual(collector.stopCallCount, 0) + } + + func testWhenSessionExpiresDueToMaxDuration_itStopsTimeseriesCollector() { + // Given + let collector = TimeseriesCollectorSpy() + var currentTime = Date() + let scope: RUMSessionScope = .mockWith( + parent: parent, + startTime: currentTime, + dependencies: .mockWith(timeseriesCollector: collector) + ) + + // When — push past the max session duration + currentTime.addTimeInterval(RUMSessionScope.Constants.sessionMaxDuration) + _ = scope.process(command: RUMCommandMock(time: currentTime), context: context, writer: writer) + + // Then + XCTAssertEqual(collector.stopCallCount, 1) + } + + func testWhenSessionExpiresDueToInactivity_itStopsTimeseriesCollector() { + // Given + let collector = TimeseriesCollectorSpy() + var currentTime = Date() + let scope: RUMSessionScope = .mockWith( + parent: parent, + startTime: currentTime, + dependencies: .mockWith(timeseriesCollector: collector) + ) + + _ = scope.process(command: RUMCommandMock(time: currentTime), context: context, writer: writer) + + // When — push past the session inactivity timeout + currentTime.addTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration) + _ = scope.process(command: RUMCommandMock(time: currentTime), context: context, writer: writer) + + // Then + XCTAssertEqual(collector.stopCallCount, 1) + } + + func testWhenSessionScopeStartsNewSession_itStartsCollectorWithCorrectSessionID() { + // Given + let collector = TimeseriesCollectorSpy() + let applicationID = "test-app-id" + + // When + let scope: RUMSessionScope = .mockWith( + parent: parent, + dependencies: .mockWith( + rumApplicationID: applicationID, + timeseriesCollector: collector + ) + ) + + // Then + XCTAssertEqual(collector.startCallCount, 1) + XCTAssertEqual(collector.lastStartedApplicationID, applicationID) + XCTAssertNotNil(collector.lastStartedSessionID, "Session ID should be set") + XCTAssertFalse(collector.lastStartedSessionID?.isEmpty ?? true) + XCTAssertEqual(collector.lastStartedSessionType, scope.context.sessionID != .nullUUID ? .user : .user) + } +} + +// MARK: - Test Helpers + +private class TimeseriesCollectorSpy: TimeseriesCollecting { + var startCallCount = 0 + var stopCallCount = 0 + var lastStartedSessionID: String? + var lastStartedApplicationID: String? + var lastStartedSessionType: RUMSessionType? + + func start(sessionID: String, applicationID: String, sessionType: RUMSessionType) { + startCallCount += 1 + lastStartedSessionID = sessionID + lastStartedApplicationID = applicationID + lastStartedSessionType = sessionType + } + + func stop() { + stopCallCount += 1 + } } diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift new file mode 100644 index 0000000000..e837e208da --- /dev/null +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -0,0 +1,247 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import XCTest +import TestUtilities +import DatadogInternal +@testable import DatadogRUM + +class TimeseriesSessionCollectorTests: XCTestCase { + private let featureScope = FeatureScopeMock() + private let memoryReader = SamplingBasedVitalReaderMock() + + // MARK: - Memory events + + func testWhenBatchSizeIsReached_itWritesMemoryEvent() { + // Given + memoryReader.vitalData = 1_000_000 + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil } + ) + + // When + let expectation = self.expectation(description: "memory batch written") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + + collector.start(sessionID: "session-abc", applicationID: "app-123", sessionType: .user) + waitForExpectations(timeout: 2) + collector.stop() + + // Then + let events = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + XCTAssertFalse(events.isEmpty, "Expected at least one memory batch to be written") + + let event = events[0] + XCTAssertEqual(event.session.id, "session-abc") + XCTAssertEqual(event.application.id, "app-123") + XCTAssertEqual(event.session.type, .user) + XCTAssertEqual(event.source, .ios) + XCTAssertEqual(event.timeseries.name, "memory") + XCTAssertEqual(event.timeseries.data.count, 2) + XCTAssertEqual(event.timeseries.data[0].dataPoint.memoryMax, 1_000_000) + XCTAssertGreaterThan(event.timeseries.data[0].dataPoint.memoryPercent, 0) + } + + func testWhenBatchSizeIsReached_itWritesCpuEvent() { + // Given + memoryReader.vitalData = nil + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { 42.5 } + ) + + // When + let expectation = self.expectation(description: "cpu batch written") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + + collector.start(sessionID: "session-abc", applicationID: "app-123", sessionType: .user) + waitForExpectations(timeout: 2) + collector.stop() + + // Then + let events = featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self) + XCTAssertFalse(events.isEmpty, "Expected at least one CPU batch to be written") + + let event = events[0] + XCTAssertEqual(event.session.id, "session-abc") + XCTAssertEqual(event.application.id, "app-123") + XCTAssertEqual(event.session.type, .user) + XCTAssertEqual(event.source, .ios) + XCTAssertEqual(event.timeseries.name, "cpu") + XCTAssertEqual(event.timeseries.data.count, 2) + XCTAssertEqual(event.timeseries.data[0].dataPoint.cpuUsage, 42.5) + } + + // MARK: - Flush on stop + + func testWhenStopIsCalled_itFlushesPartialMemoryBuffer() { + // Given + memoryReader.vitalData = 2_000_000 + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 100, // large batch — won't auto-flush + samplingInterval: 0.05, + cpuUsageProvider: { nil } + ) + + // When — let a few samples accumulate then stop + let expectation = self.expectation(description: "samples collected") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } + + collector.start(sessionID: "session-xyz", applicationID: "app-456", sessionType: .synthetics) + waitForExpectations(timeout: 2) + + let syncExpectation = self.expectation(description: "stop completed") + collector.stop() + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.2) { syncExpectation.fulfill() } + waitForExpectations(timeout: 2) + + // Then + let events = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + XCTAssertFalse(events.isEmpty, "Expected partial buffer to be flushed on stop") + XCTAssertEqual(events[0].session.id, "session-xyz") + XCTAssertEqual(events[0].application.id, "app-456") + XCTAssertEqual(events[0].session.type, .synthetics) + } + + func testWhenStopIsCalled_itFlushesPartialCpuBuffer() { + // Given + memoryReader.vitalData = nil + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 100, + samplingInterval: 0.05, + cpuUsageProvider: { 10.0 } + ) + + let expectation = self.expectation(description: "samples collected") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } + + collector.start(sessionID: "session-xyz", applicationID: "app-456", sessionType: .ciTest) + waitForExpectations(timeout: 2) + + let syncExpectation = self.expectation(description: "stop completed") + collector.stop() + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.2) { syncExpectation.fulfill() } + waitForExpectations(timeout: 2) + + // Then + let events = featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self) + XCTAssertFalse(events.isEmpty, "Expected partial CPU buffer to be flushed on stop") + XCTAssertEqual(events[0].session.type, .ciTest) + } + + // MARK: - No-data readers + + func testWhenReadersReturnNil_itWritesNoEvents() { + // Given + memoryReader.vitalData = nil + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil } + ) + + let expectation = self.expectation(description: "sampling time elapsed") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } + + collector.start(sessionID: "session-abc", applicationID: "app-123", sessionType: .user) + waitForExpectations(timeout: 2) + collector.stop() + + // Then + XCTAssertTrue(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty) + XCTAssertTrue(featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self).isEmpty) + } + + // MARK: - Session restart + + func testWhenStartIsCalledAgain_itUsesNewSessionMetadata() { + // Given + memoryReader.vitalData = 512_000 + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 100, + samplingInterval: 0.05, + cpuUsageProvider: { nil } + ) + + // First session + let firstExpectation = self.expectation(description: "first session samples") + firstExpectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { firstExpectation.fulfill() } + collector.start(sessionID: "session-1", applicationID: "app-1", sessionType: .user) + waitForExpectations(timeout: 2) + + // Second session — start() resets buffers and updates metadata + let secondExpectation = self.expectation(description: "second session samples") + secondExpectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { secondExpectation.fulfill() } + collector.start(sessionID: "session-2", applicationID: "app-1", sessionType: .user) + waitForExpectations(timeout: 2) + + // Flush second session + let stopExpectation = self.expectation(description: "stop completed") + collector.stop() + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.2) { stopExpectation.fulfill() } + waitForExpectations(timeout: 2) + + // Then — the flushed event should carry session-2 metadata + let events = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + let lastEvent = try! XCTUnwrap(events.last) + XCTAssertEqual(lastEvent.session.id, "session-2") + } + + // MARK: - Timeseries range + + func testTimestampsAreMonotonicallyIncreasing() { + // Given + memoryReader.vitalData = 1_000_000 + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 3, + samplingInterval: 0.05, + cpuUsageProvider: { nil } + ) + + let expectation = self.expectation(description: "first batch written") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + + collector.start(sessionID: "session-abc", applicationID: "app-123", sessionType: .user) + waitForExpectations(timeout: 2) + collector.stop() + + // Then + let events = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + guard let event = events.first else { + XCTFail("Expected at least one memory event") + return + } + let timestamps = event.timeseries.data.map { $0.timestamp } + XCTAssertEqual(timestamps, timestamps.sorted(), "Timestamps should be monotonically increasing") + XCTAssertEqual(event.timeseries.start, timestamps.first) + XCTAssertEqual(event.timeseries.end, timestamps.last) + } +} diff --git a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift index 4f81c21b91..a1f154abec 100644 --- a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift +++ b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift @@ -1147,6 +1147,7 @@ extension RUMScopeDependencies { firstFrameReader: RenderLoopReader = FirstFrameReader(dateProvider: DateProviderMock(), mediaTimeProvider: MediaTimeProviderMock()), viewHitchesReaderFactory: @escaping () -> (ViewHitchesModel & RenderLoopReader)? = { ViewHitchesMock.mockAny() }, vitalsReaders: VitalsReaders? = nil, + timeseriesCollector: TimeseriesCollecting? = nil, accessibilityReader: AccessibilityReading? = nil, onSessionUpdate: @escaping RUM.SessionUpdater = mockNoOpSessionUpdater(), viewCache: ViewCache = ViewCache(dateProvider: SystemDateProvider()), @@ -1183,6 +1184,7 @@ extension RUMScopeDependencies { firstFrameReader: firstFrameReader, viewHitchesReaderFactory: viewHitchesReaderFactory, vitalsReaders: vitalsReaders, + timeseriesCollector: timeseriesCollector, accessibilityReader: accessibilityReader, onSessionUpdate: onSessionUpdate, viewCache: viewCache, From e41a3c8f39f4ca05a91574b23bedb12d9a34d36d Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 21 Apr 2026 15:21:16 +0200 Subject: [PATCH 035/102] add Android timeseries SDK integration plan --- ...26-04-21-timeseries-android-integration.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 plans/ipcivr-2026-04-21-timeseries-android-integration.md diff --git a/plans/ipcivr-2026-04-21-timeseries-android-integration.md b/plans/ipcivr-2026-04-21-timeseries-android-integration.md new file mode 100644 index 0000000000..f43102e7c3 --- /dev/null +++ b/plans/ipcivr-2026-04-21-timeseries-android-integration.md @@ -0,0 +1,95 @@ +# IPCIVR Plan — Android Timeseries SDK Integration (2026-04-21) + +## Goal + +Wire memory + CPU timeseries collection into the Android RUM session lifecycle, mirroring iOS Plan 4. +Collects samples at 1s intervals, batches 30 samples, writes `RumTimeseriesMemoryEvent` / +`RumTimeseriesCpuEvent` to the RUM feature scope on `bplasovska/feature/timeseries`. + +## Decisions + +- Self-contained `TimeseriesSessionCollector` inside `dd-sdk-android-rum` (not reusing DatadogTimeseries module — that was a pipeline testbed) +- CPU % via `/proc/self/stat` delta between consecutive 1s ticks, injectable via `cpuUsageProvider: (() -> Double?)?` lambda for test isolation +- Memory via `MemoryVitalReader.readVitalData()` (already returns bytes as Double) +- Models generated from `bplasovska/timeseries` branch on rum-events-format +- `enableTimeseries: Boolean = false` opt-in flag in `RumConfiguration`; also requires `vitalsUpdateFrequency != null` +- Session hook: `collector.start()` in `renewSession()` / initial tracked state; `collector.stop()` in `stopSession()` +- Dedicated `ScheduledExecutorService` via `sdkCore.createScheduledExecutorService("rum-timeseries")`; `shutdownNow()` + `NoOpScheduledExecutorService()` on stop +- `synchronized` blocks for buffer thread safety (SDK convention) +- `EventType.DEFAULT` for event writing +- Android `RumSessionType` only has `USER` / `SYNTHETICS` (no `CI_TEST`) + +## Task List + +### Step 0 — Model generation +- Add timeseries schema mappings to `features/dd-sdk-android-rum/generate_rum_models.gradle.kts` +- Run: `./gradlew :features:dd-sdk-android-rum:generateRumModelsFromJson -Pdd.rum.schema.ref=bplasovska/timeseries` +- Models land in `build/generated/json2kotlin/` — NOT committed to source (build-time generation) +- Add `TIMESERIES_BUILD.md` in the module root documenting the required flag for anyone building this branch +- **Must run this step before writing any code that references the generated classes** + +### Step 1 — TimeseriesCollecting interface +- New file: `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/TimeseriesCollecting.kt` +- Methods: `fun start(sessionId: String, applicationId: String, sessionType: RumSessionType)` + `fun stop()` + +### Step 2 — TimeseriesSessionCollector +- New file: `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/TimeseriesSessionCollector.kt` +- Constructor: `memoryReader: VitalReader`, `writer: DataWriter`, `sdkCore: SdkCore`, `batchSize: Int = 30`, `samplingIntervalMs: Long = 1000`, `cpuUsageProvider: (() -> Double?)? = null` +- Default `cpuUsageProvider`: reads `/proc/self/stat` delta using `existsSafe()` / `readTextSafe()` helpers + `Os.sysconf(_SC_CLK_TCK)` for normalization +- **Pre-warm CPU in `start()`**: read `/proc/self/stat` once at end of `start()` to set `prevCpuTicks` — first 1s tick then has a valid delta (no wasted sample) +- `ScheduledExecutorService` via `sdkCore.createScheduledExecutorService("rum-timeseries")` — new executor created on `start()`, `shutdownNow()` + replaced with `NoOpScheduledExecutorService()` on `stop()` +- Memory buffer + CPU buffer, flush at `batchSize = 30` or on `stop()` +- Write via `DataWriter.write(event, null, EventType.DEFAULT)` +- **Thread safety**: `synchronized(this)` wraps the entire `sample()` body, `flushMemory()`, `flushCPU()`, and the flush calls inside `stop()` — prevents race between in-flight sample and stop flush + +### Step 3 — RumSessionTypeExt +- Add `fun RumSessionType.toTimeseriesMemory(): RumTimeseriesMemoryEvent.Session.Type` in `RumSessionTypeExt.kt` +- Add `fun RumSessionType.toTimeseriesCpu(): RumTimeseriesCpuEvent.Session.Type` in `RumSessionTypeExt.kt` + +### Step 4 — Serializer registration +- Add two `is` branches in `RumEventSerializer.serialize()` (the existing `when` expression): + - `is RumTimeseriesMemoryEvent -> model.toJson().toString()` + - `is RumTimeseriesCpuEvent -> model.toJson().toString()` +- No separate registration infrastructure needed — generated models already have `toJson()` from the GSON-based code generator + +### Step 5 — RumConfiguration flag +- Add `enableTimeseries: Boolean = false` to `RumConfiguration` (or `Rum.Configuration`) + +### Step 6 — RumFeature factory +- Create collector only when `enableTimeseries = true` and `vitalsUpdateFrequency != null` +- Create dedicated executor `"rum-timeseries"` +- Pass collector to `RumScopeDependencies` + +### Step 7 — RumSessionScope hookup +- **ALL session starts go through `renewSession()`** — confirmed from code: even the first session (isNewSession=true) calls `renewSession()` with `USER_APP_LAUNCH` +- At the **top** of `renewSession()`: if `sessionState == TRACKED`, call `collector.stop()` (stops previous session before renewing) +- At the **bottom** of `renewSession()`: if `keepSession == true`, call `collector.start(sessionId, applicationId, sessionType)` +- In `stopSession()`: call `collector.stop()` +- Guard all calls with null check; `stop()` must be idempotent (safe to call twice) + +### Step 8 — Unit tests +- New file: `features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/timeseries/TimeseriesSessionCollectorTest.kt` +- Use Mockito `@Mock lateinit var mockMemoryReader: VitalReader` and `@Mock lateinit var mockWriter: DataWriter` +- Mirror 7 iOS test cases: batch flush memory, batch flush CPU, partial flush on stop (memory), partial flush on stop (CPU), nil readers write no events, session restart uses new metadata, timestamps monotonically increasing +- Injectable `cpuUsageProvider` lambda for fixed CPU values + +### Step 9 — Forgery factory +- New file: `features/dd-sdk-android-rum/src/testFixtures/kotlin/com/datadog/android/rum/utils/forge/TimeseriesEventForgeryFactory.kt` + +## Key file paths + +| Purpose | Path | +|---------|------| +| Collector | `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/TimeseriesSessionCollector.kt` | +| Interface | `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/TimeseriesCollecting.kt` | +| Session scope | `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt` | +| RumFeature | `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt` | +| SessionTypeExt | `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumSessionTypeExt.kt` | +| Model generation config | `features/dd-sdk-android-rum/generate_rum_models.gradle.kts` | +| VitalReader helpers | `dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/FileExt.kt` | +| NoOpExecutor | `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/thread/NoOpScheduledExecutorService.kt` | + +## Verification strategy + +- `./gradlew :features:dd-sdk-android-rum:test` — all unit tests pass +- Manual RocketLauncher (Android) run with `enableTimeseries = true` to confirm events reach backend From 363951f4f5cc80689c0de33845305ed1b5f029ff Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 23 Apr 2026 14:33:37 +0200 Subject: [PATCH 036/102] add delta compression implementation plan --- plans/ipcivr-20260423-delta-compression.md | 57 ++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 plans/ipcivr-20260423-delta-compression.md diff --git a/plans/ipcivr-20260423-delta-compression.md b/plans/ipcivr-20260423-delta-compression.md new file mode 100644 index 0000000000..d5c27a3aab --- /dev/null +++ b/plans/ipcivr-20260423-delta-compression.md @@ -0,0 +1,57 @@ +# IPCIVR Plan — Delta Compression for Timeseries +**Date:** 2026-04-23 + +## Idea Summary +- Add `enableDeltaCompression: Bool = false` to `TimeseriesSessionCollector` on both iOS and Android +- When `true`, replace the normal `data: [{timestamp, data_point}]` array with a columnar delta object `{precision:4, ts:[...], field:[...]}` for both memory and CPU flushes +- Precision hardcoded to 4 — floats multiplied by `10^4`, stored as integer deltas +- Single-sample batches are dropped (no degenerate delta objects sent) +- Each flush logs `[Timeseries] delta flush: signal=X normal=YB delta=ZB ratio=Wx` for staging comparison +- Flag defaults `false` — demo path completely unaffected; staged override set locally only (not committed) + +## Decisions +- Flag location: `TimeseriesSessionCollector` constructor (not RUM config) +- Scope: both memory and CPU signals +- Precision: hardcoded `4` +- iOS serialization: encode normal event → patch `[String:Any]` dict → write via `AnyEncodable` wrapper (avoids envelope duplication) +- Android serialization: call `event.toJson()` → patch `timeseries.data` field in resulting `JsonObject` (reuses generated serialization) +- Encoder types: all scaled values as `Int64` (Swift) / `Long` (Kotlin) — enforced by type signature to prevent overflow for large `memory_max` values +- Size comparison log: `#if DEBUG` guarded on iOS, debug build check on Android — zero overhead in release builds +- Encoder location: iOS → `DatadogRUM/Sources/Timeseries/`, Android → same package as collector +- Tests: encoder unit tests + collector flush output tests (both modes) + +## Tasks + +### iOS +1. `DeltaEncoder.swift` — pure static `encodeMemory(_:precision:)` and `encodeCPU(_:precision:)` returning `[String: Any]?` (nil for ≤1 sample) +2. `DeltaTimeseriesEvent.swift` — `DeltaTimeseriesMemoryEvent` and `DeltaTimeseriesCpuEvent` Encodable structs with full RUM envelope + delta data field +3. Add `enableDeltaCompression: Bool = false` to `TimeseriesSessionCollector.init` +4. `flushMemory()` — branch on flag: call encoder, drop if nil, write `DeltaTimeseriesMemoryEvent`, log size +5. `flushCPU()` — same pattern +6. `DeltaEncoderTests.swift` — 3-sample batch assertions: ts[0] absolute, ts[1..2] deltas, fields scaled + delta'd +7. `TimeseriesSessionCollectorTests.swift` — delta mode cases: JSON shape assert, single-sample drop + +### Android +8. `DeltaEncoder.kt` — `encodeMemory(buffer, precision): JsonObject?` and `encodeCpu(buffer, precision): JsonObject?`, null for ≤1 sample +9. Add `enableDeltaCompression: Boolean = false` to `TimeseriesSessionCollector` constructor +10. `flushMemoryBatch()` — branch on flag: encoder, skip if null, manual `JsonObject`, write, log +11. `flushCpuBatch()` — same +12. `DeltaEncoderTest.kt` — same assertions, Kotlin style +13. `TimeseriesSessionCollectorTest.kt` — delta mode cases + +### Wrap-up +14. iOS linter + tests (`DatadogRUM iOS`) +15. Android tests (`TimeseriesSessionCollectorTest`) +16. Export pantry notes (`/nono:export --timeseries`) + +## Verification Strategy +1. **Unit tests** — `DeltaEncoderTests` with known 3-sample batches, exact Int64 delta assertions. Collector flush tests with `enableDeltaCompression=true` assert delta JSON shape and single-sample drop. Runs automatically via `make test-ios SCHEME="DatadogRUM iOS"` and Android test suite. +2. **Instrumented size logs** — Enable flag locally, run sample app for ~1 min, confirm `[Timeseries] delta flush:` lines appear in console with `ratio > 1x`. +3. **Staging event inspection** — Capture raw intake payloads in staging, confirm `timeseries.data` is the columnar delta object (not an array). + +## Status +- [ ] Phase 3: Criticism +- [ ] Phase 4: Verification strategy +- [ ] Phase 5: Implementation +- [ ] Phase 6: Verification +- [ ] Phase 7: Report From 04b2d4d89719098c47fa12e85cef1228dea800fc Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 23 Apr 2026 14:50:23 +0200 Subject: [PATCH 037/102] add delta compression behind enableDeltaCompression flag to TimeseriesSessionCollector --- Datadog/Datadog.xcodeproj/project.pbxproj | 12 ++ .../Sources/Timeseries/DeltaEncoder.swift | 107 ++++++++++++++++++ .../TimeseriesSessionCollector.swift | 81 ++++++++++++- .../Tests/Timeseries/DeltaEncoderTests.swift | 88 ++++++++++++++ .../TimeseriesSessionCollectorTests.swift | 76 +++++++++++++ 5 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 DatadogRUM/Sources/Timeseries/DeltaEncoder.swift create mode 100644 DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index b408a78d22..e22564fcde 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -750,6 +750,10 @@ 61DCC8482C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DCC8462C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift */; }; B51668F3A97DEAC2917B0F44 /* TimeseriesSessionCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */; }; 21A5D61036FA5C28E71ED824 /* TimeseriesSessionCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */; }; + BB1A2B3C4D5E6F7800000004 /* DeltaEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */; }; + BB1A2B3C4D5E6F7800000005 /* DeltaEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */; }; + 7F8C00B87F71287FB535342E /* DeltaEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */; }; + 7F8C00B87F71287FB535342F /* DeltaEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */; }; 61DCC84E2C071DCD00CB59E5 /* TelemetryInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DCC84D2C071DCD00CB59E5 /* TelemetryInterceptor.swift */; }; 61E262D42EB2592C0041E70F /* DatadogFlags.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5BA8C2ED2E784B3C00B1DA80 /* DatadogFlags.framework */; }; 61E5333824B84EE2003D6C4E /* DebugRUMViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E5333724B84EE2003D6C4E /* DebugRUMViewController.swift */; }; @@ -2968,7 +2972,9 @@ AB7890CD1234EF567890ABCD /* CALayerSnapshotOcclusionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CALayerSnapshotOcclusionTests.swift; sourceTree = ""; }; B3BBBCB0265E71C600943419 /* VitalMemoryReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VitalMemoryReader.swift; sourceTree = ""; }; BB1A2B3C4D5E6F7800000001 /* TimeseriesSessionCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollector.swift; sourceTree = ""; }; + BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoder.swift; sourceTree = ""; }; 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollectorTests.swift; sourceTree = ""; }; + 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoderTests.swift; sourceTree = ""; }; B3BBBCBB265E71D100943419 /* VitalMemoryReaderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VitalMemoryReaderTests.swift; sourceTree = ""; }; B3E46CAA2D91B3A400BABF66 /* NetworkContextProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkContextProvider.swift; sourceTree = ""; }; B3E46CAD2D91B3FC00BABF66 /* NetworkContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkContext.swift; sourceTree = ""; }; @@ -6273,6 +6279,7 @@ BB1A2B3C4D5E6F7800000002 /* Timeseries */ = { isa = PBXGroup; children = ( + BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */, BB1A2B3C4D5E6F7800000001 /* TimeseriesSessionCollector.swift */, ); path = Timeseries; @@ -6281,6 +6288,7 @@ BD113E89E74339BD1ADB0EC3 /* Timeseries */ = { isa = PBXGroup; children = ( + 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */, 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */, ); path = Timeseries; @@ -9199,6 +9207,7 @@ D23F8E5729DDCD28001CFAE8 /* RUMScopeDependencies.swift in Sources */, D23F8E5829DDCD28001CFAE8 /* VitalMemoryReader.swift in Sources */, D23F8EFF29DDCD28001CFAE8 /* TimeseriesSessionCollector.swift in Sources */, + BB1A2B3C4D5E6F7800000004 /* DeltaEncoder.swift in Sources */, 5B1D02852E8EB78800AB2391 /* FlagEvaluationReceiver.swift in Sources */, 962900252D8351AB008DFE39 /* TopLevelReflector.swift in Sources */, 6194B9342BB451DB00179430 /* FatalAppHangsHandler.swift in Sources */, @@ -9327,6 +9336,7 @@ 6188697D2A4376F700E8996B /* RUMConfigurationTests.swift in Sources */, 61DCC8482C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */, B51668F3A97DEAC2917B0F44 /* TimeseriesSessionCollectorTests.swift in Sources */, + 7F8C00B87F71287FB535342E /* DeltaEncoderTests.swift in Sources */, D23F8EA029DDCD38001CFAE8 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, 61C4534B2C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift in Sources */, D23F8EA229DDCD38001CFAE8 /* RUMSessionScopeTests.swift in Sources */, @@ -9612,6 +9622,7 @@ D29A9F5A29DD85BB005C54A4 /* RUMScopeDependencies.swift in Sources */, D29A9F5B29DD85BB005C54A4 /* VitalMemoryReader.swift in Sources */, D29A9FFF29DD85BB005C54A4 /* TimeseriesSessionCollector.swift in Sources */, + BB1A2B3C4D5E6F7800000005 /* DeltaEncoder.swift in Sources */, 5B1D02862E8EB78800AB2391 /* FlagEvaluationReceiver.swift in Sources */, 962900242D8351AB008DFE39 /* TopLevelReflector.swift in Sources */, 6194B9332BB451DB00179430 /* FatalAppHangsHandler.swift in Sources */, @@ -9741,6 +9752,7 @@ 6188697C2A4376F700E8996B /* RUMConfigurationTests.swift in Sources */, 61DCC8472C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */, 21A5D61036FA5C28E71ED824 /* TimeseriesSessionCollectorTests.swift in Sources */, + 7F8C00B87F71287FB535342F /* DeltaEncoderTests.swift in Sources */, D29A9FA629DDB483005C54A4 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, 61C4534A2C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift in Sources */, D29A9FBD29DDB483005C54A4 /* RUMSessionScopeTests.swift in Sources */, diff --git a/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift b/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift new file mode 100644 index 0000000000..3501112ffc --- /dev/null +++ b/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift @@ -0,0 +1,107 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +/// Encodes timeseries batches using delta compression. +/// +/// The first value in each array is absolute; subsequent values are deltas from the previous. +/// All floating-point fields are scaled by `10^precision` and stored as `Int64`. +internal enum DeltaEncoder { + private static let precision = 4 + private static let scale = Int64(10_000) + + /// Encodes a batch of memory samples using delta compression. + /// + /// Returns `nil` if the batch contains one or fewer samples. + /// + /// Output format: + /// ``` + /// { + /// "precision": 4, + /// "ts": [absoluteNs, delta1, delta2, ...], + /// "memory_max": [scaledInt64, delta1, delta2, ...], + /// "memory_percent": [scaledInt64, delta1, ...] + /// } + /// ``` + static func encodeMemory(_ batch: [RUMTimeseriesMemoryEvent.Timeseries.Data]) -> [String: Any]? { + guard batch.count > 1 else { + return nil + } + + var ts: [Int64] = [] + var memoryMax: [Int64] = [] + var memoryPercent: [Int64] = [] + + for (index, sample) in batch.enumerated() { + if index == 0 { + ts.append(sample.timestamp) + memoryMax.append(Int64(round(sample.dataPoint.memoryMax * Double(scale)))) + memoryPercent.append(Int64(round(sample.dataPoint.memoryPercent * Double(scale)))) + } else { + let prev = batch[index - 1] + ts.append(sample.timestamp - prev.timestamp) + memoryMax.append( + Int64(round(sample.dataPoint.memoryMax * Double(scale))) - + Int64(round(prev.dataPoint.memoryMax * Double(scale))) + ) + memoryPercent.append( + Int64(round(sample.dataPoint.memoryPercent * Double(scale))) - + Int64(round(prev.dataPoint.memoryPercent * Double(scale))) + ) + } + } + + return [ + "precision": precision, + "ts": ts, + "memory_max": memoryMax, + "memory_percent": memoryPercent + ] + } + + /// Encodes a batch of CPU samples using delta compression. + /// + /// Returns `nil` if the batch contains one or fewer samples. + /// + /// Output format: + /// ``` + /// { + /// "precision": 4, + /// "ts": [absoluteNs, delta1, delta2, ...], + /// "cpu_usage": [scaledInt64, delta1, delta2, ...] + /// } + /// ``` + static func encodeCPU(_ batch: [RUMTimeseriesCpuEvent.Timeseries.Data]) -> [String: Any]? { + guard batch.count > 1 else { + return nil + } + + var ts: [Int64] = [] + var cpuUsage: [Int64] = [] + + for (index, sample) in batch.enumerated() { + if index == 0 { + ts.append(sample.timestamp) + cpuUsage.append(Int64(round(sample.dataPoint.cpuUsage * Double(scale)))) + } else { + let prev = batch[index - 1] + ts.append(sample.timestamp - prev.timestamp) + cpuUsage.append( + Int64(round(sample.dataPoint.cpuUsage * Double(scale))) - + Int64(round(prev.dataPoint.cpuUsage * Double(scale))) + ) + } + } + + return [ + "precision": precision, + "ts": ts, + "cpu_usage": cpuUsage + ] + } +} diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 13fea2a642..2a9ac6e0f5 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -22,6 +22,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private let samplingInterval: TimeInterval private let featureScope: FeatureScope private let totalRAM: Double + private let enableDeltaCompression: Bool private var memoryBuffer: [RUMTimeseriesMemoryEvent.Timeseries.Data] = [] private var cpuBuffer: [RUMTimeseriesCpuEvent.Timeseries.Data] = [] @@ -38,7 +39,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { featureScope: FeatureScope, batchSize: Int = 30, samplingInterval: TimeInterval = 1, - cpuUsageProvider: (() -> Double?)? = nil + cpuUsageProvider: (() -> Double?)? = nil, + enableDeltaCompression: Bool = false ) { self.memoryReader = memoryReader self.batchSize = batchSize @@ -46,6 +48,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.featureScope = featureScope self.totalRAM = Double(ProcessInfo.processInfo.physicalMemory) self.cpuUsageProvider = cpuUsageProvider ?? { TimeseriesSessionCollector.processCPU() } + self.enableDeltaCompression = enableDeltaCompression } /// Per-process CPU as a percentage (0–100+), summed across all app threads. @@ -161,6 +164,44 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() + if enableDeltaCompression { + guard let deltaData = DeltaEncoder.encodeMemory(batch) else { + return + } + featureScope.eventWriteContext { context, writer in + let event = RUMTimeseriesMemoryEvent( + dd: .init(), + application: .init(id: applicationID), + date: start / 1_000_000, + service: context.service, + session: .init(id: sessionID, type: sessionType), + source: .ios, + timeseries: .init( + data: batch, + end: end, + id: eventID, + name: "memory", + start: start + ), + version: context.version + ) + if let eventData = try? JSONEncoder().encode(event), + var dict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any], + var ts = dict["timeseries"] as? [String: Any] { + ts["data"] = deltaData + dict["timeseries"] = ts + #if DEBUG + let normalBytes = eventData.count + let deltaBytes = (try? JSONSerialization.data(withJSONObject: dict))?.count ?? 0 + let ratio = Double(normalBytes) / max(Double(deltaBytes), 1) + print(String(format: "[Timeseries] delta flush: signal=memory normal=%dB delta=%dB ratio=%.1fx", normalBytes, deltaBytes, ratio)) + #endif + writer.write(value: AnyEncodable(dict)) + } + } + return + } + featureScope.eventWriteContext { context, writer in let event = RUMTimeseriesMemoryEvent( dd: .init(), @@ -195,6 +236,44 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() + if enableDeltaCompression { + guard let deltaData = DeltaEncoder.encodeCPU(batch) else { + return + } + featureScope.eventWriteContext { context, writer in + let event = RUMTimeseriesCpuEvent( + dd: .init(), + application: .init(id: applicationID), + date: start / 1_000_000, + service: context.service, + session: .init(id: sessionID, type: sessionType), + source: .ios, + timeseries: .init( + data: batch, + end: end, + id: eventID, + name: "cpu", + start: start + ), + version: context.version + ) + if let eventData = try? JSONEncoder().encode(event), + var dict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any], + var ts = dict["timeseries"] as? [String: Any] { + ts["data"] = deltaData + dict["timeseries"] = ts + #if DEBUG + let normalBytes = eventData.count + let deltaBytes = (try? JSONSerialization.data(withJSONObject: dict))?.count ?? 0 + let ratio = Double(normalBytes) / max(Double(deltaBytes), 1) + print(String(format: "[Timeseries] delta flush: signal=cpu normal=%dB delta=%dB ratio=%.1fx", normalBytes, deltaBytes, ratio)) + #endif + writer.write(value: AnyEncodable(dict)) + } + } + return + } + featureScope.eventWriteContext { context, writer in let event = RUMTimeseriesCpuEvent( dd: .init(), diff --git a/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift b/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift new file mode 100644 index 0000000000..a3cfbaa23e --- /dev/null +++ b/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift @@ -0,0 +1,88 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import XCTest +import TestUtilities +import DatadogInternal +@testable import DatadogRUM + +class DeltaEncoderTests: XCTestCase { + // MARK: - Memory encoding + + func testEncodeMemory_returnsNilForEmptyBatch() { + XCTAssertNil(DeltaEncoder.encodeMemory([])) + } + + func testEncodeMemory_returnsNilForSingleSample() { + let sample = RUMTimeseriesMemoryEvent.Timeseries.Data( + dataPoint: .init(memoryMax: 100.0, memoryPercent: 10.0), + timestamp: 1_000_000_000 + ) + XCTAssertNil(DeltaEncoder.encodeMemory([sample])) + } + + func testEncodeMemory_correctDeltaEncoding() { + // Given + let samples: [RUMTimeseriesMemoryEvent.Timeseries.Data] = [ + .init(dataPoint: .init(memoryMax: 100.0, memoryPercent: 10.0), timestamp: 1_000_000_000), + .init(dataPoint: .init(memoryMax: 200.5, memoryPercent: 20.0), timestamp: 2_000_000_000), + .init(dataPoint: .init(memoryMax: 200.5, memoryPercent: 20.5), timestamp: 3_000_000_000) + ] + + // When + let result = try! XCTUnwrap(DeltaEncoder.encodeMemory(samples)) + + // Then + XCTAssertEqual(result["precision"] as? Int, 4) + + let ts = try! XCTUnwrap(result["ts"] as? [Int64]) + XCTAssertEqual(ts, [1_000_000_000, 1_000_000_000, 1_000_000_000]) + + // memory_max: 100*10000=1_000_000, (200.5-100)*10000=1_005_000, 0 + let memoryMax = try! XCTUnwrap(result["memory_max"] as? [Int64]) + XCTAssertEqual(memoryMax, [1_000_000, 1_005_000, 0]) + + // memory_percent: 10*10000=100_000, (20-10)*10000=100_000, (20.5-20)*10000=5_000 + let memoryPercent = try! XCTUnwrap(result["memory_percent"] as? [Int64]) + XCTAssertEqual(memoryPercent, [100_000, 100_000, 5_000]) + } + + // MARK: - CPU encoding + + func testEncodeCPU_returnsNilForEmptyBatch() { + XCTAssertNil(DeltaEncoder.encodeCPU([])) + } + + func testEncodeCPU_returnsNilForSingleSample() { + let sample = RUMTimeseriesCpuEvent.Timeseries.Data( + dataPoint: .init(cpuUsage: 42.5), + timestamp: 1_000_000_000 + ) + XCTAssertNil(DeltaEncoder.encodeCPU([sample])) + } + + func testEncodeCPU_correctDeltaEncoding() { + // Given + let samples: [RUMTimeseriesCpuEvent.Timeseries.Data] = [ + .init(dataPoint: .init(cpuUsage: 42.5), timestamp: 1_000_000_000), + .init(dataPoint: .init(cpuUsage: 43.0), timestamp: 2_000_000_000), + .init(dataPoint: .init(cpuUsage: 42.0), timestamp: 3_000_000_000) + ] + + // When + let result = try! XCTUnwrap(DeltaEncoder.encodeCPU(samples)) + + // Then + XCTAssertEqual(result["precision"] as? Int, 4) + + let ts = try! XCTUnwrap(result["ts"] as? [Int64]) + XCTAssertEqual(ts, [1_000_000_000, 1_000_000_000, 1_000_000_000]) + + // cpu_usage: 42.5*10000=425_000, (43.0-42.5)*10000=5_000, (42.0-43.0)*10000=-10_000 + let cpuUsage = try! XCTUnwrap(result["cpu_usage"] as? [Int64]) + XCTAssertEqual(cpuUsage, [425_000, 5_000, -10_000]) + } +} diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index e837e208da..ddbcc85206 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -212,6 +212,82 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(lastEvent.session.id, "session-2") } + // MARK: - Delta compression + + func testWhenDeltaCompressionEnabled_itWritesDeltaShapedEvent() { + // Given + memoryReader.vitalData = 1_000_000 + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 3, + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + enableDeltaCompression: true + ) + + // When + let expectation = self.expectation(description: "delta memory batch written") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + + collector.start(sessionID: "session-delta", applicationID: "app-delta", sessionType: .user) + waitForExpectations(timeout: 2) + collector.stop() + + // Then — written events are AnyEncodable (not RUMTimeseriesMemoryEvent) + XCTAssertTrue( + featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, + "Delta mode should not write typed RUMTimeseriesMemoryEvent" + ) + + let rawEvents = featureScope.eventsWritten + XCTAssertFalse(rawEvents.isEmpty, "Expected at least one delta event to be written") + + guard let anyEncodable = rawEvents.first as? AnyEncodable else { + XCTFail("Expected AnyEncodable event, got \(type(of: rawEvents.first))") + return + } + + let jsonData = try! JSONEncoder().encode(anyEncodable) + let dict = try! XCTUnwrap(try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) + let tsDict = try! XCTUnwrap(dict["timeseries"] as? [String: Any]) + let dataDict = try! XCTUnwrap(tsDict["data"] as? [String: Any]) + + XCTAssertNotNil(dataDict["precision"], "Delta payload must contain 'precision'") + XCTAssertNotNil(dataDict["ts"], "Delta payload must contain 'ts'") + XCTAssertNotNil(dataDict["memory_max"], "Delta payload must contain 'memory_max'") + XCTAssertNotNil(dataDict["memory_percent"], "Delta payload must contain 'memory_percent'") + } + + func testWhenDeltaCompressionEnabled_singleSampleBatchIsDropped() { + // Given + memoryReader.vitalData = 1_000_000 + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 100, // large batch — won't auto-flush + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + enableDeltaCompression: true + ) + + // When — collect exactly one sample then stop + let expectation = self.expectation(description: "one sample collected") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { expectation.fulfill() } + + collector.start(sessionID: "session-single", applicationID: "app-single", sessionType: .user) + waitForExpectations(timeout: 2) + + let stopExpectation = self.expectation(description: "stop completed") + collector.stop() + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.2) { stopExpectation.fulfill() } + waitForExpectations(timeout: 2) + + // Then — single-sample batches are dropped by DeltaEncoder + XCTAssertTrue(featureScope.eventsWritten.isEmpty, "Single-sample batch must be dropped in delta mode") + } + // MARK: - Timeseries range func testTimestampsAreMonotonicallyIncreasing() { From d3defb972dcc1362e150fa7c6c0e91763780050b Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 27 Apr 2026 15:56:08 +0200 Subject: [PATCH 038/102] RUM-13949 implement dual-flush with object and delta schemas for timeseries --- .../Sources/Models/RUM/RUMDataModels.swift | 3 + .../DataModels/RUMDataModels+objc.swift | 8 -- .../Sources/Timeseries/DeltaEncoder.swift | 2 +- .../TimeseriesSessionCollector.swift | 118 +++++------------- .../Tests/Timeseries/DeltaEncoderTests.swift | 4 +- .../TimeseriesSessionCollectorTests.swift | 89 +++++++++---- 6 files changed, 105 insertions(+), 119 deletions(-) diff --git a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift index 591cefba05..2f72012efc 100644 --- a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift +++ b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift @@ -5655,6 +5655,7 @@ public struct RUMTimeseriesCpuEvent: RUMDataModel { /// Wire-shape discriminator for the data field public let schema: String = "object-v2" + /// Timestamp of the first sample in nanoseconds from epoch public let start: Int64 @@ -5732,6 +5733,7 @@ public struct RUMTimeseriesCpuEvent: RUMDataModel { } } } + } /// View properties @@ -6252,6 +6254,7 @@ public struct RUMTimeseriesMemoryEvent: RUMDataModel { /// Wire-shape discriminator for the data field public let schema: String = "object-v2" + /// Timestamp of the first sample in nanoseconds from epoch public let start: Int64 diff --git a/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift b/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift index 2069782b48..efc0f5ffb2 100644 --- a/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift +++ b/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift @@ -6370,10 +6370,6 @@ public class objc_RUMTimeseriesCpuEventDDConfiguration: NSObject { root.swiftModel.dd.configuration!.profilingSampleRate as NSNumber? } - public var sessionReplayExperimentalFeatures: [String]? { - root.swiftModel.dd.configuration!.sessionReplayExperimentalFeatures - } - public var sessionReplaySampleRate: NSNumber? { root.swiftModel.dd.configuration!.sessionReplaySampleRate as NSNumber? } @@ -7295,10 +7291,6 @@ public class objc_RUMTimeseriesMemoryEventDDConfiguration: NSObject { root.swiftModel.dd.configuration!.profilingSampleRate as NSNumber? } - public var sessionReplayExperimentalFeatures: [String]? { - root.swiftModel.dd.configuration!.sessionReplayExperimentalFeatures - } - public var sessionReplaySampleRate: NSNumber? { root.swiftModel.dd.configuration!.sessionReplaySampleRate as NSNumber? } diff --git a/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift b/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift index 3501112ffc..25d807bac8 100644 --- a/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift +++ b/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift @@ -101,7 +101,7 @@ internal enum DeltaEncoder { return [ "precision": precision, "ts": ts, - "cpu_usage": cpuUsage + "value": cpuUsage ] } } diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 2a9ac6e0f5..458617c674 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -15,6 +15,9 @@ internal protocol TimeseriesCollecting: AnyObject { /// Collects memory and CPU samples at 1s intervals during a RUM session and flushes them /// as `RUMTimeseriesMemoryEvent` / `RUMTimeseriesCpuEvent` batches via the RUM feature scope. +/// +/// Each flush sends two events per metric: one with `schema: .object` (full array) and one with +/// the delta-compressed schema (`schema: .deltaObject` for memory, `schema: .deltaScalar` for CPU). internal class TimeseriesSessionCollector: TimeseriesCollecting { private let memoryReader: SamplingBasedVitalReader private let cpuUsageProvider: () -> Double? @@ -22,7 +25,6 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private let samplingInterval: TimeInterval private let featureScope: FeatureScope private let totalRAM: Double - private let enableDeltaCompression: Bool private var memoryBuffer: [RUMTimeseriesMemoryEvent.Timeseries.Data] = [] private var cpuBuffer: [RUMTimeseriesCpuEvent.Timeseries.Data] = [] @@ -39,8 +41,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { featureScope: FeatureScope, batchSize: Int = 30, samplingInterval: TimeInterval = 1, - cpuUsageProvider: (() -> Double?)? = nil, - enableDeltaCompression: Bool = false + cpuUsageProvider: (() -> Double?)? = nil ) { self.memoryReader = memoryReader self.batchSize = batchSize @@ -48,7 +49,6 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.featureScope = featureScope self.totalRAM = Double(ProcessInfo.processInfo.physicalMemory) self.cpuUsageProvider = cpuUsageProvider ?? { TimeseriesSessionCollector.processCPU() } - self.enableDeltaCompression = enableDeltaCompression } /// Per-process CPU as a percentage (0–100+), summed across all app threads. @@ -164,46 +164,9 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() - if enableDeltaCompression { - guard let deltaData = DeltaEncoder.encodeMemory(batch) else { - return - } - featureScope.eventWriteContext { context, writer in - let event = RUMTimeseriesMemoryEvent( - dd: .init(), - application: .init(id: applicationID), - date: start / 1_000_000, - service: context.service, - session: .init(id: sessionID, type: sessionType), - source: .ios, - timeseries: .init( - data: batch, - end: end, - id: eventID, - name: "memory", - start: start - ), - version: context.version - ) - if let eventData = try? JSONEncoder().encode(event), - var dict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any], - var ts = dict["timeseries"] as? [String: Any] { - ts["data"] = deltaData - dict["timeseries"] = ts - #if DEBUG - let normalBytes = eventData.count - let deltaBytes = (try? JSONSerialization.data(withJSONObject: dict))?.count ?? 0 - let ratio = Double(normalBytes) / max(Double(deltaBytes), 1) - print(String(format: "[Timeseries] delta flush: signal=memory normal=%dB delta=%dB ratio=%.1fx", normalBytes, deltaBytes, ratio)) - #endif - writer.write(value: AnyEncodable(dict)) - } - } - return - } - featureScope.eventWriteContext { context, writer in - let event = RUMTimeseriesMemoryEvent( + // object schema — full array of data points + let objectEvent = RUMTimeseriesMemoryEvent( dd: .init(), application: .init(id: applicationID), date: start / 1_000_000, @@ -215,11 +178,23 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { end: end, id: eventID, name: "memory", + schema: .object, start: start ), version: context.version ) - writer.write(value: event) + writer.write(value: objectEvent) + + // delta-object schema — columnar delta-compressed payload + if let deltaData = DeltaEncoder.encodeMemory(batch), + let eventData = try? JSONEncoder().encode(objectEvent), + var dict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any], + var ts = dict["timeseries"] as? [String: Any] { + ts["schema"] = "delta-object" + ts["data"] = deltaData + dict["timeseries"] = ts + writer.write(value: AnyEncodable(dict)) + } } } @@ -236,46 +211,9 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() - if enableDeltaCompression { - guard let deltaData = DeltaEncoder.encodeCPU(batch) else { - return - } - featureScope.eventWriteContext { context, writer in - let event = RUMTimeseriesCpuEvent( - dd: .init(), - application: .init(id: applicationID), - date: start / 1_000_000, - service: context.service, - session: .init(id: sessionID, type: sessionType), - source: .ios, - timeseries: .init( - data: batch, - end: end, - id: eventID, - name: "cpu", - start: start - ), - version: context.version - ) - if let eventData = try? JSONEncoder().encode(event), - var dict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any], - var ts = dict["timeseries"] as? [String: Any] { - ts["data"] = deltaData - dict["timeseries"] = ts - #if DEBUG - let normalBytes = eventData.count - let deltaBytes = (try? JSONSerialization.data(withJSONObject: dict))?.count ?? 0 - let ratio = Double(normalBytes) / max(Double(deltaBytes), 1) - print(String(format: "[Timeseries] delta flush: signal=cpu normal=%dB delta=%dB ratio=%.1fx", normalBytes, deltaBytes, ratio)) - #endif - writer.write(value: AnyEncodable(dict)) - } - } - return - } - featureScope.eventWriteContext { context, writer in - let event = RUMTimeseriesCpuEvent( + // object schema — full array of data points + let objectEvent = RUMTimeseriesCpuEvent( dd: .init(), application: .init(id: applicationID), date: start / 1_000_000, @@ -287,11 +225,23 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { end: end, id: eventID, name: "cpu", + schema: .object, start: start ), version: context.version ) - writer.write(value: event) + writer.write(value: objectEvent) + + // delta-scalar schema — columnar delta-compressed payload + if let deltaData = DeltaEncoder.encodeCPU(batch), + let eventData = try? JSONEncoder().encode(objectEvent), + var dict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any], + var ts = dict["timeseries"] as? [String: Any] { + ts["schema"] = "delta-scalar" + ts["data"] = deltaData + dict["timeseries"] = ts + writer.write(value: AnyEncodable(dict)) + } } } } diff --git a/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift b/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift index a3cfbaa23e..84d4acffdf 100644 --- a/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift +++ b/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift @@ -81,8 +81,8 @@ class DeltaEncoderTests: XCTestCase { let ts = try! XCTUnwrap(result["ts"] as? [Int64]) XCTAssertEqual(ts, [1_000_000_000, 1_000_000_000, 1_000_000_000]) - // cpu_usage: 42.5*10000=425_000, (43.0-42.5)*10000=5_000, (42.0-43.0)*10000=-10_000 - let cpuUsage = try! XCTUnwrap(result["cpu_usage"] as? [Int64]) + // value: 42.5*10000=425_000, (43.0-42.5)*10000=5_000, (42.0-43.0)*10000=-10_000 + let cpuUsage = try! XCTUnwrap(result["value"] as? [Int64]) XCTAssertEqual(cpuUsage, [425_000, 5_000, -10_000]) } } diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index ddbcc85206..3a3a303940 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -212,9 +212,9 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(lastEvent.session.id, "session-2") } - // MARK: - Delta compression + // MARK: - Dual-flush (object + delta) - func testWhenDeltaCompressionEnabled_itWritesDeltaShapedEvent() { + func testFlushWritesBothObjectAndDeltaEventsForMemory() { // Given memoryReader.vitalData = 1_000_000 let collector = TimeseriesSessionCollector( @@ -222,45 +222,82 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 3, samplingInterval: 0.05, - cpuUsageProvider: { nil }, - enableDeltaCompression: true + cpuUsageProvider: { nil } ) // When - let expectation = self.expectation(description: "delta memory batch written") + let expectation = self.expectation(description: "memory batch written") expectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } - collector.start(sessionID: "session-delta", applicationID: "app-delta", sessionType: .user) + collector.start(sessionID: "session-dual", applicationID: "app-dual", sessionType: .user) waitForExpectations(timeout: 2) collector.stop() - // Then — written events are AnyEncodable (not RUMTimeseriesMemoryEvent) - XCTAssertTrue( - featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, - "Delta mode should not write typed RUMTimeseriesMemoryEvent" - ) + // Then — both a typed object event and an AnyEncodable delta event are written + let typedEvents = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + XCTAssertFalse(typedEvents.isEmpty, "Expected object-schema memory events") + XCTAssertEqual(typedEvents[0].timeseries.schema, .object) let rawEvents = featureScope.eventsWritten - XCTAssertFalse(rawEvents.isEmpty, "Expected at least one delta event to be written") + let anyEncodableEvents = rawEvents.compactMap { $0 as? AnyEncodable } + XCTAssertFalse(anyEncodableEvents.isEmpty, "Expected delta-schema AnyEncodable event") - guard let anyEncodable = rawEvents.first as? AnyEncodable else { - XCTFail("Expected AnyEncodable event, got \(type(of: rawEvents.first))") - return - } - - let jsonData = try! JSONEncoder().encode(anyEncodable) + let jsonData = try! JSONEncoder().encode(anyEncodableEvents[0]) let dict = try! XCTUnwrap(try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) let tsDict = try! XCTUnwrap(dict["timeseries"] as? [String: Any]) - let dataDict = try! XCTUnwrap(tsDict["data"] as? [String: Any]) + XCTAssertEqual(tsDict["schema"] as? String, "delta-object") + + let dataDict = try! XCTUnwrap(tsDict["data"] as? [String: Any]) XCTAssertNotNil(dataDict["precision"], "Delta payload must contain 'precision'") XCTAssertNotNil(dataDict["ts"], "Delta payload must contain 'ts'") XCTAssertNotNil(dataDict["memory_max"], "Delta payload must contain 'memory_max'") XCTAssertNotNil(dataDict["memory_percent"], "Delta payload must contain 'memory_percent'") } - func testWhenDeltaCompressionEnabled_singleSampleBatchIsDropped() { + func testFlushWritesBothObjectAndDeltaEventsForCPU() { + // Given + memoryReader.vitalData = nil + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 3, + samplingInterval: 0.05, + cpuUsageProvider: { 50.0 } + ) + + // When + let expectation = self.expectation(description: "cpu batch written") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + + collector.start(sessionID: "session-dual-cpu", applicationID: "app-dual", sessionType: .user) + waitForExpectations(timeout: 2) + collector.stop() + + // Then — both a typed object event and an AnyEncodable delta event are written + let typedEvents = featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self) + XCTAssertFalse(typedEvents.isEmpty, "Expected object-schema CPU events") + XCTAssertEqual(typedEvents[0].timeseries.schema, .object) + + let rawEvents = featureScope.eventsWritten + let anyEncodableEvents = rawEvents.compactMap { $0 as? AnyEncodable } + XCTAssertFalse(anyEncodableEvents.isEmpty, "Expected delta-schema AnyEncodable event") + + let jsonData = try! JSONEncoder().encode(anyEncodableEvents[0]) + let dict = try! XCTUnwrap(try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) + let tsDict = try! XCTUnwrap(dict["timeseries"] as? [String: Any]) + + XCTAssertEqual(tsDict["schema"] as? String, "delta-scalar") + + let dataDict = try! XCTUnwrap(tsDict["data"] as? [String: Any]) + XCTAssertNotNil(dataDict["precision"], "Delta payload must contain 'precision'") + XCTAssertNotNil(dataDict["ts"], "Delta payload must contain 'ts'") + XCTAssertNotNil(dataDict["value"], "Delta payload must contain 'value'") + } + + func testSingleSampleBatchWritesObjectEventButNoDeltaEvent() { // Given memoryReader.vitalData = 1_000_000 let collector = TimeseriesSessionCollector( @@ -268,8 +305,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 100, // large batch — won't auto-flush samplingInterval: 0.05, - cpuUsageProvider: { nil }, - enableDeltaCompression: true + cpuUsageProvider: { nil } ) // When — collect exactly one sample then stop @@ -284,8 +320,13 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.2) { stopExpectation.fulfill() } waitForExpectations(timeout: 2) - // Then — single-sample batches are dropped by DeltaEncoder - XCTAssertTrue(featureScope.eventsWritten.isEmpty, "Single-sample batch must be dropped in delta mode") + // Then — object event is written, but DeltaEncoder requires >1 samples so no delta event + XCTAssertFalse( + featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, + "Object-schema event should be written even for a single sample" + ) + let anyEncodableEvents = featureScope.eventsWritten.compactMap { $0 as? AnyEncodable } + XCTAssertTrue(anyEncodableEvents.isEmpty, "Delta event must not be written for a single-sample batch") } // MARK: - Timeseries range From 1b6449470c4e73bde95ebc89444b80356448d8ef Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 28 Apr 2026 10:41:54 +0200 Subject: [PATCH 039/102] RUM-13949 replace dual-flush with per-session coin flip for schema selection --- .../TimeseriesSessionCollector.swift | 31 +++-- .../TimeseriesSessionCollectorTests.swift | 125 ++++++++++++------ 2 files changed, 101 insertions(+), 55 deletions(-) diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 458617c674..11902a3b6c 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -16,11 +16,12 @@ internal protocol TimeseriesCollecting: AnyObject { /// Collects memory and CPU samples at 1s intervals during a RUM session and flushes them /// as `RUMTimeseriesMemoryEvent` / `RUMTimeseriesCpuEvent` batches via the RUM feature scope. /// -/// Each flush sends two events per metric: one with `schema: .object` (full array) and one with -/// the delta-compressed schema (`schema: .deltaObject` for memory, `schema: .deltaScalar` for CPU). +/// At session start a coin is flipped: 50% of sessions send full-array `object` schema events, +/// 50% send delta-compressed events (`delta-object` for memory, `delta-scalar` for CPU). internal class TimeseriesSessionCollector: TimeseriesCollecting { private let memoryReader: SamplingBasedVitalReader private let cpuUsageProvider: () -> Double? + private let compressionSampler: () -> Bool private let batchSize: Int private let samplingInterval: TimeInterval private let featureScope: FeatureScope @@ -31,6 +32,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private var sessionID: String = "" private var applicationID: String = "" private var sessionType: RUMSessionType = .user + private var useDeltaCompression: Bool = false private var timer: DispatchSourceTimer? /// All buffer mutations and timer events run on this queue. @@ -41,7 +43,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { featureScope: FeatureScope, batchSize: Int = 30, samplingInterval: TimeInterval = 1, - cpuUsageProvider: (() -> Double?)? = nil + cpuUsageProvider: (() -> Double?)? = nil, + compressionSampler: @escaping () -> Bool = { Bool.random() } ) { self.memoryReader = memoryReader self.batchSize = batchSize @@ -49,6 +52,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.featureScope = featureScope self.totalRAM = Double(ProcessInfo.processInfo.physicalMemory) self.cpuUsageProvider = cpuUsageProvider ?? { TimeseriesSessionCollector.processCPU() } + self.compressionSampler = compressionSampler } /// Per-process CPU as a percentage (0–100+), summed across all app threads. @@ -88,7 +92,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { return total } - /// Resets state and starts a 1s sampling timer for the new session. + /// Resets state, flips the compression coin, and starts a 1s sampling timer for the new session. func start(sessionID: String, applicationID: String, sessionType: RUMSessionType) { queue.async { [weak self] in guard let self = self else { @@ -97,6 +101,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.sessionID = sessionID self.applicationID = applicationID self.sessionType = sessionType + self.useDeltaCompression = self.compressionSampler() self.memoryBuffer = [] self.cpuBuffer = [] @@ -163,9 +168,9 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let start = batch[0].timestamp let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() + let useDelta = self.useDeltaCompression featureScope.eventWriteContext { context, writer in - // object schema — full array of data points let objectEvent = RUMTimeseriesMemoryEvent( dd: .init(), application: .init(id: applicationID), @@ -183,10 +188,9 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { ), version: context.version ) - writer.write(value: objectEvent) - // delta-object schema — columnar delta-compressed payload - if let deltaData = DeltaEncoder.encodeMemory(batch), + if useDelta, + let deltaData = DeltaEncoder.encodeMemory(batch), let eventData = try? JSONEncoder().encode(objectEvent), var dict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any], var ts = dict["timeseries"] as? [String: Any] { @@ -194,6 +198,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { ts["data"] = deltaData dict["timeseries"] = ts writer.write(value: AnyEncodable(dict)) + } else { + writer.write(value: objectEvent) } } } @@ -210,9 +216,9 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let start = batch[0].timestamp let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() + let useDelta = self.useDeltaCompression featureScope.eventWriteContext { context, writer in - // object schema — full array of data points let objectEvent = RUMTimeseriesCpuEvent( dd: .init(), application: .init(id: applicationID), @@ -230,10 +236,9 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { ), version: context.version ) - writer.write(value: objectEvent) - // delta-scalar schema — columnar delta-compressed payload - if let deltaData = DeltaEncoder.encodeCPU(batch), + if useDelta, + let deltaData = DeltaEncoder.encodeCPU(batch), let eventData = try? JSONEncoder().encode(objectEvent), var dict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any], var ts = dict["timeseries"] as? [String: Any] { @@ -241,6 +246,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { ts["data"] = deltaData dict["timeseries"] = ts writer.write(value: AnyEncodable(dict)) + } else { + writer.write(value: objectEvent) } } } diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 3a3a303940..ff160aac04 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -212,9 +212,9 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(lastEvent.session.id, "session-2") } - // MARK: - Dual-flush (object + delta) + // MARK: - Schema coin flip - func testFlushWritesBothObjectAndDeltaEventsForMemory() { + func testWhenDeltaCompressionSampled_itWritesDeltaEventForMemory() { // Given memoryReader.vitalData = 1_000_000 let collector = TimeseriesSessionCollector( @@ -222,41 +222,63 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 3, samplingInterval: 0.05, - cpuUsageProvider: { nil } + cpuUsageProvider: { nil }, + compressionSampler: { true } ) - // When let expectation = self.expectation(description: "memory batch written") expectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } - collector.start(sessionID: "session-dual", applicationID: "app-dual", sessionType: .user) + collector.start(sessionID: "session-delta", applicationID: "app-delta", sessionType: .user) waitForExpectations(timeout: 2) collector.stop() - // Then — both a typed object event and an AnyEncodable delta event are written + // Then — AnyEncodable delta-object event written, no typed object event let typedEvents = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) - XCTAssertFalse(typedEvents.isEmpty, "Expected object-schema memory events") - XCTAssertEqual(typedEvents[0].timeseries.schema, .object) + XCTAssertTrue(typedEvents.isEmpty, "Object-schema typed event must not be written when delta is sampled") - let rawEvents = featureScope.eventsWritten - let anyEncodableEvents = rawEvents.compactMap { $0 as? AnyEncodable } + let anyEncodableEvents = featureScope.eventsWritten.compactMap { $0 as? AnyEncodable } XCTAssertFalse(anyEncodableEvents.isEmpty, "Expected delta-schema AnyEncodable event") let jsonData = try! JSONEncoder().encode(anyEncodableEvents[0]) let dict = try! XCTUnwrap(try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) let tsDict = try! XCTUnwrap(dict["timeseries"] as? [String: Any]) - XCTAssertEqual(tsDict["schema"] as? String, "delta-object") - let dataDict = try! XCTUnwrap(tsDict["data"] as? [String: Any]) - XCTAssertNotNil(dataDict["precision"], "Delta payload must contain 'precision'") - XCTAssertNotNil(dataDict["ts"], "Delta payload must contain 'ts'") - XCTAssertNotNil(dataDict["memory_max"], "Delta payload must contain 'memory_max'") - XCTAssertNotNil(dataDict["memory_percent"], "Delta payload must contain 'memory_percent'") + XCTAssertNotNil(dataDict["ts"]) + XCTAssertNotNil(dataDict["memory_max"]) + XCTAssertNotNil(dataDict["memory_percent"]) + } + + func testWhenObjectSchemaSampled_itWritesObjectEventForMemory() { + // Given + memoryReader.vitalData = 1_000_000 + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 3, + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + compressionSampler: { false } + ) + + let expectation = self.expectation(description: "memory batch written") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + + collector.start(sessionID: "session-object", applicationID: "app-object", sessionType: .user) + waitForExpectations(timeout: 2) + collector.stop() + + // Then — typed object event written, no AnyEncodable delta event + let typedEvents = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + XCTAssertFalse(typedEvents.isEmpty, "Expected object-schema typed memory event") + XCTAssertEqual(typedEvents[0].timeseries.schema, .object) + XCTAssertTrue(featureScope.eventsWritten.compactMap { $0 as? AnyEncodable }.isEmpty) } - func testFlushWritesBothObjectAndDeltaEventsForCPU() { + func testWhenDeltaCompressionSampled_itWritesDeltaEventForCPU() { // Given memoryReader.vitalData = nil let collector = TimeseriesSessionCollector( @@ -264,55 +286,76 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 3, samplingInterval: 0.05, - cpuUsageProvider: { 50.0 } + cpuUsageProvider: { 50.0 }, + compressionSampler: { true } ) - // When let expectation = self.expectation(description: "cpu batch written") expectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } - collector.start(sessionID: "session-dual-cpu", applicationID: "app-dual", sessionType: .user) + collector.start(sessionID: "session-delta-cpu", applicationID: "app-delta", sessionType: .user) waitForExpectations(timeout: 2) collector.stop() - // Then — both a typed object event and an AnyEncodable delta event are written + // Then — AnyEncodable delta-scalar event written, no typed object event let typedEvents = featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self) - XCTAssertFalse(typedEvents.isEmpty, "Expected object-schema CPU events") - XCTAssertEqual(typedEvents[0].timeseries.schema, .object) + XCTAssertTrue(typedEvents.isEmpty, "Object-schema typed event must not be written when delta is sampled") - let rawEvents = featureScope.eventsWritten - let anyEncodableEvents = rawEvents.compactMap { $0 as? AnyEncodable } + let anyEncodableEvents = featureScope.eventsWritten.compactMap { $0 as? AnyEncodable } XCTAssertFalse(anyEncodableEvents.isEmpty, "Expected delta-schema AnyEncodable event") let jsonData = try! JSONEncoder().encode(anyEncodableEvents[0]) let dict = try! XCTUnwrap(try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) let tsDict = try! XCTUnwrap(dict["timeseries"] as? [String: Any]) - XCTAssertEqual(tsDict["schema"] as? String, "delta-scalar") - let dataDict = try! XCTUnwrap(tsDict["data"] as? [String: Any]) - XCTAssertNotNil(dataDict["precision"], "Delta payload must contain 'precision'") - XCTAssertNotNil(dataDict["ts"], "Delta payload must contain 'ts'") - XCTAssertNotNil(dataDict["value"], "Delta payload must contain 'value'") + XCTAssertNotNil(dataDict["ts"]) + XCTAssertNotNil(dataDict["value"]) } - func testSingleSampleBatchWritesObjectEventButNoDeltaEvent() { + func testWhenObjectSchemaSampled_itWritesObjectEventForCPU() { // Given + memoryReader.vitalData = nil + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 3, + samplingInterval: 0.05, + cpuUsageProvider: { 50.0 }, + compressionSampler: { false } + ) + + let expectation = self.expectation(description: "cpu batch written") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + + collector.start(sessionID: "session-object-cpu", applicationID: "app-object", sessionType: .user) + waitForExpectations(timeout: 2) + collector.stop() + + // Then — typed object event written, no AnyEncodable delta event + let typedEvents = featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self) + XCTAssertFalse(typedEvents.isEmpty, "Expected object-schema typed CPU event") + XCTAssertEqual(typedEvents[0].timeseries.schema, .object) + XCTAssertTrue(featureScope.eventsWritten.compactMap { $0 as? AnyEncodable }.isEmpty) + } + + func testWhenDeltaCompressionSampledWithSingleSample_itFallsBackToObjectEvent() { + // Given — delta sampled but only 1 sample: DeltaEncoder returns nil, falls back to object memoryReader.vitalData = 1_000_000 let collector = TimeseriesSessionCollector( memoryReader: memoryReader, featureScope: featureScope, - batchSize: 100, // large batch — won't auto-flush + batchSize: 100, samplingInterval: 0.05, - cpuUsageProvider: { nil } + cpuUsageProvider: { nil }, + compressionSampler: { true } ) - // When — collect exactly one sample then stop let expectation = self.expectation(description: "one sample collected") DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { expectation.fulfill() } - - collector.start(sessionID: "session-single", applicationID: "app-single", sessionType: .user) + collector.start(sessionID: "session-fallback", applicationID: "app-fallback", sessionType: .user) waitForExpectations(timeout: 2) let stopExpectation = self.expectation(description: "stop completed") @@ -320,13 +363,9 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.2) { stopExpectation.fulfill() } waitForExpectations(timeout: 2) - // Then — object event is written, but DeltaEncoder requires >1 samples so no delta event - XCTAssertFalse( - featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, - "Object-schema event should be written even for a single sample" - ) - let anyEncodableEvents = featureScope.eventsWritten.compactMap { $0 as? AnyEncodable } - XCTAssertTrue(anyEncodableEvents.isEmpty, "Delta event must not be written for a single-sample batch") + // Then — falls back to object event, no AnyEncodable + XCTAssertFalse(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty) + XCTAssertTrue(featureScope.eventsWritten.compactMap { $0 as? AnyEncodable }.isEmpty) } // MARK: - Timeseries range From e7e41601fdf0a0378be69bcbc0c48b7576363341 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 28 Apr 2026 15:01:41 +0200 Subject: [PATCH 040/102] fix function_default_parameter_at_end lint violation in RUMScopeDependencies --- DatadogRUM/Sources/Feature/RUMFeature.swift | 20 +++++++++---------- .../Scopes/RUMScopeDependencies.swift | 4 ++-- .../Mocks/DatadogRUM/RUMFeatureMocks.swift | 8 ++++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index aef325a2ef..ada3f9defe 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -155,15 +155,6 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider telemetry: core.telemetry ) }, - timeseriesCollector: { - guard configuration.enableTimeseries, let vitalsReaders = configuration.vitalsUpdateFrequency.map({ - VitalsReaders(frequency: $0.timeInterval, telemetry: core.telemetry) - }) else { return nil } - return TimeseriesSessionCollector( - memoryReader: vitalsReaders.memory, - featureScope: featureScope - ) - }(), accessibilityReader: accessibilityReader, onSessionUpdate: onSessionUpdate, viewCache: ViewCache(dateProvider: configuration.dateProvider), @@ -206,7 +197,16 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider predicate: nextViewActionPredicate ) }, - sessionType: configuration.sessionTypeOverride.flatMap { RUMSessionType(rawValue: $0) } + sessionType: configuration.sessionTypeOverride.flatMap { RUMSessionType(rawValue: $0) }, + timeseriesCollector: { + guard configuration.enableTimeseries, let vitalsReaders = configuration.vitalsUpdateFrequency.map({ + VitalsReaders(frequency: $0.timeInterval, telemetry: core.telemetry) + }) else { return nil } + return TimeseriesSessionCollector( + memoryReader: vitalsReaders.memory, + featureScope: featureScope + ) + }() ) self.monitor = Monitor( diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift index b04d9c67b2..1057c1d6cb 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift @@ -89,7 +89,6 @@ internal struct RUMScopeDependencies { firstFrameReader: RenderLoopReader, viewHitchesReaderFactory: @escaping () -> (ViewHitchesModel & RenderLoopReader)?, vitalsReaders: VitalsReaders?, - timeseriesCollector: TimeseriesCollecting? = nil, accessibilityReader: AccessibilityReading?, onSessionUpdate: @escaping RUM.SessionUpdater, viewCache: ViewCache, @@ -100,7 +99,8 @@ internal struct RUMScopeDependencies { watchdogTermination: WatchdogTerminationMonitor?, networkSettledMetricFactory: @escaping (Date, String) -> TNSMetricTracking, interactionToNextViewMetricFactory: @escaping () -> INVMetricTracking?, - sessionType: RUMSessionType? + sessionType: RUMSessionType?, + timeseriesCollector: TimeseriesCollecting? = nil ) { self.featureScope = featureScope self.rumApplicationID = rumApplicationID diff --git a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift index a1f154abec..ebe7bef1df 100644 --- a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift +++ b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift @@ -1147,7 +1147,6 @@ extension RUMScopeDependencies { firstFrameReader: RenderLoopReader = FirstFrameReader(dateProvider: DateProviderMock(), mediaTimeProvider: MediaTimeProviderMock()), viewHitchesReaderFactory: @escaping () -> (ViewHitchesModel & RenderLoopReader)? = { ViewHitchesMock.mockAny() }, vitalsReaders: VitalsReaders? = nil, - timeseriesCollector: TimeseriesCollecting? = nil, accessibilityReader: AccessibilityReading? = nil, onSessionUpdate: @escaping RUM.SessionUpdater = mockNoOpSessionUpdater(), viewCache: ViewCache = ViewCache(dateProvider: SystemDateProvider()), @@ -1164,7 +1163,8 @@ extension RUMScopeDependencies { interactionToNextViewMetricFactory: @escaping () -> INVMetricTracking = { INVMetric(predicate: TimeBasedINVActionPredicate()) }, - sessionType: RUMSessionType? = nil + sessionType: RUMSessionType? = nil, + timeseriesCollector: TimeseriesCollecting? = nil ) -> RUMScopeDependencies { return RUMScopeDependencies( featureScope: featureScope, @@ -1184,7 +1184,6 @@ extension RUMScopeDependencies { firstFrameReader: firstFrameReader, viewHitchesReaderFactory: viewHitchesReaderFactory, vitalsReaders: vitalsReaders, - timeseriesCollector: timeseriesCollector, accessibilityReader: accessibilityReader, onSessionUpdate: onSessionUpdate, viewCache: viewCache, @@ -1195,7 +1194,8 @@ extension RUMScopeDependencies { watchdogTermination: watchdogTermination, networkSettledMetricFactory: networkSettledMetricFactory, interactionToNextViewMetricFactory: interactionToNextViewMetricFactory, - sessionType: sessionType + sessionType: sessionType, + timeseriesCollector: timeseriesCollector ) } From 4bb1df8381d73b9b19c939ebbeff3092663cf7d6 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 28 Apr 2026 16:39:37 +0200 Subject: [PATCH 041/102] add license headers to DatadogTimeseries source and test files --- DatadogTimeseries/Scripts/generate-fixture.py | 5 +++++ .../DatadogTimeseries/Core/TimeseriesBatcher.swift | 5 +++++ .../Sources/DatadogTimeseries/Core/TimeseriesConfig.swift | 5 +++++ .../DatadogTimeseries/Core/TimeseriesEventBuilder.swift | 5 +++++ .../DatadogTimeseries/DataProvider/CSVDataProvider.swift | 8 +++++++- .../DatadogTimeseries/DataProvider/DataProvider.swift | 5 +++++ .../DatadogTimeseries/Encoding/TimeseriesEncoder.swift | 5 +++++ .../Sources/DatadogTimeseries/Models/Sample.swift | 5 +++++ .../DatadogTimeseries/Models/TimeseriesEvent.swift | 5 +++++ .../Sources/DatadogTimeseries/Models/TimeseriesName.swift | 5 +++++ .../Sources/DatadogTimeseries/TimeseriesPipeline.swift | 5 +++++ .../DatadogTimeseriesTests/CSVDataProviderTests.swift | 8 +++++++- .../EndToEndVerificationTests.swift | 5 +++++ .../Filters/DeadbandFilterTests.swift | 5 +++++ .../Filters/FilterComparisonTests.swift | 5 +++++ .../Filters/PassThroughFilterTests.swift | 5 +++++ .../Filters/WindowAggregateFilterTests.swift | 5 +++++ .../Tests/DatadogTimeseriesTests/SkipSampleTests.swift | 5 +++++ .../DatadogTimeseriesTests/TimeseriesBatcherTests.swift | 5 +++++ .../DatadogTimeseriesTests/TimeseriesEncoderTests.swift | 5 +++++ .../TimeseriesEventBuilderTests.swift | 5 +++++ .../TimeseriesEventModelTests.swift | 5 +++++ .../DatadogTimeseriesTests/TimeseriesPipelineTests.swift | 8 +++++++- 23 files changed, 121 insertions(+), 3 deletions(-) diff --git a/DatadogTimeseries/Scripts/generate-fixture.py b/DatadogTimeseries/Scripts/generate-fixture.py index 0c00c7f0ef..c164def3d1 100644 --- a/DatadogTimeseries/Scripts/generate-fixture.py +++ b/DatadogTimeseries/Scripts/generate-fixture.py @@ -1,4 +1,9 @@ #!/usr/bin/env python3 +# ----------------------------------------------------------- +# Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2019-Present Datadog, Inc. +# ----------------------------------------------------------- """ Generates a realistic 60-sample CSV fixture for DatadogTimeseries tests. diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesBatcher.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesBatcher.swift index 9ddc6498e3..a22dd10050 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesBatcher.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesBatcher.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import Foundation class TimeseriesBatcher { diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift index ec8e9083a1..46a57fee66 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import Foundation public struct TimeseriesConfig { diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift index 855884ec27..c59a966c62 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import Foundation struct TimeseriesEventBuilder { diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift index 19afa4a465..cee7a7acf5 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import Foundation public class CSVDataProvider: DataProvider { @@ -6,7 +11,8 @@ public class CSVDataProvider: DataProvider { public init(csvContent: String, metric: TimeseriesName) { var parsed: [Sample] = [] - let lines = csvContent.components(separatedBy: "\n") + let lines = csvContent.components(separatedBy: " +") for line in lines.dropFirst() { // skip header let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift index 235f82a3be..29198c7f35 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import Foundation public protocol DataProvider { diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Encoding/TimeseriesEncoder.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Encoding/TimeseriesEncoder.swift index d6bf001c84..4a25ed6cc0 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Encoding/TimeseriesEncoder.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Encoding/TimeseriesEncoder.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import Foundation struct TimeseriesEncoder { diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift index df1348be2e..efbca8844e 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import Foundation /// A single timestamped performance sample. diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift index 196e12a191..d794a862e0 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import Foundation public struct TimeseriesEvent: Codable { diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift index 1c168d8438..932711e348 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import Foundation public enum TimeseriesName: String, Codable { diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift b/DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift index 7b46fb3f1a..f30abd4aa0 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import Foundation public struct TimeseriesPipeline { diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift index 7d265798dd..88ebdd4e7c 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import XCTest @testable import DatadogTimeseries @@ -52,7 +57,8 @@ final class CSVDataProviderTests: XCTestCase { } func testReturnsNilForEmptyCSV() { - let csv = "timestamp,metric,value\n" + let csv = "timestamp,metric,value +" let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) XCTAssertNil(provider.read()) } diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/EndToEndVerificationTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/EndToEndVerificationTests.swift index 37b30919af..feab150fe4 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/EndToEndVerificationTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/EndToEndVerificationTests.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import XCTest @testable import DatadogTimeseries diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/DeadbandFilterTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/DeadbandFilterTests.swift index ab06c61da0..3864a5567a 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/DeadbandFilterTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/DeadbandFilterTests.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import XCTest @testable import DatadogTimeseries diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift index af6155547e..70577300b2 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import XCTest @testable import DatadogTimeseries diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/PassThroughFilterTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/PassThroughFilterTests.swift index 641b64f215..d883a72840 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/PassThroughFilterTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/PassThroughFilterTests.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import XCTest @testable import DatadogTimeseries diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/WindowAggregateFilterTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/WindowAggregateFilterTests.swift index 1398bd0f65..f9db7f2df5 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/WindowAggregateFilterTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/WindowAggregateFilterTests.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import XCTest @testable import DatadogTimeseries diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift index edd7548445..60956a30b1 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import XCTest @testable import DatadogTimeseries diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesBatcherTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesBatcherTests.swift index 93960929f5..3fade2ec24 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesBatcherTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesBatcherTests.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import XCTest @testable import DatadogTimeseries diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift index 482e33f48b..5235056ae5 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import XCTest @testable import DatadogTimeseries diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift index aab8e74d90..47863e82ca 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import XCTest @testable import DatadogTimeseries diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift index a7c56feefb..659d0bd381 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import XCTest @testable import DatadogTimeseries diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift index 87a6e416b0..305fd09b74 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ import XCTest @testable import DatadogTimeseries @@ -60,7 +65,8 @@ final class TimeseriesPipelineTests: XCTestCase { } func testEmptyProviderProducesNoOutput() throws { - let csv = "timestamp,metric,value\n" + let csv = "timestamp,metric,value +" let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) let pipeline = TimeseriesPipeline( provider: provider, From 264c21c90376bfe6bc9bf9507e7e9ab03d0eda9c Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 28 Apr 2026 17:15:12 +0200 Subject: [PATCH 042/102] Fix: rum events format --- DatadogInternal/Sources/Context/DatadogSite.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DatadogInternal/Sources/Context/DatadogSite.swift b/DatadogInternal/Sources/Context/DatadogSite.swift index f0cf590402..11f023bb85 100644 --- a/DatadogInternal/Sources/Context/DatadogSite.swift +++ b/DatadogInternal/Sources/Context/DatadogSite.swift @@ -40,7 +40,7 @@ extension DatadogSite { public var endpoint: URL { switch self { // swiftlint:disable force_unwrapping - case .us1: return URL(string: "https://browser-intake-datadoghq.com/")! + case .us1: return URL(string: "https://rum.browser-intake-datad0g.com/")! // TODO: RUM-13949 remove - staging override for timeseries testing case .us3: return URL(string: "https://browser-intake-us3-datadoghq.com/")! case .us5: return URL(string: "https://browser-intake-us5-datadoghq.com/")! case .eu1: return URL(string: "https://browser-intake-datadoghq.eu/")! From 8806dda52064aab9d0593d4991f2ef18b2d96141 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 29 Apr 2026 10:48:19 +0200 Subject: [PATCH 043/102] fix ObjC generator to stop at array-item boundaries for nested struct root resolution --- .../Sources/DataModels/RUMDataModels+objc.swift | 4 ++-- .../ObjcInteropType+reflection.swift | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift b/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift index efc0f5ffb2..a2aa80df61 100644 --- a/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift +++ b/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift @@ -7048,7 +7048,7 @@ public class objc_RUMTimeseriesCpuEventTimeseries: NSObject { public class objc_RUMTimeseriesCpuEventTimeseriesData: NSObject { internal let root: objc_RUMTimeseriesCpuEvent - internal init(root: objc_RUMTimeseriesCpuEvent) { + internal init(root: objc_RUMTimeseriesCpuEventTimeseriesData) { self.root = root } @@ -7969,7 +7969,7 @@ public class objc_RUMTimeseriesMemoryEventTimeseries: NSObject { public class objc_RUMTimeseriesMemoryEventTimeseriesData: NSObject { internal let root: objc_RUMTimeseriesMemoryEvent - internal init(root: objc_RUMTimeseriesMemoryEvent) { + internal init(root: objc_RUMTimeseriesMemoryEventTimeseriesData) { self.root = root } diff --git a/tools/rum-models-generator/Sources/CodeGeneration/Generate/Transformers/ObjcInterop/ObjcInteropType+reflection.swift b/tools/rum-models-generator/Sources/CodeGeneration/Generate/Transformers/ObjcInterop/ObjcInteropType+reflection.swift index 185a9081b3..cde419d168 100644 --- a/tools/rum-models-generator/Sources/CodeGeneration/Generate/Transformers/ObjcInterop/ObjcInteropType+reflection.swift +++ b/tools/rum-models-generator/Sources/CodeGeneration/Generate/Transformers/ObjcInterop/ObjcInteropType+reflection.swift @@ -11,13 +11,13 @@ import Foundation // swiftlint:disable force_cast internal protocol ObjcInteropReflectable { - var objcRootClass: ObjcInteropRootClass { get } + var objcRootClass: ObjcInteropReflectable { get } var objcTypeName: String { get } var swiftTypeName: String { get } } extension ObjcInteropRootClass: ObjcInteropReflectable { - var objcRootClass: ObjcInteropRootClass { self } + var objcRootClass: ObjcInteropReflectable { self } var objcTypeName: String { bridgedSwiftStruct.name } var swiftTypeName: String { bridgedSwiftStruct.name } } @@ -27,7 +27,12 @@ extension ObjcInteropTransitiveNestedClass: ObjcInteropReflectable { return parentProperty.owner } - var objcRootClass: ObjcInteropRootClass { + var objcRootClass: ObjcInteropReflectable { + // ObjcInteropNestedClass (array items) own their swiftModel and act as a local root. + // Stop here instead of walking up to the top-level event. + if let nestedParent = parentClass as? ObjcInteropNestedClass { + return nestedParent + } return (parentClass as! ObjcInteropReflectable).objcRootClass } @@ -49,7 +54,7 @@ extension ObjcInteropNestedClass: ObjcInteropReflectable { return parentProperty.owner } - var objcRootClass: ObjcInteropRootClass { + var objcRootClass: ObjcInteropReflectable { return (parentClass as! ObjcInteropReflectable).objcRootClass } @@ -68,7 +73,7 @@ extension ObjcInteropNestedEnum: ObjcInteropReflectable { return parentProperty.owner } - var objcRootClass: ObjcInteropRootClass { + var objcRootClass: ObjcInteropReflectable { return (parentClass as! ObjcInteropReflectable).objcRootClass } @@ -90,7 +95,7 @@ extension ObjcInteropAssociatedTypeEnum: ObjcInteropReflectable { return parentProperty.owner } - var objcRootClass: ObjcInteropRootClass { + var objcRootClass: ObjcInteropReflectable { return (parentClass as! ObjcInteropReflectable).objcRootClass } From 5579ad20dde0fe54bb5526ec9ad8cda5a8c034ac Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 29 Apr 2026 13:28:42 +0200 Subject: [PATCH 044/102] RUM-13949 revert staging intake URL override in DatadogSite --- DatadogInternal/Sources/Context/DatadogSite.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DatadogInternal/Sources/Context/DatadogSite.swift b/DatadogInternal/Sources/Context/DatadogSite.swift index 11f023bb85..f0cf590402 100644 --- a/DatadogInternal/Sources/Context/DatadogSite.swift +++ b/DatadogInternal/Sources/Context/DatadogSite.swift @@ -40,7 +40,7 @@ extension DatadogSite { public var endpoint: URL { switch self { // swiftlint:disable force_unwrapping - case .us1: return URL(string: "https://rum.browser-intake-datad0g.com/")! // TODO: RUM-13949 remove - staging override for timeseries testing + case .us1: return URL(string: "https://browser-intake-datadoghq.com/")! case .us3: return URL(string: "https://browser-intake-us3-datadoghq.com/")! case .us5: return URL(string: "https://browser-intake-us5-datadoghq.com/")! case .eu1: return URL(string: "https://browser-intake-datadoghq.eu/")! From cf629d226d1f29839158f252eb951dbec88c1f5d Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 29 Apr 2026 13:28:46 +0200 Subject: [PATCH 045/102] RUM-13949 add enableTimeseries to watchOS platform initializer in RUMConfiguration --- DatadogRUM/Sources/RUMConfiguration.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index b040dae502..669eb1f554 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -655,6 +655,7 @@ extension RUM.Configuration { trackSlowFrames: Bool = true, telemetrySampleRate: SampleRate = 20, collectAccessibility: Bool = false, + enableTimeseries: Bool = false, featureFlags: FeatureFlags = .defaults ) { self.applicationID = applicationID @@ -679,6 +680,7 @@ extension RUM.Configuration { self.trackSlowFrames = trackSlowFrames self.telemetrySampleRate = telemetrySampleRate self.collectAccessibility = collectAccessibility + self.enableTimeseries = enableTimeseries self.featureFlags = featureFlags } #endif From 06ab6520414ea9830329b2faf20880f7b86e3ef7 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 29 Apr 2026 13:28:49 +0200 Subject: [PATCH 046/102] RUM-13949 fix non-deterministic TimeseriesSessionCollector tests by pinning compressionSampler --- .../TimeseriesSessionCollectorTests.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index ff160aac04..3066f9b37c 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -23,7 +23,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 2, samplingInterval: 0.05, - cpuUsageProvider: { nil } + cpuUsageProvider: { nil }, + compressionSampler: { false } ) // When @@ -58,7 +59,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 2, samplingInterval: 0.05, - cpuUsageProvider: { 42.5 } + cpuUsageProvider: { 42.5 }, + compressionSampler: { false } ) // When @@ -94,7 +96,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 100, // large batch — won't auto-flush samplingInterval: 0.05, - cpuUsageProvider: { nil } + cpuUsageProvider: { nil }, + compressionSampler: { false } ) // When — let a few samples accumulate then stop @@ -126,7 +129,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 100, samplingInterval: 0.05, - cpuUsageProvider: { 10.0 } + cpuUsageProvider: { 10.0 }, + compressionSampler: { false } ) let expectation = self.expectation(description: "samples collected") @@ -183,7 +187,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 100, samplingInterval: 0.05, - cpuUsageProvider: { nil } + cpuUsageProvider: { nil }, + compressionSampler: { false } ) // First session @@ -378,7 +383,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 3, samplingInterval: 0.05, - cpuUsageProvider: { nil } + cpuUsageProvider: { nil }, + compressionSampler: { false } ) let expectation = self.expectation(description: "first batch written") From 0a742bbef55a85de4354e6fd9dd75bbe6743efd1 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 29 Apr 2026 13:59:07 +0200 Subject: [PATCH 047/102] Fix wachos run --- TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift index ebe7bef1df..e0c49c6a99 100644 --- a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift +++ b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift @@ -1557,6 +1557,7 @@ public class RUMActionsHandlerMock: RUMActionsHandling { onViewModifierTapped?(actionName, actionAttributes) } } +#endif public class SamplingBasedVitalReaderMock: SamplingBasedVitalReader { public var vitalData: Double? @@ -1589,7 +1590,6 @@ public class ContinuousVitalReaderMock: ContinuousVitalReader { } } } -#endif extension TelemetryReceiver: AnyMockable { public static func mockAny() -> Self { .mockWith() } From 830d9be65a5929d76c387002c9a51db38a9a2af5 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 29 Apr 2026 14:56:33 +0200 Subject: [PATCH 048/102] Fix Swift 6 SPM Build --- .../Sources/Timeseries/TimeseriesSessionCollector.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 11902a3b6c..bf3c0dce69 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -58,6 +58,9 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { /// Per-process CPU as a percentage (0–100+), summed across all app threads. /// Separated into a static so it can be called from the init closure without capturing self. private static func processCPU() -> Double? { + #if os(watchOS) + return nil + #else var threadsList: thread_act_array_t? var threadsCount = mach_msg_type_number_t() let kr = withUnsafeMutablePointer(to: &threadsList) { @@ -90,6 +93,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { total += Double(info.cpu_usage) / Double(TH_USAGE_SCALE) * 100.0 } return total + #endif } /// Resets state, flips the compression coin, and starts a 1s sampling timer for the new session. From e57b16a402d2456206e3b1c1da4964325ae52344 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 5 May 2026 16:29:20 +0200 Subject: [PATCH 049/102] RUM-13949 add delta encoder for timeseries compression --- Datadog/Datadog.xcodeproj/project.pbxproj | 1 + DatadogRUM/Sources/Timeseries/DeltaEncoder.swift | 2 ++ DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift | 2 ++ 3 files changed, 5 insertions(+) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index e22564fcde..84717b3257 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -2974,6 +2974,7 @@ BB1A2B3C4D5E6F7800000001 /* TimeseriesSessionCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollector.swift; sourceTree = ""; }; BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoder.swift; sourceTree = ""; }; 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollectorTests.swift; sourceTree = ""; }; + BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoder.swift; sourceTree = ""; }; 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoderTests.swift; sourceTree = ""; }; B3BBBCBB265E71D100943419 /* VitalMemoryReaderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VitalMemoryReaderTests.swift; sourceTree = ""; }; B3E46CAA2D91B3A400BABF66 /* NetworkContextProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkContextProvider.swift; sourceTree = ""; }; diff --git a/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift b/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift index 25d807bac8..ec04009899 100644 --- a/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift +++ b/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift @@ -58,6 +58,7 @@ internal enum DeltaEncoder { return [ "precision": precision, + "resolution": "ns", "ts": ts, "memory_max": memoryMax, "memory_percent": memoryPercent @@ -100,6 +101,7 @@ internal enum DeltaEncoder { return [ "precision": precision, + "resolution": "ns", "ts": ts, "value": cpuUsage ] diff --git a/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift b/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift index 84d4acffdf..b84819ed80 100644 --- a/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift +++ b/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift @@ -37,6 +37,7 @@ class DeltaEncoderTests: XCTestCase { // Then XCTAssertEqual(result["precision"] as? Int, 4) + XCTAssertEqual(result["resolution"] as? String, "ns") let ts = try! XCTUnwrap(result["ts"] as? [Int64]) XCTAssertEqual(ts, [1_000_000_000, 1_000_000_000, 1_000_000_000]) @@ -77,6 +78,7 @@ class DeltaEncoderTests: XCTestCase { // Then XCTAssertEqual(result["precision"] as? Int, 4) + XCTAssertEqual(result["resolution"] as? String, "ns") let ts = try! XCTUnwrap(result["ts"] as? [Int64]) XCTAssertEqual(ts, [1_000_000_000, 1_000_000_000, 1_000_000_000]) From 599a859b58543f60d40c5634fd5b8739e85b1985 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 28 May 2026 15:44:15 +0200 Subject: [PATCH 050/102] RUM-13949 address review: overflow safety, test throws, remove pbxproj conflict artifact --- Datadog/Datadog.xcodeproj/project.pbxproj | 136 ------------------ .../Sources/Timeseries/DeltaEncoder.swift | 18 +-- .../Tests/Timeseries/DeltaEncoderTests.swift | 18 +-- 3 files changed, 18 insertions(+), 154 deletions(-) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 84717b3257..c72175cce5 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -1060,7 +1060,6 @@ D23F8E5529DDCD28001CFAE8 /* RUMEventSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61122ED325B1B84D00F9C7F5 /* RUMEventSanitizer.swift */; }; D23F8E5729DDCD28001CFAE8 /* RUMScopeDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6122514727FDFF82004F5AE4 /* RUMScopeDependencies.swift */; }; D23F8E5829DDCD28001CFAE8 /* VitalMemoryReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BBBCB0265E71C600943419 /* VitalMemoryReader.swift */; }; - D23F8EFF29DDCD28001CFAE8 /* TimeseriesSessionCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A2B3C4D5E6F7800000001 /* TimeseriesSessionCollector.swift */; }; D23F8E5929DDCD28001CFAE8 /* WebViewEventReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2CBC26A294383F200134409 /* WebViewEventReceiver.swift */; }; D23F8E5A29DDCD28001CFAE8 /* RUMResourceScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61494CB024C839460082C633 /* RUMResourceScope.swift */; }; D23F8E5C29DDCD28001CFAE8 /* RUMApplicationScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63D24BF1B91008053F2 /* RUMApplicationScope.swift */; }; @@ -9195,141 +9194,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - D23F8E5129DDCD28001CFAE8 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 6167E6D42B7F8B3300C3CA2D /* AppHangsMonitor.swift in Sources */, - 615E2B8F2D39444300D85243 /* ViewEndedController.swift in Sources */, - D23F8E5229DDCD28001CFAE8 /* UIViewControllerHandler.swift in Sources */, - D23F8E5329DDCD28001CFAE8 /* RUMCommand.swift in Sources */, - D23F8E5429DDCD28001CFAE8 /* ValuePublisher.swift in Sources */, - D23F8E5529DDCD28001CFAE8 /* RUMEventSanitizer.swift in Sources */, - D23F8E5729DDCD28001CFAE8 /* RUMScopeDependencies.swift in Sources */, - D23F8E5829DDCD28001CFAE8 /* VitalMemoryReader.swift in Sources */, - D23F8EFF29DDCD28001CFAE8 /* TimeseriesSessionCollector.swift in Sources */, - BB1A2B3C4D5E6F7800000004 /* DeltaEncoder.swift in Sources */, - 5B1D02852E8EB78800AB2391 /* FlagEvaluationReceiver.swift in Sources */, - 962900252D8351AB008DFE39 /* TopLevelReflector.swift in Sources */, - 6194B9342BB451DB00179430 /* FatalAppHangsHandler.swift in Sources */, - D23F8E5929DDCD28001CFAE8 /* WebViewEventReceiver.swift in Sources */, - 265496D32D81C5B10094B6E2 /* RUMAccount.swift in Sources */, - D253EE972B988CA90010B589 /* ViewCache.swift in Sources */, - 11EA5C252DC288DD00E8DFA2 /* RUM+objc.swift in Sources */, - D23F8E5A29DDCD28001CFAE8 /* RUMResourceScope.swift in Sources */, - D23F8E5C29DDCD28001CFAE8 /* RUMApplicationScope.swift in Sources */, - 3CFF4F982C09E64C006F191D /* WatchdogTerminationMonitor.swift in Sources */, - 61193AAF2CB54C7300C3CDF5 /* RUMActionsHandler.swift in Sources */, - 86DE9C5F2EB22417006DADE7 /* GraphQLResponse.swift in Sources */, - 114FFDE82E0031BA00330C91 /* SwiftUIRUMViewsPredicate+objc.swift in Sources */, - D23F8E5D29DDCD28001CFAE8 /* SwiftUIViewModifier.swift in Sources */, - D23F8E5E29DDCD28001CFAE8 /* VitalInfo.swift in Sources */, - D23F8E5F29DDCD28001CFAE8 /* UIApplicationSwizzler.swift in Sources */, - D23F8E6029DDCD28001CFAE8 /* PerformanceMetric.swift in Sources */, - D23F8E6129DDCD28001CFAE8 /* RUMConfiguration.swift in Sources */, - 61F930CC2BA213AC005F0EE2 /* AppHang.swift in Sources */, - 112877992D708D300082D11B /* VitalRefreshRateReader.swift in Sources */, - 1128779B2D708D300082D11B /* RenderLoopObserver.swift in Sources */, - 1128779C2D708D300082D11B /* ViewHitchesReader.swift in Sources */, - 61C713AB2A3B790B00FA735A /* Monitor.swift in Sources */, - D23F8E6429DDCD28001CFAE8 /* SwiftUIViewHandler.swift in Sources */, - 965497002D761E2B006428EE /* RUMView.swift in Sources */, - D23F8E6529DDCD28001CFAE8 /* RUMFeature.swift in Sources */, - D23F8E6629DDCD28001CFAE8 /* RUMDebugging.swift in Sources */, - 3C4CF9912C47BE07006DE1C0 /* MemoryWarningMonitor.swift in Sources */, - 864A707A2DDF742A00AC0619 /* AccessibilityInfo.swift in Sources */, - 9632900C2DF1F01400E9199E /* ModernSwiftUIComponentDetector.swift in Sources */, - D23F8E6729DDCD28001CFAE8 /* RUMUUID.swift in Sources */, - D23F8E6829DDCD28001CFAE8 /* UIKitExtensions.swift in Sources */, - 61C713A82A3B78F900FA735A /* RUMMonitorProtocol+Convenience.swift in Sources */, - D23F8E6929DDCD28001CFAE8 /* RUMContextAttributes.swift in Sources */, - 11E6D5A02E6B1468004D2F87 /* FirstFrameReader.swift in Sources */, - D23F8E6B29DDCD28001CFAE8 /* RUMMonitor.swift in Sources */, - D23F8E6C29DDCD28001CFAE8 /* RUMContextProvider.swift in Sources */, - 61DA6F6D2BB57E32009537E5 /* FatalErrorBuilder.swift in Sources */, - 619F1A242DEE493F003954BD /* LaunchReasonResolver.swift in Sources */, - D23F8E6D29DDCD28001CFAE8 /* ViewIdentifier.swift in Sources */, - 49D8C0B82AC5D2160075E427 /* RUM+Internal.swift in Sources */, - 96F70D512DDDBDA600D3736B /* SwiftUIComponentDetector.swift in Sources */, - D23F8E6E29DDCD28001CFAE8 /* RUMViewsHandler.swift in Sources */, - 61C713BA2A3C935C00FA735A /* RUM.swift in Sources */, - 3C0CB3462C19A1ED003B0E9B /* WatchdogTerminationReporter.swift in Sources */, - D23F8E6F29DDCD28001CFAE8 /* RequestBuilder.swift in Sources */, - 9629FFE12D81C348008DFE39 /* SwiftUIControllerType.swift in Sources */, - 1124D5362EA6D4390002E053 /* RUMAppLaunchManager.swift in Sources */, - 1124D5372EA6D4390002E053 /* RUMFeatureOperationManager.swift in Sources */, - D224430529E9588500274EC7 /* TelemetryReceiver.swift in Sources */, - D23F8E7029DDCD28001CFAE8 /* URLSessionRUMResourcesHandler.swift in Sources */, - 965497062D761FCB006428EE /* SwiftUIViewNameExtractor.swift in Sources */, - 11F55FDA2DCE183500DE4944 /* RUMDataModels+objc.swift in Sources */, - D23F8E7129DDCD28001CFAE8 /* RUMEventBuilder.swift in Sources */, - 118ACA3E2EEED247004E7F20 /* AppLaunchMetricController.swift in Sources */, - 118ACA3F2EEED247004E7F20 /* AppLaunchMetric.swift in Sources */, - D23F8E7229DDCD28001CFAE8 /* ErrorMessageReceiver.swift in Sources */, - 96F70D462DD793C400D3736B /* RUMAction.swift in Sources */, - D23F8E7329DDCD28001CFAE8 /* SwiftUIActionModifier.swift in Sources */, - D23F8E7429DDCD28001CFAE8 /* RUMCommandSubscriber.swift in Sources */, - 6194B92B2BB4116A00179430 /* RUMDataStore.swift in Sources */, - 6105C4F72CEBA7A100C4C5EE /* TNSMetric.swift in Sources */, - 6194B9312BB451C100179430 /* NonFatalAppHangsHandler.swift in Sources */, - D23F8E7529DDCD28001CFAE8 /* RUMUserActionScope.swift in Sources */, - 6167E6D72B7F8C3400C3CA2D /* AppHangsWatchdogThread.swift in Sources */, - 965497032D761EA3006428EE /* SwiftUIRUMViewsPredicate.swift in Sources */, - 61C713A42A3B78F900FA735A /* RUMMonitorProtocol.swift in Sources */, - 6174D6112BFDEA4600EC7469 /* SessionEndedMetric.swift in Sources */, - 3C0D5DED2A54405A00446CF9 /* RUMViewEventsFilter.swift in Sources */, - D23F8E7629DDCD28001CFAE8 /* RUMConnectivityInfoProvider.swift in Sources */, - D23F8E7729DDCD28001CFAE8 /* UIKitRUMViewsPredicate.swift in Sources */, - 261255882E2167E40015042B /* BaggageHeaderMerger.swift in Sources */, - 261255912E2167E40015042B /* HeaderProcessor.swift in Sources */, - 9629FFDE2D81C317008DFE39 /* SwiftUIViewPath.swift in Sources */, - 111201C12E93C13000375DA3 /* AppStateManager.swift in Sources */, - 111201C22E93C13000375DA3 /* AppStateInfo.swift in Sources */, - 61C713A62A3B78F900FA735A /* RUMMonitorProtocol+Internal.swift in Sources */, - D23F8E7829DDCD28001CFAE8 /* LongTaskObserver.swift in Sources */, - 615E2B962D425F5600D85243 /* ViewEndedMetric.swift in Sources */, - 864A707C2DDF743900AC0619 /* AccessibilityReader.swift in Sources */, - D23F8E7A29DDCD28001CFAE8 /* SessionReplayDependency.swift in Sources */, - 1124D52F2EA6D23C0002E053 /* StartupTypeHandler.swift in Sources */, - 9632900E2DF1F04200E9199E /* LegacySwiftUIComponentDetector.swift in Sources */, - 616F8C282BB1CD990061EA53 /* ProcessIdentifier.swift in Sources */, - D23F8E7C29DDCD28001CFAE8 /* RUMOffViewEventsHandlingRule.swift in Sources */, - D23F8E7D29DDCD28001CFAE8 /* RUMScope.swift in Sources */, - D23F8E7E29DDCD28001CFAE8 /* CrashReportReceiver.swift in Sources */, - D23F8E7F29DDCD28001CFAE8 /* UIViewControllerSwizzler.swift in Sources */, - D23F8E8029DDCD28001CFAE8 /* VitalInfoSampler.swift in Sources */, - D23F8E8129DDCD28001CFAE8 /* RUMViewScope.swift in Sources */, - 96F70D492DD79B3D00D3736B /* SwiftUIRUMActionsPredicate.swift in Sources */, - 11030D762D96EC5C00732D5F /* ViewHitchesMetric.swift in Sources */, - D2D748242DC0FF7E00C61353 /* FatalErrorContextNotifier.swift in Sources */, - D23F8E8229DDCD28001CFAE8 /* RUMSessionScope.swift in Sources */, - A7E6EA812D3146AD00997201 /* AnonymousIdentifierManager.swift in Sources */, - D23F8E8329DDCD28001CFAE8 /* RUMUser.swift in Sources */, - D23F8E8429DDCD28001CFAE8 /* UIKitRUMUserActionsPredicate.swift in Sources */, - 3C5CD8CE2C3ECB9400B12303 /* MemoryWarningReporter.swift in Sources */, - D23F8E8529DDCD28001CFAE8 /* SwiftUIExtensions.swift in Sources */, - 3CFF4F952C09E63C006F191D /* WatchdogTerminationChecker.swift in Sources */, - D23F8E8629DDCD28001CFAE8 /* RUMDataModelsMapping.swift in Sources */, - 618F2B042D146BB300A647C4 /* NetworkSettledResourcePredicate.swift in Sources */, - D23F8E8729DDCD28001CFAE8 /* RUMInstrumentation.swift in Sources */, - D23F8E8829DDCD28001CFAE8 /* VitalCPUReader.swift in Sources */, - D23F8E8A29DDCD28001CFAE8 /* RUMEventsMapper.swift in Sources */, - D23F8E8B29DDCD28001CFAE8 /* RUMContext.swift in Sources */, - 6174D6212C009C6300EC7469 /* SessionEndedMetricController.swift in Sources */, - 618F2B072D15922400A647C4 /* NextViewActionPredicate.swift in Sources */, - 6105C50A2CFA222400C4C5EE /* INVMetric.swift in Sources */, - D23F8E8E29DDCD28001CFAE8 /* UIEventCommandFactory.swift in Sources */, - AA0001012A000002000C0001 /* UIScrollViewDelegateProxy.swift in Sources */, - AA0001012A000001000C0001 /* RUMScrollHandler.swift in Sources */, - AA0001012A000003000C0001 /* UIScrollViewSwizzler.swift in Sources */, - AA0001012A000005000C0001 /* UIScrollViewHandler.swift in Sources */, - 11A2F24C2E70CC08006EDC52 /* FrameInfoProvider.swift in Sources */, - 11A2F24D2E70CC08006EDC52 /* MediaTimeProvider.swift in Sources */, - D23F8E8F29DDCD28001CFAE8 /* RUMUUIDGenerator.swift in Sources */, - 61DCC84F2C071DCD00CB59E5 /* TelemetryInterceptor.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; D23F8E9F29DDCD38001CFAE8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; diff --git a/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift b/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift index ec04009899..d4a8766503 100644 --- a/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift +++ b/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift @@ -40,18 +40,18 @@ internal enum DeltaEncoder { for (index, sample) in batch.enumerated() { if index == 0 { ts.append(sample.timestamp) - memoryMax.append(Int64(round(sample.dataPoint.memoryMax * Double(scale)))) - memoryPercent.append(Int64(round(sample.dataPoint.memoryPercent * Double(scale)))) + memoryMax.append(Int64.ddWithNoOverflow(sample.dataPoint.memoryMax * Double(scale))) + memoryPercent.append(Int64.ddWithNoOverflow(sample.dataPoint.memoryPercent * Double(scale))) } else { let prev = batch[index - 1] ts.append(sample.timestamp - prev.timestamp) memoryMax.append( - Int64(round(sample.dataPoint.memoryMax * Double(scale))) - - Int64(round(prev.dataPoint.memoryMax * Double(scale))) + Int64.ddWithNoOverflow(sample.dataPoint.memoryMax * Double(scale)) - + Int64.ddWithNoOverflow(prev.dataPoint.memoryMax * Double(scale)) ) memoryPercent.append( - Int64(round(sample.dataPoint.memoryPercent * Double(scale))) - - Int64(round(prev.dataPoint.memoryPercent * Double(scale))) + Int64.ddWithNoOverflow(sample.dataPoint.memoryPercent * Double(scale)) - + Int64.ddWithNoOverflow(prev.dataPoint.memoryPercent * Double(scale)) ) } } @@ -88,13 +88,13 @@ internal enum DeltaEncoder { for (index, sample) in batch.enumerated() { if index == 0 { ts.append(sample.timestamp) - cpuUsage.append(Int64(round(sample.dataPoint.cpuUsage * Double(scale)))) + cpuUsage.append(Int64.ddWithNoOverflow(sample.dataPoint.cpuUsage * Double(scale))) } else { let prev = batch[index - 1] ts.append(sample.timestamp - prev.timestamp) cpuUsage.append( - Int64(round(sample.dataPoint.cpuUsage * Double(scale))) - - Int64(round(prev.dataPoint.cpuUsage * Double(scale))) + Int64.ddWithNoOverflow(sample.dataPoint.cpuUsage * Double(scale)) - + Int64.ddWithNoOverflow(prev.dataPoint.cpuUsage * Double(scale)) ) } } diff --git a/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift b/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift index b84819ed80..3b94d964ed 100644 --- a/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift +++ b/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift @@ -24,7 +24,7 @@ class DeltaEncoderTests: XCTestCase { XCTAssertNil(DeltaEncoder.encodeMemory([sample])) } - func testEncodeMemory_correctDeltaEncoding() { + func testEncodeMemory_correctDeltaEncoding() throws { // Given let samples: [RUMTimeseriesMemoryEvent.Timeseries.Data] = [ .init(dataPoint: .init(memoryMax: 100.0, memoryPercent: 10.0), timestamp: 1_000_000_000), @@ -33,21 +33,21 @@ class DeltaEncoderTests: XCTestCase { ] // When - let result = try! XCTUnwrap(DeltaEncoder.encodeMemory(samples)) + let result = try XCTUnwrap(DeltaEncoder.encodeMemory(samples)) // Then XCTAssertEqual(result["precision"] as? Int, 4) XCTAssertEqual(result["resolution"] as? String, "ns") - let ts = try! XCTUnwrap(result["ts"] as? [Int64]) + let ts = try XCTUnwrap(result["ts"] as? [Int64]) XCTAssertEqual(ts, [1_000_000_000, 1_000_000_000, 1_000_000_000]) // memory_max: 100*10000=1_000_000, (200.5-100)*10000=1_005_000, 0 - let memoryMax = try! XCTUnwrap(result["memory_max"] as? [Int64]) + let memoryMax = try XCTUnwrap(result["memory_max"] as? [Int64]) XCTAssertEqual(memoryMax, [1_000_000, 1_005_000, 0]) // memory_percent: 10*10000=100_000, (20-10)*10000=100_000, (20.5-20)*10000=5_000 - let memoryPercent = try! XCTUnwrap(result["memory_percent"] as? [Int64]) + let memoryPercent = try XCTUnwrap(result["memory_percent"] as? [Int64]) XCTAssertEqual(memoryPercent, [100_000, 100_000, 5_000]) } @@ -65,7 +65,7 @@ class DeltaEncoderTests: XCTestCase { XCTAssertNil(DeltaEncoder.encodeCPU([sample])) } - func testEncodeCPU_correctDeltaEncoding() { + func testEncodeCPU_correctDeltaEncoding() throws { // Given let samples: [RUMTimeseriesCpuEvent.Timeseries.Data] = [ .init(dataPoint: .init(cpuUsage: 42.5), timestamp: 1_000_000_000), @@ -74,17 +74,17 @@ class DeltaEncoderTests: XCTestCase { ] // When - let result = try! XCTUnwrap(DeltaEncoder.encodeCPU(samples)) + let result = try XCTUnwrap(DeltaEncoder.encodeCPU(samples)) // Then XCTAssertEqual(result["precision"] as? Int, 4) XCTAssertEqual(result["resolution"] as? String, "ns") - let ts = try! XCTUnwrap(result["ts"] as? [Int64]) + let ts = try XCTUnwrap(result["ts"] as? [Int64]) XCTAssertEqual(ts, [1_000_000_000, 1_000_000_000, 1_000_000_000]) // value: 42.5*10000=425_000, (43.0-42.5)*10000=5_000, (42.0-43.0)*10000=-10_000 - let cpuUsage = try! XCTUnwrap(result["value"] as? [Int64]) + let cpuUsage = try XCTUnwrap(result["value"] as? [Int64]) XCTAssertEqual(cpuUsage, [425_000, 5_000, -10_000]) } } From 7aac22b2d72f735e2b7192df487edcb929536a67 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 2 Jun 2026 11:45:38 +0200 Subject: [PATCH 051/102] Apply PR suggestions --- Datadog/Datadog.xcodeproj/project.pbxproj | 54 ------------------- .../Sources/Timeseries/DeltaEncoder.swift | 38 ++++++------- .../Tests/Timeseries/DeltaEncoderTests.swift | 23 ++++++++ 3 files changed, 43 insertions(+), 72 deletions(-) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index c72175cce5..388f24fc4c 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -1054,58 +1054,6 @@ D233E8002E4B588B00E7CFDE /* dd_pprof.h in Headers */ = {isa = PBXBuildFile; fileRef = D233E7FE2E4B588B00E7CFDE /* dd_pprof.h */; settings = {ATTRIBUTES = (Private, ); }; }; D233E8022E4B653F00E7CFDE /* dd_pprof.cpp in Sources */ = {isa = PBXBuildFile; fileRef = D233E8012E4B653F00E7CFDE /* dd_pprof.cpp */; }; D234613228B7713000055D4C /* FeatureContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D234613028B7712F00055D4C /* FeatureContextTests.swift */; }; - D23F8E5229DDCD28001CFAE8 /* UIViewControllerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F3CDA2251118FB00C816E5 /* UIViewControllerHandler.swift */; }; - D23F8E5329DDCD28001CFAE8 /* RUMCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63A24BF1A4B008053F2 /* RUMCommand.swift */; }; - D23F8E5429DDCD28001CFAE8 /* ValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611529A425E3DD51004F740E /* ValuePublisher.swift */; }; - D23F8E5529DDCD28001CFAE8 /* RUMEventSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61122ED325B1B84D00F9C7F5 /* RUMEventSanitizer.swift */; }; - D23F8E5729DDCD28001CFAE8 /* RUMScopeDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6122514727FDFF82004F5AE4 /* RUMScopeDependencies.swift */; }; - D23F8E5829DDCD28001CFAE8 /* VitalMemoryReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BBBCB0265E71C600943419 /* VitalMemoryReader.swift */; }; - D23F8E5929DDCD28001CFAE8 /* WebViewEventReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2CBC26A294383F200134409 /* WebViewEventReceiver.swift */; }; - D23F8E5A29DDCD28001CFAE8 /* RUMResourceScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61494CB024C839460082C633 /* RUMResourceScope.swift */; }; - D23F8E5C29DDCD28001CFAE8 /* RUMApplicationScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63D24BF1B91008053F2 /* RUMApplicationScope.swift */; }; - D23F8E5D29DDCD28001CFAE8 /* SwiftUIViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D249859F2728042200B4F72D /* SwiftUIViewModifier.swift */; }; - D23F8E5E29DDCD28001CFAE8 /* VitalInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3FC3C0626526EFF00DEED9E /* VitalInfo.swift */; }; - D23F8E5F29DDCD28001CFAE8 /* UIApplicationSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6141014E251A57AF00E3C2D9 /* UIApplicationSwizzler.swift */; }; - D23F8E6029DDCD28001CFAE8 /* PerformanceMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = E179FB4D28F80A6400CC2698 /* PerformanceMetric.swift */; }; - D23F8E6129DDCD28001CFAE8 /* RUMConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D25FF2EA29CC6D6F0063802D /* RUMConfiguration.swift */; }; - D23F8E6429DDCD28001CFAE8 /* SwiftUIViewHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D24985A12728048B00B4F72D /* SwiftUIViewHandler.swift */; }; - D23F8E6529DDCD28001CFAE8 /* RUMFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = D25FF2E729CC6B680063802D /* RUMFeature.swift */; }; - D23F8E6629DDCD28001CFAE8 /* RUMDebugging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B22E5924F3E6B700DC26D2 /* RUMDebugging.swift */; }; - D23F8E6729DDCD28001CFAE8 /* RUMUUID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618DCFD624C7265300589570 /* RUMUUID.swift */; }; - D23F8E6829DDCD28001CFAE8 /* UIKitExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615F197B25B5A64B00BE14B5 /* UIKitExtensions.swift */; }; - D23F8E6929DDCD28001CFAE8 /* RUMContextAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2CBC26D294395A300134409 /* RUMContextAttributes.swift */; }; - D23F8E6B29DDCD28001CFAE8 /* RUMMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E5333524B84B43003D6C4E /* RUMMonitor.swift */; }; - D23F8E6C29DDCD28001CFAE8 /* RUMContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6156CB8D24DDA1B5008CB2B2 /* RUMContextProvider.swift */; }; - D23F8E6D29DDCD28001CFAE8 /* ViewIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF9A4425AC5DEA001058CC /* ViewIdentifier.swift */; }; - D23F8E6E29DDCD28001CFAE8 /* RUMViewsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2EFF3D22731822A00D09F33 /* RUMViewsHandler.swift */; }; - D23F8E6F29DDCD28001CFAE8 /* RequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D25FF2ED29CC73240063802D /* RequestBuilder.swift */; }; - D23F8E7029DDCD28001CFAE8 /* URLSessionRUMResourcesHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BCB11E29D30AF000737A9A /* URLSessionRUMResourcesHandler.swift */; }; - D23F8E7129DDCD28001CFAE8 /* RUMEventBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF281D24B8968D000B3D9B /* RUMEventBuilder.swift */; }; - D23F8E7229DDCD28001CFAE8 /* ErrorMessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D215ED6A29D2E1080046B721 /* ErrorMessageReceiver.swift */; }; - D23F8E7329DDCD28001CFAE8 /* SwiftUIActionModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29D5A4C273BF8B400A687C1 /* SwiftUIActionModifier.swift */; }; - D23F8E7429DDCD28001CFAE8 /* RUMCommandSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616CCE12250A1868009FED46 /* RUMCommandSubscriber.swift */; }; - D23F8E7529DDCD28001CFAE8 /* RUMUserActionScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61494CB924CB126F0082C633 /* RUMUserActionScope.swift */; }; - D23F8E7629DDCD28001CFAE8 /* RUMConnectivityInfoProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B0A4E24EBDC6B00A2A780 /* RUMConnectivityInfoProvider.swift */; }; - D23F8E7729DDCD28001CFAE8 /* UIKitRUMViewsPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F3CDA62512144600C816E5 /* UIKitRUMViewsPredicate.swift */; }; - D23F8E7829DDCD28001CFAE8 /* LongTaskObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E359F4D26CD518D001E25E9 /* LongTaskObserver.swift */; }; - D23F8E7A29DDCD28001CFAE8 /* SessionReplayDependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615950ED291C058F00470E0C /* SessionReplayDependency.swift */; }; - D23F8E7C29DDCD28001CFAE8 /* RUMOffViewEventsHandlingRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A614E7276B2BD000A06CE7 /* RUMOffViewEventsHandlingRule.swift */; }; - D23F8E7D29DDCD28001CFAE8 /* RUMScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63624BF191F008053F2 /* RUMScope.swift */; }; - D23F8E7E29DDCD28001CFAE8 /* CrashReportReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D236BE2729520FED00676E67 /* CrashReportReceiver.swift */; }; - D23F8E7F29DDCD28001CFAE8 /* UIViewControllerSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F3CDA42511190E00C816E5 /* UIViewControllerSwizzler.swift */; }; - D23F8E8029DDCD28001CFAE8 /* VitalInfoSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E9973F0268DF69500D8059B /* VitalInfoSampler.swift */; }; - D23F8E8129DDCD28001CFAE8 /* RUMViewScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C21124C5951400C0321C /* RUMViewScope.swift */; }; - D23F8E8229DDCD28001CFAE8 /* RUMSessionScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C20624C098FC00C0321C /* RUMSessionScope.swift */; }; - D23F8E8329DDCD28001CFAE8 /* RUMUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B0A4A24EBC43D00A2A780 /* RUMUser.swift */; }; - D23F8E8429DDCD28001CFAE8 /* UIKitRUMUserActionsPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F637AED12697404200516F32 /* UIKitRUMUserActionsPredicate.swift */; }; - D23F8E8529DDCD28001CFAE8 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FCA238271D896E0020286F /* SwiftUIExtensions.swift */; }; - D23F8E8629DDCD28001CFAE8 /* RUMDataModelsMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618715F824DC13A100FC0F69 /* RUMDataModelsMapping.swift */; }; - D23F8E8729DDCD28001CFAE8 /* RUMInstrumentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616CCE15250A467E009FED46 /* RUMInstrumentation.swift */; }; - D23F8E8829DDCD28001CFAE8 /* VitalCPUReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC8B5D92668197B000F7529 /* VitalCPUReader.swift */; }; - D23F8E8A29DDCD28001CFAE8 /* RUMEventsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 613E81EF25A740140084B751 /* RUMEventsMapper.swift */; }; - D23F8E8B29DDCD28001CFAE8 /* RUMContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63824BF19B4008053F2 /* RUMContext.swift */; }; - D23F8E8E29DDCD28001CFAE8 /* UIEventCommandFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6141015A251A601D00E3C2D9 /* UIEventCommandFactory.swift */; }; - D23F8E8F29DDCD28001CFAE8 /* RUMUUIDGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618DCFD824C7269500589570 /* RUMUUIDGenerator.swift */; }; D23F8EA029DDCD38001CFAE8 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A614E9276B9D4C00A06CE7 /* RUMOffViewEventsHandlingRuleTests.swift */; }; D23F8EA229DDCD38001CFAE8 /* RUMSessionScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C20824C0C75500C0321C /* RUMSessionScopeTests.swift */; }; D23F8EA329DDCD38001CFAE8 /* RUMUserActionScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617CD0DC24CEDDD300B0B557 /* RUMUserActionScopeTests.swift */; }; @@ -6280,7 +6228,6 @@ isa = PBXGroup; children = ( BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */, - BB1A2B3C4D5E6F7800000001 /* TimeseriesSessionCollector.swift */, ); path = Timeseries; sourceTree = ""; @@ -9486,7 +9433,6 @@ D29A9F7F29DD85BB005C54A4 /* RUMEventSanitizer.swift in Sources */, D29A9F5A29DD85BB005C54A4 /* RUMScopeDependencies.swift in Sources */, D29A9F5B29DD85BB005C54A4 /* VitalMemoryReader.swift in Sources */, - D29A9FFF29DD85BB005C54A4 /* TimeseriesSessionCollector.swift in Sources */, BB1A2B3C4D5E6F7800000005 /* DeltaEncoder.swift in Sources */, 5B1D02862E8EB78800AB2391 /* FlagEvaluationReceiver.swift in Sources */, 962900242D8351AB008DFE39 /* TopLevelReflector.swift in Sources */, diff --git a/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift b/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift index d4a8766503..8f6dec0305 100644 --- a/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift +++ b/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift @@ -13,7 +13,7 @@ import DatadogInternal /// All floating-point fields are scaled by `10^precision` and stored as `Int64`. internal enum DeltaEncoder { private static let precision = 4 - private static let scale = Int64(10_000) + private static let scale = 10_000.0 /// Encodes a batch of memory samples using delta compression. /// @@ -40,19 +40,20 @@ internal enum DeltaEncoder { for (index, sample) in batch.enumerated() { if index == 0 { ts.append(sample.timestamp) - memoryMax.append(Int64.ddWithNoOverflow(sample.dataPoint.memoryMax * Double(scale))) - memoryPercent.append(Int64.ddWithNoOverflow(sample.dataPoint.memoryPercent * Double(scale))) + memoryMax.append(Int64.ddWithNoOverflow(sample.dataPoint.memoryMax * scale)) + memoryPercent.append(Int64.ddWithNoOverflow(sample.dataPoint.memoryPercent * scale)) } else { let prev = batch[index - 1] - ts.append(sample.timestamp - prev.timestamp) - memoryMax.append( - Int64.ddWithNoOverflow(sample.dataPoint.memoryMax * Double(scale)) - - Int64.ddWithNoOverflow(prev.dataPoint.memoryMax * Double(scale)) - ) - memoryPercent.append( - Int64.ddWithNoOverflow(sample.dataPoint.memoryPercent * Double(scale)) - - Int64.ddWithNoOverflow(prev.dataPoint.memoryPercent * Double(scale)) - ) + let (tsDelta, _) = sample.timestamp.subtractingReportingOverflow(prev.timestamp) + ts.append(tsDelta) + let curMax = Int64.ddWithNoOverflow(sample.dataPoint.memoryMax * scale) + let prevMax = Int64.ddWithNoOverflow(prev.dataPoint.memoryMax * scale) + let (maxDelta, _) = curMax.subtractingReportingOverflow(prevMax) + memoryMax.append(maxDelta) + let curPct = Int64.ddWithNoOverflow(sample.dataPoint.memoryPercent * scale) + let prevPct = Int64.ddWithNoOverflow(prev.dataPoint.memoryPercent * scale) + let (pctDelta, _) = curPct.subtractingReportingOverflow(prevPct) + memoryPercent.append(pctDelta) } } @@ -88,14 +89,15 @@ internal enum DeltaEncoder { for (index, sample) in batch.enumerated() { if index == 0 { ts.append(sample.timestamp) - cpuUsage.append(Int64.ddWithNoOverflow(sample.dataPoint.cpuUsage * Double(scale))) + cpuUsage.append(Int64.ddWithNoOverflow(sample.dataPoint.cpuUsage * scale)) } else { let prev = batch[index - 1] - ts.append(sample.timestamp - prev.timestamp) - cpuUsage.append( - Int64.ddWithNoOverflow(sample.dataPoint.cpuUsage * Double(scale)) - - Int64.ddWithNoOverflow(prev.dataPoint.cpuUsage * Double(scale)) - ) + let (tsDelta, _) = sample.timestamp.subtractingReportingOverflow(prev.timestamp) + ts.append(tsDelta) + let cur = Int64.ddWithNoOverflow(sample.dataPoint.cpuUsage * scale) + let prv = Int64.ddWithNoOverflow(prev.dataPoint.cpuUsage * scale) + let (delta, _) = cur.subtractingReportingOverflow(prv) + cpuUsage.append(delta) } } diff --git a/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift b/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift index 3b94d964ed..999840d386 100644 --- a/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift +++ b/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift @@ -51,8 +51,31 @@ class DeltaEncoderTests: XCTestCase { XCTAssertEqual(memoryPercent, [100_000, 100_000, 5_000]) } + func testEncodeMemory_doesNotCrashOnOverflowBoundaryValues() throws { + // Values scaled to near Int64.max / Int64.min to exercise subtractingReportingOverflow + let hugeBytes = Double(Int64.max) / 10_000.0 + let samples: [RUMTimeseriesMemoryEvent.Timeseries.Data] = [ + .init(dataPoint: .init(memoryMax: hugeBytes, memoryPercent: 100.0), timestamp: Int64.max), + .init(dataPoint: .init(memoryMax: 0.0, memoryPercent: 0.0), timestamp: 0) + ] + // Should not crash + let result = try XCTUnwrap(DeltaEncoder.encodeMemory(samples)) + XCTAssertNotNil(result["memory_max"] as? [Int64]) + } + // MARK: - CPU encoding + func testEncodeCPU_doesNotCrashOnOverflowBoundaryValues() throws { + let hugeCPU = Double(Int64.max) / 10_000.0 + let samples: [RUMTimeseriesCpuEvent.Timeseries.Data] = [ + .init(dataPoint: .init(cpuUsage: hugeCPU), timestamp: Int64.max), + .init(dataPoint: .init(cpuUsage: 0.0), timestamp: 0) + ] + // Should not crash + let result = try XCTUnwrap(DeltaEncoder.encodeCPU(samples)) + XCTAssertNotNil(result["value"] as? [Int64]) + } + func testEncodeCPU_returnsNilForEmptyBatch() { XCTAssertNil(DeltaEncoder.encodeCPU([])) } From 18ad8f65d0f78f8ec217519ff7edfaefd54bb8a0 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 2 Jun 2026 14:48:15 +0200 Subject: [PATCH 052/102] Remove more xcode project mess --- Datadog/Datadog.xcodeproj/project.pbxproj | 115 ---------------------- 1 file changed, 115 deletions(-) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 388f24fc4c..fd4d36430f 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -1054,31 +1054,6 @@ D233E8002E4B588B00E7CFDE /* dd_pprof.h in Headers */ = {isa = PBXBuildFile; fileRef = D233E7FE2E4B588B00E7CFDE /* dd_pprof.h */; settings = {ATTRIBUTES = (Private, ); }; }; D233E8022E4B653F00E7CFDE /* dd_pprof.cpp in Sources */ = {isa = PBXBuildFile; fileRef = D233E8012E4B653F00E7CFDE /* dd_pprof.cpp */; }; D234613228B7713000055D4C /* FeatureContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D234613028B7712F00055D4C /* FeatureContextTests.swift */; }; - D23F8EA029DDCD38001CFAE8 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A614E9276B9D4C00A06CE7 /* RUMOffViewEventsHandlingRuleTests.swift */; }; - D23F8EA229DDCD38001CFAE8 /* RUMSessionScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C20824C0C75500C0321C /* RUMSessionScopeTests.swift */; }; - D23F8EA329DDCD38001CFAE8 /* RUMUserActionScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617CD0DC24CEDDD300B0B557 /* RUMUserActionScopeTests.swift */; }; - D23F8EA629DDCD38001CFAE8 /* RUMDeviceInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FD9FCE28534EBD00214BD9 /* RUMDeviceInfoTests.swift */; }; - D23F8EA829DDCD38001CFAE8 /* RUMResourceScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61494CB424C864680082C633 /* RUMResourceScopeTests.swift */; }; - D23F8EAC29DDCD38001CFAE8 /* RUMDataModelsMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618715FB24DC5F0800FC0F69 /* RUMDataModelsMappingTests.swift */; }; - D23F8EAD29DDCD38001CFAE8 /* RUMEventBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FF282024B8981D000B3D9B /* RUMEventBuilderTests.swift */; }; - D23F8EAE29DDCD38001CFAE8 /* DDTAssertValidRUMUUID.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29A9FCB29DDBCC5005C54A4 /* DDTAssertValidRUMUUID.swift */; }; - D23F8EAF29DDCD38001CFAE8 /* RUMScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618DCFDE24C75FD300589570 /* RUMScopeTests.swift */; }; - D23F8EB029DDCD38001CFAE8 /* SessionReplayDependencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615950EA291C029700470E0C /* SessionReplayDependencyTests.swift */; }; - D23F8EB129DDCD38001CFAE8 /* RUMViewScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6198D27024C6E3B700493501 /* RUMViewScopeTests.swift */; }; - D23F8EB229DDCD38001CFAE8 /* ValuePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611529AD25E3E429004F740E /* ValuePublisherTests.swift */; }; - D23F8EB329DDCD38001CFAE8 /* ErrorMessageReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21C26ED28AFB65B005DD405 /* ErrorMessageReceiverTests.swift */; }; - D23F8EB429DDCD38001CFAE8 /* RUMApplicationScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617B953F24BF4DB300E6F443 /* RUMApplicationScopeTests.swift */; }; - D23F8EB629DDCD38001CFAE8 /* RUMViewsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29889C72734136200A4D1A9 /* RUMViewsHandlerTests.swift */; }; - D23F8EB829DDCD38001CFAE8 /* RUMActionsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615C3195251DD5080018781C /* RUMActionsHandlerTests.swift */; }; - D23F8EBA29DDCD38001CFAE8 /* ViewIdentifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C1510C25AC8C1B00362D4B /* ViewIdentifierTests.swift */; }; - D23F8EBE29DDCD38001CFAE8 /* WebViewEventReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E53889B2773C4B300A7DC42 /* WebViewEventReceiverTests.swift */; }; - D23F8EBF29DDCD38001CFAE8 /* URLSessionRUMResourcesHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BCB12129D34A5F00737A9A /* URLSessionRUMResourcesHandlerTests.swift */; }; - D23F8EC029DDCD38001CFAE8 /* RUMEventSanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61122EED25B1D75B00F9C7F5 /* RUMEventSanitizerTests.swift */; }; - D23F8EC129DDCD38001CFAE8 /* RUMEventsMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 613E81F625A743600084B751 /* RUMEventsMapperTests.swift */; }; - D23F8EC429DDCD38001CFAE8 /* RUMCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618715F624DC0CDE00FC0F69 /* RUMCommandTests.swift */; }; - D23F8EC629DDCD38001CFAE8 /* TestUtilities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D257953E298ABA65008A1BE5 /* TestUtilities.framework */; }; - D23F8EC729DDCD38001CFAE8 /* DatadogRUM.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D29A9F3429DD84AA005C54A4 /* DatadogRUM.framework */; }; - D23F8ECE29DDCD53001CFAE8 /* DatadogInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2DA2385298D57AA00C6C7E6 /* DatadogInternal.framework */; }; D240680827CE6C9E00C04F44 /* ConsoleOutputInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61441C902461A648003D8BB8 /* ConsoleOutputInterceptor.swift */; }; D240681E27CE6C9E00C04F44 /* ExampleAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61441C0424616DE9003D8BB8 /* ExampleAppDelegate.swift */; }; D240682B27CE6C9E00C04F44 /* UIButton+Disabling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61441C912461A648003D8BB8 /* UIButton+Disabling.swift */; }; @@ -6236,7 +6211,6 @@ isa = PBXGroup; children = ( 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */, - 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */, ); path = Timeseries; sourceTree = ""; @@ -9141,94 +9115,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - D23F8E9F29DDCD38001CFAE8 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 6188697D2A4376F700E8996B /* RUMConfigurationTests.swift in Sources */, - 61DCC8482C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */, - B51668F3A97DEAC2917B0F44 /* TimeseriesSessionCollectorTests.swift in Sources */, - 7F8C00B87F71287FB535342E /* DeltaEncoderTests.swift in Sources */, - D23F8EA029DDCD38001CFAE8 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, - 61C4534B2C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift in Sources */, - D23F8EA229DDCD38001CFAE8 /* RUMSessionScopeTests.swift in Sources */, - 0904F9F52EE1DA6800ED9A22 /* UIKitExtensionsTests.swift in Sources */, - 3C4CF9992C47CC92006DE1C0 /* MemoryWarningMonitorTests.swift in Sources */, - D23F8EA329DDCD38001CFAE8 /* RUMUserActionScopeTests.swift in Sources */, - 864A70812DE092AD00AC0619 /* AccessibilityReaderTests.swift in Sources */, - 615B0F8C2BB33C2800E9ED6C /* AppHangsMonitorTests.swift in Sources */, - 61C713B42A3C3A0B00FA735A /* RUMMonitorProtocol+InternalTests.swift in Sources */, - D23F8EA629DDCD38001CFAE8 /* RUMDeviceInfoTests.swift in Sources */, - D23F8EA829DDCD38001CFAE8 /* RUMResourceScopeTests.swift in Sources */, - 3CFF4FA52C0E0FE9006F191D /* WatchdogTerminationCheckerTests.swift in Sources */, - 619F1A282DEF0B3F003954BD /* LaunchReasonResolverTests.swift in Sources */, - D23F8EAC29DDCD38001CFAE8 /* RUMDataModelsMappingTests.swift in Sources */, - D23F8EAD29DDCD38001CFAE8 /* RUMEventBuilderTests.swift in Sources */, - 61CE2E602BF2177100EC7D42 /* Monitor+GlobalAttributesTests.swift in Sources */, - 3CEC57782C16FDD80042B5F2 /* AppStateManagerTests.swift in Sources */, - D23F8EAE29DDCD38001CFAE8 /* DDTAssertValidRUMUUID.swift in Sources */, - D23F8EAF29DDCD38001CFAE8 /* RUMScopeTests.swift in Sources */, - 2612558A2E2167F10015042B /* BaggageHeaderMergerTests.swift in Sources */, - 261255942E2167F10015042B /* HeaderProcessorTests.swift in Sources */, - A7E6EA842D314A9B00997201 /* AnonymousIdentifierManagerTests.swift in Sources */, - D23F8EB029DDCD38001CFAE8 /* SessionReplayDependencyTests.swift in Sources */, - 61C713B72A3C600400FA735A /* RUMMonitorProtocol+ConvenienceTests.swift in Sources */, - D23F8EB129DDCD38001CFAE8 /* RUMViewScopeTests.swift in Sources */, - D224431029E977A100274EC7 /* TelemetryReceiverTests.swift in Sources */, - 96155A2A2D79A4E50029034E /* SwiftUIViewNameExtractorIntegrationTests.swift in Sources */, - 5B1D02952E8ED6C000AB2391 /* FlagEvaluationReceiverTests.swift in Sources */, - 3C4CF99C2C47DAA5006DE1C0 /* MemoryWarningMocks.swift in Sources */, - 3C43A3892C188975000BFB21 /* WatchdogTerminationMonitorTests.swift in Sources */, - D23F8EB229DDCD38001CFAE8 /* ValuePublisherTests.swift in Sources */, - 6174D61B2BFE449300EC7469 /* SessionEndedMetricTests.swift in Sources */, - 9654971E2D774060006428EE /* SwiftUIViewNameExtractorTests.swift in Sources */, - 61181CDD2BF35BC000632A7A /* FatalErrorContextNotifierTests.swift in Sources */, - 61C713BD2A3C95AD00FA735A /* RUMInstrumentationTests.swift in Sources */, - D23F8EB329DDCD38001CFAE8 /* ErrorMessageReceiverTests.swift in Sources */, - 61C713C12A3C9DAD00FA735A /* RequestBuilderTests.swift in Sources */, - D23F8EB429DDCD38001CFAE8 /* RUMApplicationScopeTests.swift in Sources */, - 6105C5152D0C584F00C4C5EE /* INVMetricTests.swift in Sources */, - D23F8EB629DDCD38001CFAE8 /* RUMViewsHandlerTests.swift in Sources */, - 61C713CB2A3DC22700FA735A /* RUMTests.swift in Sources */, - D23F8EB829DDCD38001CFAE8 /* RUMActionsHandlerTests.swift in Sources */, - AA0001012A000004000B0001 /* RUMScrollHandlerTests.swift in Sources */, - AA0001012A000006000B0001 /* UIScrollViewDelegateProxyTests.swift in Sources */, - AA0001012A000007000B0001 /* UIScrollViewSwizzlerTests.swift in Sources */, - AA0001012A000009000B0001 /* ThirdPartyDelegateProxy.swift in Sources */, - 61C713AE2A3B793E00FA735A /* RUMMonitorProtocolTests.swift in Sources */, - 6105C4FB2CEBD72600C4C5EE /* TNSMetricTests.swift in Sources */, - D23F8EBA29DDCD38001CFAE8 /* ViewIdentifierTests.swift in Sources */, - 96F70D582DDE253F00D3736B /* SwiftUIComponentDetectorTests.swift in Sources */, - D23F8EBE29DDCD38001CFAE8 /* WebViewEventReceiverTests.swift in Sources */, - D23F8EBF29DDCD38001CFAE8 /* URLSessionRUMResourcesHandlerTests.swift in Sources */, - 117ADDDA2EAA8A90008BD9D8 /* StartupTypeHandlerTests.swift in Sources */, - D23F8EC029DDCD38001CFAE8 /* RUMEventSanitizerTests.swift in Sources */, - D253EE9C2B98B37C0010B589 /* ViewCacheTests.swift in Sources */, - 6176C1732ABDBA2E00131A70 /* MonitorTests.swift in Sources */, - D23F8EC129DDCD38001CFAE8 /* RUMEventsMapperTests.swift in Sources */, - 6167E6DB2B8004A500C3CA2D /* AppHangsWatchdogThreadTests.swift in Sources */, - 3C0D5DEA2A543EA300446CF9 /* RUMViewEventsFilterTests.swift in Sources */, - 1124D5332EA6D4050002E053 /* RUMAppLaunchManagerTests.swift in Sources */, - 118ACA452EEEED24004E7F20 /* AppLaunchMetricControllerTests.swift in Sources */, - D23F8EC429DDCD38001CFAE8 /* RUMCommandTests.swift in Sources */, - 9678E2762E55CD200094B106 /* RUMFeatureOperationManagerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - D24067FD27CE6C9E00C04F44 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 1434A4672B7F8D880072E3BB /* DebugOTelTracingViewController.swift in Sources */, - D2F44FC3299BD5600074B0D9 /* UIViewController+KeyboardControlling.swift in Sources */, - D240680827CE6C9E00C04F44 /* ConsoleOutputInterceptor.swift in Sources */, - D240681E27CE6C9E00C04F44 /* ExampleAppDelegate.swift in Sources */, - D240682B27CE6C9E00C04F44 /* UIButton+Disabling.swift in Sources */, - D240682D27CE6C9E00C04F44 /* Environment.swift in Sources */, - D240686827CF642900C04F44 /* SwiftUI.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; D257953A298ABA65008A1BE5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -9562,7 +9448,6 @@ files = ( 6188697C2A4376F700E8996B /* RUMConfigurationTests.swift in Sources */, 61DCC8472C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */, - 21A5D61036FA5C28E71ED824 /* TimeseriesSessionCollectorTests.swift in Sources */, 7F8C00B87F71287FB535342F /* DeltaEncoderTests.swift in Sources */, D29A9FA629DDB483005C54A4 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, 61C4534A2C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift in Sources */, From ef511038f3df89a4c17323c51b3c9e1be98d51e2 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 5 May 2026 16:29:34 +0200 Subject: [PATCH 053/102] Add TimeseriesSessionCollector with memory and CPU collection --- .../Tests/Timeseries/TimeseriesSessionCollectorTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 3066f9b37c..0e45abf2d4 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -251,6 +251,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { let tsDict = try! XCTUnwrap(dict["timeseries"] as? [String: Any]) XCTAssertEqual(tsDict["schema"] as? String, "delta-object") let dataDict = try! XCTUnwrap(tsDict["data"] as? [String: Any]) + XCTAssertEqual(dataDict["resolution"] as? String, "ns") XCTAssertNotNil(dataDict["ts"]) XCTAssertNotNil(dataDict["memory_max"]) XCTAssertNotNil(dataDict["memory_percent"]) @@ -315,6 +316,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { let tsDict = try! XCTUnwrap(dict["timeseries"] as? [String: Any]) XCTAssertEqual(tsDict["schema"] as? String, "delta-scalar") let dataDict = try! XCTUnwrap(tsDict["data"] as? [String: Any]) + XCTAssertEqual(dataDict["resolution"] as? String, "ns") XCTAssertNotNil(dataDict["ts"]) XCTAssertNotNil(dataDict["value"]) } From 5aa78a76a2838b7a1a9be334798f1d7fac4d142c Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 5 May 2026 16:29:51 +0200 Subject: [PATCH 054/102] Register timeseries source and test files in Xcode project --- Datadog/Datadog.xcodeproj/project.pbxproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index fd4d36430f..65cc2b91f5 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -2897,6 +2897,7 @@ BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoder.swift; sourceTree = ""; }; 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollectorTests.swift; sourceTree = ""; }; BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoder.swift; sourceTree = ""; }; + 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollectorTests.swift; sourceTree = ""; }; 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoderTests.swift; sourceTree = ""; }; B3BBBCBB265E71D100943419 /* VitalMemoryReaderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VitalMemoryReaderTests.swift; sourceTree = ""; }; B3E46CAA2D91B3A400BABF66 /* NetworkContextProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkContextProvider.swift; sourceTree = ""; }; From 3747ff33cc22ea024911f14da4a0133da9321003 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Fri, 22 May 2026 16:22:42 +0200 Subject: [PATCH 055/102] Add pause/resume lifecycle support to TimeseriesSessionCollector --- .../TimeseriesSessionCollector.swift | 51 ++++++- .../TimeseriesSessionCollectorTests.swift | 134 ++++++++++++++++++ 2 files changed, 180 insertions(+), 5 deletions(-) diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index bf3c0dce69..ee3c4a9442 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -10,6 +10,8 @@ import DatadogInternal /// Defines the interface for collecting timeseries data during a RUM session. internal protocol TimeseriesCollecting: AnyObject { func start(sessionID: String, applicationID: String, sessionType: RUMSessionType) + func pause() + func resume() func stop() } @@ -24,6 +26,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private let compressionSampler: () -> Bool private let batchSize: Int private let samplingInterval: TimeInterval + private let collectInBackground: Bool private let featureScope: FeatureScope private let totalRAM: Double @@ -34,6 +37,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private var sessionType: RUMSessionType = .user private var useDeltaCompression: Bool = false private var timer: DispatchSourceTimer? + private var isPaused: Bool = false /// All buffer mutations and timer events run on this queue. private let queue = DispatchQueue(label: "com.datadoghq.timeseries-collector", qos: .utility) @@ -43,12 +47,14 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { featureScope: FeatureScope, batchSize: Int = 30, samplingInterval: TimeInterval = 1, + collectInBackground: Bool = false, cpuUsageProvider: (() -> Double?)? = nil, compressionSampler: @escaping () -> Bool = { Bool.random() } ) { self.memoryReader = memoryReader self.batchSize = batchSize self.samplingInterval = samplingInterval + self.collectInBackground = collectInBackground self.featureScope = featureScope self.totalRAM = Double(ProcessInfo.processInfo.physicalMemory) self.cpuUsageProvider = cpuUsageProvider ?? { TimeseriesSessionCollector.processCPU() } @@ -108,13 +114,39 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.useDeltaCompression = self.compressionSampler() self.memoryBuffer = [] self.cpuBuffer = [] + self.isPaused = false self.timer?.cancel() - let timer = DispatchSource.makeTimerSource(queue: self.queue) - timer.schedule(deadline: .now() + samplingInterval, repeating: samplingInterval) - timer.setEventHandler { [weak self] in self?.sample() } - timer.resume() - self.timer = timer + self.timer = self.makeTimer() + } + } + + /// Suspends sampling and flushes buffered data. Session state is preserved for `resume()`. Idempotent. + /// No-op when `collectInBackground` is `true`. + func pause() { + queue.async { [weak self] in + guard let self = self else { + return + } + if self.collectInBackground || self.isPaused || self.timer == nil { + return + } + self.timer?.cancel() + self.timer = nil + self.isPaused = true + self.flushMemory() + self.flushCPU() + } + } + + /// Resumes sampling after `pause()`. Idempotent — only takes effect if currently paused. + func resume() { + queue.async { [weak self] in + guard let self = self, self.isPaused else { + return + } + self.isPaused = false + self.timer = self.makeTimer() } } @@ -126,11 +158,20 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } self.timer?.cancel() self.timer = nil + self.isPaused = false self.flushMemory() self.flushCPU() } } + private func makeTimer() -> DispatchSourceTimer { + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + samplingInterval, repeating: samplingInterval) + timer.setEventHandler { [weak self] in self?.sample() } + timer.resume() + return timer + } + // MARK: - Private private func sample() { diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 0e45abf2d4..a30fe57425 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -4,6 +4,8 @@ * Copyright 2019-Present Datadog, Inc. */ +#if !os(watchOS) + import XCTest import TestUtilities import DatadogInternal @@ -375,6 +377,136 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertTrue(featureScope.eventsWritten.compactMap { $0 as? AnyEncodable }.isEmpty) } + // MARK: - Pause / resume + + func testWhenPaused_itStopsCollectingSamples() { + // Given + memoryReader.vitalData = 1_000_000 + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + compressionSampler: { false } + ) + + let samplingExpectation = self.expectation(description: "initial samples collected") + samplingExpectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { samplingExpectation.fulfill() } + + collector.start(sessionID: "session-pause", applicationID: "app-1", sessionType: .user) + waitForExpectations(timeout: 2) + + let countBeforePause = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count + XCTAssertGreaterThan(countBeforePause, 0, "Should have collected events before pause") + + // When — pause and wait for more potential samples + let pauseExpectation = self.expectation(description: "pause settled") + collector.pause() + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3) { pauseExpectation.fulfill() } + waitForExpectations(timeout: 2) + + // Then — pause() flushes the partial buffer; no further events written + let countAfterPause = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count + XCTAssertGreaterThanOrEqual(countAfterPause, countBeforePause, "pause() must not drop buffered data") + + let countAfterSettle = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count + XCTAssertEqual(countAfterPause, countAfterSettle, "No further events should be written while paused") + collector.stop() + } + + func testWhenResumedAfterPause_itContinuesSampling() { + // Given + memoryReader.vitalData = 1_000_000 + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + compressionSampler: { false } + ) + + collector.start(sessionID: "session-resume", applicationID: "app-1", sessionType: .user) + + let pauseExpectation = self.expectation(description: "pause settled") + collector.pause() + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.1) { pauseExpectation.fulfill() } + waitForExpectations(timeout: 2) + + let countAfterPause = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count + + // When — resume and let samples accumulate + let resumeExpectation = self.expectation(description: "resumed samples collected") + resumeExpectation.assertForOverFulfill = false + collector.resume() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { resumeExpectation.fulfill() } + waitForExpectations(timeout: 2) + collector.stop() + + // Then — new events were written after resume + let countAfterResume = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count + XCTAssertGreaterThan(countAfterResume, countAfterPause, "Expected new events after resume") + } + + func testWhenCollectInBackgroundEnabled_pauseIsNoOp() { + // Given + memoryReader.vitalData = 1_000_000 + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 2, + samplingInterval: 0.05, + collectInBackground: true, + cpuUsageProvider: { nil }, + compressionSampler: { false } + ) + + let startExpectation = self.expectation(description: "initial samples") + startExpectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { startExpectation.fulfill() } + + collector.start(sessionID: "session-bg", applicationID: "app-1", sessionType: .user) + waitForExpectations(timeout: 2) + + let countBeforePause = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count + XCTAssertGreaterThan(countBeforePause, 0) + + // When — pause should be a no-op + let afterPauseExpectation = self.expectation(description: "sampling continues after pause") + afterPauseExpectation.assertForOverFulfill = false + collector.pause() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { afterPauseExpectation.fulfill() } + waitForExpectations(timeout: 2) + collector.stop() + + // Then — events keep accumulating + let countAfterPause = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count + XCTAssertGreaterThan(countAfterPause, countBeforePause, "Sampling should continue when collectInBackground = true") + } + + func testWhenPauseCalledBeforeStart_itIsNoOp() { + // Given — collector not yet started + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil } + ) + + // When / Then — should not crash + collector.pause() + collector.resume() + + let settleExpectation = self.expectation(description: "settle") + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.1) { settleExpectation.fulfill() } + waitForExpectations(timeout: 2) + + XCTAssertTrue(featureScope.eventsWritten.isEmpty) + } + // MARK: - Timeseries range func testTimestampsAreMonotonicallyIncreasing() { @@ -409,3 +541,5 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(event.timeseries.end, timestamps.last) } } + +#endif From 9b177b503dfd7ceb5ad93549a4f2ed0942cdd40d Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 3 Jun 2026 10:32:16 +0200 Subject: [PATCH 056/102] Overflow safety on timestamp and try! in tests --- Datadog/Datadog.xcodeproj/project.pbxproj | 3 --- .../TimeseriesSessionCollector.swift | 2 +- .../TimeseriesSessionCollectorTests.swift | 24 +++++++++---------- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 65cc2b91f5..44e41d89bb 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -747,9 +747,6 @@ 61DA8CB828647A500074A606 /* InternalLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DA8CB728647A500074A606 /* InternalLoggerTests.swift */; }; 61DB33B225DEDFC200F7EA71 /* CustomObjcViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 61DB33B125DEDFC200F7EA71 /* CustomObjcViewController.m */; }; 61DCC8472C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DCC8462C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift */; }; - 61DCC8482C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DCC8462C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift */; }; - B51668F3A97DEAC2917B0F44 /* TimeseriesSessionCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */; }; - 21A5D61036FA5C28E71ED824 /* TimeseriesSessionCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */; }; BB1A2B3C4D5E6F7800000004 /* DeltaEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */; }; BB1A2B3C4D5E6F7800000005 /* DeltaEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */; }; 7F8C00B87F71287FB535342E /* DeltaEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */; }; diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index ee3c4a9442..715406e33e 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -175,7 +175,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { // MARK: - Private private func sample() { - let now = Int64(Date().timeIntervalSince1970 * 1_000_000_000) + let now = Int64.ddWithNoOverflow(Date().timeIntervalSince1970 * 1_000_000_000) if let bytes = memoryReader.readVitalData() { let memoryPercent = totalRAM > 0 ? bytes / totalRAM * 100 : 0 diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index a30fe57425..d9041dda14 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -181,7 +181,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { // MARK: - Session restart - func testWhenStartIsCalledAgain_itUsesNewSessionMetadata() { + func testWhenStartIsCalledAgain_itUsesNewSessionMetadata() throws { // Given memoryReader.vitalData = 512_000 let collector = TimeseriesSessionCollector( @@ -215,13 +215,13 @@ class TimeseriesSessionCollectorTests: XCTestCase { // Then — the flushed event should carry session-2 metadata let events = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) - let lastEvent = try! XCTUnwrap(events.last) + let lastEvent = try XCTUnwrap(events.last) XCTAssertEqual(lastEvent.session.id, "session-2") } // MARK: - Schema coin flip - func testWhenDeltaCompressionSampled_itWritesDeltaEventForMemory() { + func testWhenDeltaCompressionSampled_itWritesDeltaEventForMemory() throws { // Given memoryReader.vitalData = 1_000_000 let collector = TimeseriesSessionCollector( @@ -248,11 +248,11 @@ class TimeseriesSessionCollectorTests: XCTestCase { let anyEncodableEvents = featureScope.eventsWritten.compactMap { $0 as? AnyEncodable } XCTAssertFalse(anyEncodableEvents.isEmpty, "Expected delta-schema AnyEncodable event") - let jsonData = try! JSONEncoder().encode(anyEncodableEvents[0]) - let dict = try! XCTUnwrap(try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) - let tsDict = try! XCTUnwrap(dict["timeseries"] as? [String: Any]) + let jsonData = try JSONEncoder().encode(anyEncodableEvents[0]) + let dict = try XCTUnwrap(try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) + let tsDict = try XCTUnwrap(dict["timeseries"] as? [String: Any]) XCTAssertEqual(tsDict["schema"] as? String, "delta-object") - let dataDict = try! XCTUnwrap(tsDict["data"] as? [String: Any]) + let dataDict = try XCTUnwrap(tsDict["data"] as? [String: Any]) XCTAssertEqual(dataDict["resolution"] as? String, "ns") XCTAssertNotNil(dataDict["ts"]) XCTAssertNotNil(dataDict["memory_max"]) @@ -286,7 +286,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertTrue(featureScope.eventsWritten.compactMap { $0 as? AnyEncodable }.isEmpty) } - func testWhenDeltaCompressionSampled_itWritesDeltaEventForCPU() { + func testWhenDeltaCompressionSampled_itWritesDeltaEventForCPU() throws { // Given memoryReader.vitalData = nil let collector = TimeseriesSessionCollector( @@ -313,11 +313,11 @@ class TimeseriesSessionCollectorTests: XCTestCase { let anyEncodableEvents = featureScope.eventsWritten.compactMap { $0 as? AnyEncodable } XCTAssertFalse(anyEncodableEvents.isEmpty, "Expected delta-schema AnyEncodable event") - let jsonData = try! JSONEncoder().encode(anyEncodableEvents[0]) - let dict = try! XCTUnwrap(try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) - let tsDict = try! XCTUnwrap(dict["timeseries"] as? [String: Any]) + let jsonData = try JSONEncoder().encode(anyEncodableEvents[0]) + let dict = try XCTUnwrap(try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) + let tsDict = try XCTUnwrap(dict["timeseries"] as? [String: Any]) XCTAssertEqual(tsDict["schema"] as? String, "delta-scalar") - let dataDict = try! XCTUnwrap(tsDict["data"] as? [String: Any]) + let dataDict = try XCTUnwrap(tsDict["data"] as? [String: Any]) XCTAssertEqual(dataDict["resolution"] as? String, "ns") XCTAssertNotNil(dataDict["ts"]) XCTAssertNotNil(dataDict["value"]) From 43e915ea87b2ff412c6c7c5db4bcc9043e850b61 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 10 Jun 2026 15:15:53 +0200 Subject: [PATCH 057/102] Apply CR Suggestions --- Datadog/Datadog.xcodeproj/project.pbxproj | 5 + .../TimeseriesSessionCollector.swift | 56 +++++--- .../TimeseriesSessionCollectorTests.swift | 129 +++++++++++++++++- 3 files changed, 172 insertions(+), 18 deletions(-) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 44e41d89bb..7937569afc 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -751,6 +751,7 @@ BB1A2B3C4D5E6F7800000005 /* DeltaEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */; }; 7F8C00B87F71287FB535342E /* DeltaEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */; }; 7F8C00B87F71287FB535342F /* DeltaEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */; }; + 7F8C00B87F71287FB535342B /* TimeseriesSessionCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */; }; 61DCC84E2C071DCD00CB59E5 /* TelemetryInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DCC84D2C071DCD00CB59E5 /* TelemetryInterceptor.swift */; }; 61E262D42EB2592C0041E70F /* DatadogFlags.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5BA8C2ED2E784B3C00B1DA80 /* DatadogFlags.framework */; }; 61E5333824B84EE2003D6C4E /* DebugRUMViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E5333724B84EE2003D6C4E /* DebugRUMViewController.swift */; }; @@ -6201,6 +6202,7 @@ isa = PBXGroup; children = ( BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */, + BB1A2B3C4D5E6F7800000001 /* TimeseriesSessionCollector.swift */, ); path = Timeseries; sourceTree = ""; @@ -6209,6 +6211,7 @@ isa = PBXGroup; children = ( 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */, + 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */, ); path = Timeseries; sourceTree = ""; @@ -9318,6 +9321,7 @@ D29A9F5A29DD85BB005C54A4 /* RUMScopeDependencies.swift in Sources */, D29A9F5B29DD85BB005C54A4 /* VitalMemoryReader.swift in Sources */, BB1A2B3C4D5E6F7800000005 /* DeltaEncoder.swift in Sources */, + D29A9FFF29DD85BB005C54A4 /* TimeseriesSessionCollector.swift in Sources */, 5B1D02862E8EB78800AB2391 /* FlagEvaluationReceiver.swift in Sources */, 962900242D8351AB008DFE39 /* TopLevelReflector.swift in Sources */, 6194B9332BB451DB00179430 /* FatalAppHangsHandler.swift in Sources */, @@ -9447,6 +9451,7 @@ 6188697C2A4376F700E8996B /* RUMConfigurationTests.swift in Sources */, 61DCC8472C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */, 7F8C00B87F71287FB535342F /* DeltaEncoderTests.swift in Sources */, + 7F8C00B87F71287FB535342B /* TimeseriesSessionCollectorTests.swift in Sources */, D29A9FA629DDB483005C54A4 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, 61C4534A2C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift in Sources */, D29A9FBD29DDB483005C54A4 /* RUMSessionScopeTests.swift in Sources */, diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 715406e33e..6ed09f3ab1 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -15,7 +15,7 @@ internal protocol TimeseriesCollecting: AnyObject { func stop() } -/// Collects memory and CPU samples at 1s intervals during a RUM session and flushes them +/// Collects memory and CPU samples at configurable intervals (default: 1 s) during a RUM session and flushes them /// as `RUMTimeseriesMemoryEvent` / `RUMTimeseriesCpuEvent` batches via the RUM feature scope. /// /// At session start a coin is flipped: 50% of sessions send full-array `object` schema events, @@ -49,14 +49,15 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { samplingInterval: TimeInterval = 1, collectInBackground: Bool = false, cpuUsageProvider: (() -> Double?)? = nil, - compressionSampler: @escaping () -> Bool = { Bool.random() } + compressionSampler: @escaping () -> Bool = { Bool.random() }, + totalRAM: Double = Double(ProcessInfo.processInfo.physicalMemory) ) { self.memoryReader = memoryReader self.batchSize = batchSize self.samplingInterval = samplingInterval self.collectInBackground = collectInBackground self.featureScope = featureScope - self.totalRAM = Double(ProcessInfo.processInfo.physicalMemory) + self.totalRAM = totalRAM self.cpuUsageProvider = cpuUsageProvider ?? { TimeseriesSessionCollector.processCPU() } self.compressionSampler = compressionSampler } @@ -86,6 +87,9 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } var total = 0.0 for i in 0.. Date: Wed, 10 Jun 2026 16:56:35 +0200 Subject: [PATCH 058/102] Apply CR Suggestions --- .../TimeseriesSessionCollector.swift | 42 ++++++++++--------- .../TimeseriesSessionCollectorTests.swift | 10 ++--- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 6ed09f3ab1..974d92e856 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -222,7 +222,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let useDelta = self.useDeltaCompression featureScope.eventWriteContext { context, writer in - let offsetNs = Int64(context.serverTimeOffset * 1_000_000_000) + let offsetNs = context.serverTimeOffset.dd.toInt64Nanoseconds let adjustedBatch = batch.map { sample in RUMTimeseriesMemoryEvent.Timeseries.Data( dataPoint: sample.dataPoint, @@ -249,15 +249,16 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { version: context.version ) - if useDelta, - let deltaData = DeltaEncoder.encodeMemory(adjustedBatch), - let eventData = try? JSONEncoder().encode(objectEvent), - var dict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any], - var ts = dict["timeseries"] as? [String: Any] { - ts["schema"] = "delta-object" - ts["data"] = deltaData - dict["timeseries"] = ts - writer.write(value: AnyEncodable(dict)) + if useDelta { + if let deltaData = DeltaEncoder.encodeMemory(adjustedBatch), + let eventData = try? JSONEncoder().encode(objectEvent), + var dict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any], + var ts = dict["timeseries"] as? [String: Any] { + ts["schema"] = "delta-object" + ts["data"] = deltaData + dict["timeseries"] = ts + writer.write(value: AnyEncodable(dict)) + } } else { writer.write(value: objectEvent) } @@ -279,7 +280,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let useDelta = self.useDeltaCompression featureScope.eventWriteContext { context, writer in - let offsetNs = Int64(context.serverTimeOffset * 1_000_000_000) + let offsetNs = context.serverTimeOffset.dd.toInt64Nanoseconds let adjustedBatch = batch.map { sample in RUMTimeseriesCpuEvent.Timeseries.Data( dataPoint: sample.dataPoint, @@ -306,15 +307,16 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { version: context.version ) - if useDelta, - let deltaData = DeltaEncoder.encodeCPU(adjustedBatch), - let eventData = try? JSONEncoder().encode(objectEvent), - var dict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any], - var ts = dict["timeseries"] as? [String: Any] { - ts["schema"] = "delta-scalar" - ts["data"] = deltaData - dict["timeseries"] = ts - writer.write(value: AnyEncodable(dict)) + if useDelta { + if let deltaData = DeltaEncoder.encodeCPU(adjustedBatch), + let eventData = try? JSONEncoder().encode(objectEvent), + var dict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any], + var ts = dict["timeseries"] as? [String: Any] { + ts["schema"] = "delta-scalar" + ts["data"] = deltaData + dict["timeseries"] = ts + writer.write(value: AnyEncodable(dict)) + } } else { writer.write(value: objectEvent) } diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 02f338f157..22a84d1566 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -475,8 +475,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertTrue(featureScope.eventsWritten.compactMap { $0 as? AnyEncodable }.isEmpty) } - func testWhenDeltaCompressionSampledWithSingleSample_itFallsBackToObjectEvent() { - // Given — delta sampled but only 1 sample: DeltaEncoder returns nil, falls back to object + func testWhenDeltaCompressionSampledWithSingleSample_itDropsTheEvent() { + // Given — delta sampled but only 1 sample: DeltaEncoder returns nil, event is dropped memoryReader.vitalData = 1_000_000 let collector = TimeseriesSessionCollector( memoryReader: memoryReader, @@ -489,7 +489,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { let expectation = self.expectation(description: "one sample collected") DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { expectation.fulfill() } - collector.start(sessionID: "session-fallback", applicationID: "app-fallback", sessionType: .user) + collector.start(sessionID: "session-drop", applicationID: "app-drop", sessionType: .user) waitForExpectations(timeout: 2) let stopExpectation = self.expectation(description: "stop completed") @@ -497,8 +497,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.2) { stopExpectation.fulfill() } waitForExpectations(timeout: 2) - // Then — falls back to object event, no AnyEncodable - XCTAssertFalse(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty) + // Then — event is dropped entirely, nothing written + XCTAssertTrue(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty) XCTAssertTrue(featureScope.eventsWritten.compactMap { $0 as? AnyEncodable }.isEmpty) } From c3a72b278b17ec9c56f898832dc3d581db913cd3 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Fri, 22 May 2026 16:23:09 +0200 Subject: [PATCH 059/102] RUM-13949 wire timeseries pause/resume to app lifecycle in RUMSessionScope --- .../RUMMonitor/Scopes/RUMSessionScope.swift | 2 + .../Scopes/RUMSessionScopeTests.swift | 49 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift index 21eabc2fae..dab7a47443 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift @@ -281,11 +281,13 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { case let appLifecycleCommand as RUMHandleAppLifecycleEventCommand where appLifecycleCommand.event == .didEnterBackground: hadApplicationLaunchViewWhenEnteringBackground = activeView?.viewPath == RUMOffViewEventsHandlingRule.Constants.applicationLaunchViewURL appLaunchManager.process(command, context: context, writer: writer) + dependencies.timeseriesCollector?.pause() case let appLifecycleCommand as RUMHandleAppLifecycleEventCommand where appLifecycleCommand.event == .willEnterForeground: if hadApplicationLaunchViewWhenEnteringBackground == true { startApplicationLaunchView(on: appLifecycleCommand, context: context, writer: writer) } hadApplicationLaunchViewWhenEnteringBackground = nil + dependencies.timeseriesCollector?.resume() case let operationStepVitalCommand as RUMOperationStepVitalCommand: // Forward command to the feature operation manager diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift index 462a226eff..c9b21061b0 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift @@ -783,12 +783,58 @@ class RUMSessionScopeTests: XCTestCase { XCTAssertFalse(collector.lastStartedSessionID?.isEmpty ?? true) XCTAssertEqual(collector.lastStartedSessionType, scope.context.sessionID != .nullUUID ? .user : .user) } + + func testWhenAppEntersBackground_itPausesTimeseriesCollector() { + // Given + let collector = TimeseriesCollectorSpy() + let currentTime = Date() + let scope: RUMSessionScope = .mockWith( + parent: parent, + startTime: currentTime, + dependencies: .mockWith(timeseriesCollector: collector) + ) + + // When + _ = scope.process( + command: RUMHandleAppLifecycleEventCommand(time: currentTime, event: .didEnterBackground), + context: context, + writer: writer + ) + + // Then + XCTAssertEqual(collector.pauseCallCount, 1) + XCTAssertEqual(collector.resumeCallCount, 0) + } + + func testWhenAppEntersForeground_itResumesTimeseriesCollector() { + // Given + let collector = TimeseriesCollectorSpy() + let currentTime = Date() + let scope: RUMSessionScope = .mockWith( + parent: parent, + startTime: currentTime, + dependencies: .mockWith(timeseriesCollector: collector) + ) + + // When + _ = scope.process( + command: RUMHandleAppLifecycleEventCommand(time: currentTime, event: .willEnterForeground), + context: context, + writer: writer + ) + + // Then + XCTAssertEqual(collector.resumeCallCount, 1) + XCTAssertEqual(collector.pauseCallCount, 0) + } } // MARK: - Test Helpers private class TimeseriesCollectorSpy: TimeseriesCollecting { var startCallCount = 0 + var pauseCallCount = 0 + var resumeCallCount = 0 var stopCallCount = 0 var lastStartedSessionID: String? var lastStartedApplicationID: String? @@ -801,6 +847,9 @@ private class TimeseriesCollectorSpy: TimeseriesCollecting { lastStartedSessionType = sessionType } + func pause() { pauseCallCount += 1 } + func resume() { resumeCallCount += 1 } + func stop() { stopCallCount += 1 } From e4e22dcc95b9ef81c19e98bf477cfc3e9fab2804 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 2 Jun 2026 15:38:33 +0200 Subject: [PATCH 060/102] Added possibility to change batch size of Timeseries event --- DatadogRUM/Sources/Feature/RUMFeature.swift | 3 ++- DatadogRUM/Sources/RUMConfiguration.swift | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index ada3f9defe..1697c3b35d 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -204,7 +204,8 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider }) else { return nil } return TimeseriesSessionCollector( memoryReader: vitalsReaders.memory, - featureScope: featureScope + featureScope: featureScope, + batchSize: configuration.timeseriesBatchSize ) }() ) diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index 669eb1f554..14c3fec8e3 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -336,6 +336,11 @@ extension RUM { /// Default: `false`. public var enableTimeseries: Bool + /// The number of samples collected before a timeseries batch is flushed. + /// + /// Default: `30`. + public var timeseriesBatchSize: Int + /// Feature flags to preview features in RUM. public var featureFlags: FeatureFlags @@ -563,6 +568,7 @@ extension RUM.Configuration { /// - telemetrySampleRate: The sampling rate for SDK internal telemetry utilized by Datadog. Must be a value between `0` and `100`. Default: `20`. /// - collectAccessibility: Determines whether accessibility data should be collected and included in RUM view events. Default: `false`. /// - enableTimeseries: Enables collection of memory and CPU timeseries events. Default: `false`. + /// - timeseriesBatchSize: The number of samples collected before a timeseries batch is flushed. Default: `30`. /// - featureFlags: Experimental feature flags. /// /// - Note: On watchOS, automatic UIKit and SwiftUI view/action tracking is unavailable. The predicate parameters will be ignored. @@ -599,6 +605,7 @@ extension RUM.Configuration { telemetrySampleRate: SampleRate = 20, collectAccessibility: Bool = false, enableTimeseries: Bool = false, + timeseriesBatchSize: Int = 30, featureFlags: FeatureFlags = .defaults ) { self.applicationID = applicationID @@ -629,6 +636,7 @@ extension RUM.Configuration { self.telemetrySampleRate = telemetrySampleRate self.collectAccessibility = collectAccessibility self.enableTimeseries = enableTimeseries + self.timeseriesBatchSize = timeseriesBatchSize self.featureFlags = featureFlags } #else @@ -656,6 +664,7 @@ extension RUM.Configuration { telemetrySampleRate: SampleRate = 20, collectAccessibility: Bool = false, enableTimeseries: Bool = false, + timeseriesBatchSize: Int = 30, featureFlags: FeatureFlags = .defaults ) { self.applicationID = applicationID @@ -681,6 +690,7 @@ extension RUM.Configuration { self.telemetrySampleRate = telemetrySampleRate self.collectAccessibility = collectAccessibility self.enableTimeseries = enableTimeseries + self.timeseriesBatchSize = timeseriesBatchSize self.featureFlags = featureFlags } #endif From 9c69c4122639e103b5f147a38c6e20b1ae772cd3 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 11 Jun 2026 14:06:39 +0200 Subject: [PATCH 061/102] Removed duplicate and ran api-surface --- DatadogRUM/Sources/Feature/RUMFeature.swift | 22 +++++++++------------ api-surface-swift | 4 +++- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 1697c3b35d..bb76f76eaf 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -119,6 +119,10 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider } } + let vitalsReaders = configuration.vitalsUpdateFrequency.map { + VitalsReaders(frequency: $0.timeInterval, telemetry: core.telemetry) + } + let dependencies = RUMScopeDependencies( featureScope: featureScope, rumApplicationID: configuration.applicationID, @@ -149,12 +153,7 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider ? ViewHitchesReader(hangThreshold: configuration.appHangThreshold) : nil }, - vitalsReaders: configuration.vitalsUpdateFrequency.map { - VitalsReaders( - frequency: $0.timeInterval, - telemetry: core.telemetry - ) - }, + vitalsReaders: vitalsReaders, accessibilityReader: accessibilityReader, onSessionUpdate: onSessionUpdate, viewCache: ViewCache(dateProvider: configuration.dateProvider), @@ -198,16 +197,13 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider ) }, sessionType: configuration.sessionTypeOverride.flatMap { RUMSessionType(rawValue: $0) }, - timeseriesCollector: { - guard configuration.enableTimeseries, let vitalsReaders = configuration.vitalsUpdateFrequency.map({ - VitalsReaders(frequency: $0.timeInterval, telemetry: core.telemetry) - }) else { return nil } - return TimeseriesSessionCollector( - memoryReader: vitalsReaders.memory, + timeseriesCollector: configuration.enableTimeseries ? vitalsReaders.map { + TimeseriesSessionCollector( + memoryReader: $0.memory, featureScope: featureScope, batchSize: configuration.timeseriesBatchSize ) - }() + } : nil ) self.monitor = Monitor( diff --git a/api-surface-swift b/api-surface-swift index 5dd31fcef9..417e5e07bf 100644 --- a/api-surface-swift +++ b/api-surface-swift @@ -421,6 +421,8 @@ public enum RUM public var trackSlowFrames: Bool public var telemetrySampleRate: SampleRate public var collectAccessibility: Bool + public var enableTimeseries: Bool + public var timeseriesBatchSize: Int public var featureFlags: FeatureFlags public struct URLSessionTracking public var firstPartyHostsTracing: FirstPartyHostsTracing? @@ -443,7 +445,7 @@ public enum RUM case matchHeaders([String]) public init(firstPartyHostsTracing: RUM.Configuration.URLSessionTracking.FirstPartyHostsTracing? = nil,resourceAttributesProvider: RUM.ResourceAttributesProvider? = nil,trackResourceHeaders: TrackResourceHeaders = .disabled) [?] extension RUM.Configuration - public init(applicationID: String,sessionSampleRate: SampleRate = .maxSampleRate,uiKitViewsPredicate: UIKitRUMViewsPredicate? = nil,uiKitActionsPredicate: UIKitRUMActionsPredicate? = nil,swiftUIViewsPredicate: SwiftUIRUMViewsPredicate? = nil,swiftUIActionsPredicate: SwiftUIRUMActionsPredicate? = nil,urlSessionTracking: URLSessionTracking? = nil,trackFrustrations: Bool = true,trackBackgroundEvents: Bool = false,longTaskThreshold: TimeInterval? = 0.1,appHangThreshold: TimeInterval? = nil,trackWatchdogTerminations: Bool = false,vitalsUpdateFrequency: VitalsFrequency? = .average,networkSettledResourcePredicate: NetworkSettledResourcePredicate = TimeBasedTNSResourcePredicate(),nextViewActionPredicate: NextViewActionPredicate? = TimeBasedINVActionPredicate(),viewEventMapper: RUM.ViewEventMapper? = nil,resourceEventMapper: RUM.ResourceEventMapper? = nil,actionEventMapper: RUM.ActionEventMapper? = nil,errorEventMapper: RUM.ErrorEventMapper? = nil,longTaskEventMapper: RUM.LongTaskEventMapper? = nil,onSessionStart: RUM.SessionListener? = nil,customEndpoint: URL? = nil,trackAnonymousUser: Bool = true,trackMemoryWarnings: Bool = true,trackSlowFrames: Bool = true,telemetrySampleRate: SampleRate = 20,collectAccessibility: Bool = false,featureFlags: FeatureFlags = .defaults) + public init(applicationID: String,sessionSampleRate: SampleRate = .maxSampleRate,uiKitViewsPredicate: UIKitRUMViewsPredicate? = nil,uiKitActionsPredicate: UIKitRUMActionsPredicate? = nil,swiftUIViewsPredicate: SwiftUIRUMViewsPredicate? = nil,swiftUIActionsPredicate: SwiftUIRUMActionsPredicate? = nil,urlSessionTracking: URLSessionTracking? = nil,trackFrustrations: Bool = true,trackBackgroundEvents: Bool = false,longTaskThreshold: TimeInterval? = 0.1,appHangThreshold: TimeInterval? = nil,trackWatchdogTerminations: Bool = false,vitalsUpdateFrequency: VitalsFrequency? = .average,networkSettledResourcePredicate: NetworkSettledResourcePredicate = TimeBasedTNSResourcePredicate(),nextViewActionPredicate: NextViewActionPredicate? = TimeBasedINVActionPredicate(),viewEventMapper: RUM.ViewEventMapper? = nil,resourceEventMapper: RUM.ResourceEventMapper? = nil,actionEventMapper: RUM.ActionEventMapper? = nil,errorEventMapper: RUM.ErrorEventMapper? = nil,longTaskEventMapper: RUM.LongTaskEventMapper? = nil,onSessionStart: RUM.SessionListener? = nil,customEndpoint: URL? = nil,trackAnonymousUser: Bool = true,trackMemoryWarnings: Bool = true,trackSlowFrames: Bool = true,telemetrySampleRate: SampleRate = 20,collectAccessibility: Bool = false,enableTimeseries: Bool = false,timeseriesBatchSize: Int = 30,featureFlags: FeatureFlags = .defaults) [?] extension InternalExtension where ExtendedType == RUM.Configuration public var configurationTelemetrySampleRate: Float [?] extension RUM.Configuration From 5b54bc88ac39aadc32e8ea387aee70d060429ad0 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 11 Jun 2026 14:09:06 +0200 Subject: [PATCH 062/102] Fix tautological session type assertion in timeseries collector test --- DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift index c9b21061b0..3b86681260 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift @@ -781,7 +781,7 @@ class RUMSessionScopeTests: XCTestCase { XCTAssertEqual(collector.lastStartedApplicationID, applicationID) XCTAssertNotNil(collector.lastStartedSessionID, "Session ID should be set") XCTAssertFalse(collector.lastStartedSessionID?.isEmpty ?? true) - XCTAssertEqual(collector.lastStartedSessionType, scope.context.sessionID != .nullUUID ? .user : .user) + XCTAssertEqual(collector.lastStartedSessionType, .user) } func testWhenAppEntersBackground_itPausesTimeseriesCollector() { From b2a7945e623bddb8b11ae6927fb379651437abc9 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Fri, 12 Jun 2026 11:56:02 +0200 Subject: [PATCH 063/102] Rename memory_max to memory_footprint in timeseries schema and regenerate models --- .../Sources/Timeseries/DeltaEncoder.swift | 14 ++++++------- .../TimeseriesSessionCollector.swift | 2 +- .../Tests/Timeseries/DeltaEncoderTests.swift | 20 +++++++++---------- .../TimeseriesSessionCollectorTests.swift | 6 +++--- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift b/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift index 8f6dec0305..c388800c99 100644 --- a/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift +++ b/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift @@ -24,7 +24,7 @@ internal enum DeltaEncoder { /// { /// "precision": 4, /// "ts": [absoluteNs, delta1, delta2, ...], - /// "memory_max": [scaledInt64, delta1, delta2, ...], + /// "memory_footprint": [scaledInt64, delta1, delta2, ...], /// "memory_percent": [scaledInt64, delta1, ...] /// } /// ``` @@ -34,22 +34,22 @@ internal enum DeltaEncoder { } var ts: [Int64] = [] - var memoryMax: [Int64] = [] + var memoryFootprint: [Int64] = [] var memoryPercent: [Int64] = [] for (index, sample) in batch.enumerated() { if index == 0 { ts.append(sample.timestamp) - memoryMax.append(Int64.ddWithNoOverflow(sample.dataPoint.memoryMax * scale)) + memoryFootprint.append(Int64.ddWithNoOverflow(sample.dataPoint.memoryFootprint * scale)) memoryPercent.append(Int64.ddWithNoOverflow(sample.dataPoint.memoryPercent * scale)) } else { let prev = batch[index - 1] let (tsDelta, _) = sample.timestamp.subtractingReportingOverflow(prev.timestamp) ts.append(tsDelta) - let curMax = Int64.ddWithNoOverflow(sample.dataPoint.memoryMax * scale) - let prevMax = Int64.ddWithNoOverflow(prev.dataPoint.memoryMax * scale) + let curMax = Int64.ddWithNoOverflow(sample.dataPoint.memoryFootprint * scale) + let prevMax = Int64.ddWithNoOverflow(prev.dataPoint.memoryFootprint * scale) let (maxDelta, _) = curMax.subtractingReportingOverflow(prevMax) - memoryMax.append(maxDelta) + memoryFootprint.append(maxDelta) let curPct = Int64.ddWithNoOverflow(sample.dataPoint.memoryPercent * scale) let prevPct = Int64.ddWithNoOverflow(prev.dataPoint.memoryPercent * scale) let (pctDelta, _) = curPct.subtractingReportingOverflow(prevPct) @@ -61,7 +61,7 @@ internal enum DeltaEncoder { "precision": precision, "resolution": "ns", "ts": ts, - "memory_max": memoryMax, + "memory_footprint": memoryFootprint, "memory_percent": memoryPercent ] } diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 974d92e856..52a94c3f86 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -186,7 +186,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { if let bytes = memoryReader.readVitalData() { let memoryPercent = totalRAM > 0 ? bytes / totalRAM * 100 : 0 let dataPoint = RUMTimeseriesMemoryEvent.Timeseries.Data( - dataPoint: .init(memoryMax: bytes, memoryPercent: memoryPercent), + dataPoint: .init(memoryFootprint: bytes, memoryPercent: memoryPercent), timestamp: now ) memoryBuffer.append(dataPoint) diff --git a/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift b/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift index 999840d386..dc88111263 100644 --- a/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift +++ b/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift @@ -18,7 +18,7 @@ class DeltaEncoderTests: XCTestCase { func testEncodeMemory_returnsNilForSingleSample() { let sample = RUMTimeseriesMemoryEvent.Timeseries.Data( - dataPoint: .init(memoryMax: 100.0, memoryPercent: 10.0), + dataPoint: .init(memoryFootprint: 100.0, memoryPercent: 10.0), timestamp: 1_000_000_000 ) XCTAssertNil(DeltaEncoder.encodeMemory([sample])) @@ -27,9 +27,9 @@ class DeltaEncoderTests: XCTestCase { func testEncodeMemory_correctDeltaEncoding() throws { // Given let samples: [RUMTimeseriesMemoryEvent.Timeseries.Data] = [ - .init(dataPoint: .init(memoryMax: 100.0, memoryPercent: 10.0), timestamp: 1_000_000_000), - .init(dataPoint: .init(memoryMax: 200.5, memoryPercent: 20.0), timestamp: 2_000_000_000), - .init(dataPoint: .init(memoryMax: 200.5, memoryPercent: 20.5), timestamp: 3_000_000_000) + .init(dataPoint: .init(memoryFootprint: 100.0, memoryPercent: 10.0), timestamp: 1_000_000_000), + .init(dataPoint: .init(memoryFootprint: 200.5, memoryPercent: 20.0), timestamp: 2_000_000_000), + .init(dataPoint: .init(memoryFootprint: 200.5, memoryPercent: 20.5), timestamp: 3_000_000_000) ] // When @@ -42,9 +42,9 @@ class DeltaEncoderTests: XCTestCase { let ts = try XCTUnwrap(result["ts"] as? [Int64]) XCTAssertEqual(ts, [1_000_000_000, 1_000_000_000, 1_000_000_000]) - // memory_max: 100*10000=1_000_000, (200.5-100)*10000=1_005_000, 0 - let memoryMax = try XCTUnwrap(result["memory_max"] as? [Int64]) - XCTAssertEqual(memoryMax, [1_000_000, 1_005_000, 0]) + // memory_footprint: 100*10000=1_000_000, (200.5-100)*10000=1_005_000, 0 + let memoryFootprint = try XCTUnwrap(result["memory_footprint"] as? [Int64]) + XCTAssertEqual(memoryFootprint, [1_000_000, 1_005_000, 0]) // memory_percent: 10*10000=100_000, (20-10)*10000=100_000, (20.5-20)*10000=5_000 let memoryPercent = try XCTUnwrap(result["memory_percent"] as? [Int64]) @@ -55,12 +55,12 @@ class DeltaEncoderTests: XCTestCase { // Values scaled to near Int64.max / Int64.min to exercise subtractingReportingOverflow let hugeBytes = Double(Int64.max) / 10_000.0 let samples: [RUMTimeseriesMemoryEvent.Timeseries.Data] = [ - .init(dataPoint: .init(memoryMax: hugeBytes, memoryPercent: 100.0), timestamp: Int64.max), - .init(dataPoint: .init(memoryMax: 0.0, memoryPercent: 0.0), timestamp: 0) + .init(dataPoint: .init(memoryFootprint: hugeBytes, memoryPercent: 100.0), timestamp: Int64.max), + .init(dataPoint: .init(memoryFootprint: 0.0, memoryPercent: 0.0), timestamp: 0) ] // Should not crash let result = try XCTUnwrap(DeltaEncoder.encodeMemory(samples)) - XCTAssertNotNil(result["memory_max"] as? [Int64]) + XCTAssertNotNil(result["memory_footprint"] as? [Int64]) } // MARK: - CPU encoding diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 22a84d1566..478abcc389 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -50,7 +50,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(event.source, .ios) XCTAssertEqual(event.timeseries.name, "memory") XCTAssertEqual(event.timeseries.data.count, 2) - XCTAssertEqual(event.timeseries.data[0].dataPoint.memoryMax, 1_000_000) + XCTAssertEqual(event.timeseries.data[0].dataPoint.memoryFootprint, 1_000_000) XCTAssertEqual(event.timeseries.data[0].dataPoint.memoryPercent, 0.025, accuracy: 0.001) } @@ -114,7 +114,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { // Then XCTAssertFalse(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, "Expected memory events") XCTAssertFalse(featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self).isEmpty, "Expected CPU events") - XCTAssertEqual(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self)[0].timeseries.data[0].dataPoint.memoryMax, 2_000_000) + XCTAssertEqual(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self)[0].timeseries.data[0].dataPoint.memoryFootprint, 2_000_000) XCTAssertEqual(featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self)[0].timeseries.data[0].dataPoint.cpuUsage, 75.0) } @@ -380,7 +380,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { let dataDict = try XCTUnwrap(tsDict["data"] as? [String: Any]) XCTAssertEqual(dataDict["resolution"] as? String, "ns") XCTAssertNotNil(dataDict["ts"]) - XCTAssertNotNil(dataDict["memory_max"]) + XCTAssertNotNil(dataDict["memory_footprint"]) XCTAssertNotNil(dataDict["memory_percent"]) } From 3b28e9a54b5518f3d3f642f95590e36775f8406e Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 16 Jun 2026 10:48:35 +0200 Subject: [PATCH 064/102] Address Codex review comments on timeseries session wiring --- DatadogRUM/Sources/Feature/RUMFeature.swift | 3 ++- .../RUMMonitor/Scopes/RUMSessionScope.swift | 15 ++++++++++----- .../Timeseries/TimeseriesSessionCollector.swift | 1 + 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index bb76f76eaf..2464d74553 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -201,7 +201,8 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider TimeseriesSessionCollector( memoryReader: $0.memory, featureScope: featureScope, - batchSize: configuration.timeseriesBatchSize + batchSize: configuration.timeseriesBatchSize, + collectInBackground: configuration.trackBackgroundEvents ) } : nil ) diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift index dab7a47443..5233b4f3b0 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift @@ -171,11 +171,16 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { // Update fatal error context with recent RUM session state: dependencies.fatalErrorContext.sessionState = state - dependencies.timeseriesCollector?.start( - sessionID: sessionUUID.rawValue.uuidString.lowercased(), - applicationID: dependencies.rumApplicationID, - sessionType: dependencies.sessionType - ) + if sampler.isSampled { + dependencies.timeseriesCollector?.start( + sessionID: sessionUUID.rawValue.uuidString.lowercased(), + applicationID: dependencies.rumApplicationID, + sessionType: dependencies.sessionType + ) + if !context.applicationStateHistory.currentState.isRunningInForeground { + dependencies.timeseriesCollector?.pause() + } + } } /// Creates a new Session upon expiration of the previous one. diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 52a94c3f86..dd1efcaddd 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -52,6 +52,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { compressionSampler: @escaping () -> Bool = { Bool.random() }, totalRAM: Double = Double(ProcessInfo.processInfo.physicalMemory) ) { + precondition(batchSize >= 2, "timeseriesBatchSize must be at least 2 — delta encoding requires a minimum of 2 samples") self.memoryReader = memoryReader self.batchSize = batchSize self.samplingInterval = samplingInterval From 19af1b2d34e98b04ece1ee28332e760ff88b3220 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 16 Jun 2026 11:03:29 +0200 Subject: [PATCH 065/102] Clamp timeseriesBatchSize to minimum of 2 instead of precondition --- DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index dd1efcaddd..9ed5372cb4 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -52,9 +52,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { compressionSampler: @escaping () -> Bool = { Bool.random() }, totalRAM: Double = Double(ProcessInfo.processInfo.physicalMemory) ) { - precondition(batchSize >= 2, "timeseriesBatchSize must be at least 2 — delta encoding requires a minimum of 2 samples") self.memoryReader = memoryReader - self.batchSize = batchSize + self.batchSize = max(2, batchSize) self.samplingInterval = samplingInterval self.collectInBackground = collectInBackground self.featureScope = featureScope From 7bdb6bc9305626587efe1662d5bed1b73f198100 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Tue, 16 Jun 2026 14:44:03 +0200 Subject: [PATCH 066/102] Add tests for unsampled session and background-created session collector behavior --- .../Scopes/RUMSessionScopeTests.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift index 3b86681260..c889772470 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift @@ -784,6 +784,40 @@ class RUMSessionScopeTests: XCTestCase { XCTAssertEqual(collector.lastStartedSessionType, .user) } + func testWhenSessionIsNotSampled_itDoesNotStartTimeseriesCollector() { + // Given + let collector = TimeseriesCollectorSpy() + + // When + _ = RUMSessionScope.mockWith( + parent: parent, + dependencies: .mockWith(samplingRate: 0, timeseriesCollector: collector) + ) + + // Then + XCTAssertEqual(collector.startCallCount, 0) + } + + func testWhenSessionIsCreatedInBackground_itStartsThenPausesTimeseriesCollector() { + // Given + let collector = TimeseriesCollectorSpy() + let sessionStartTime = Date() + var context = self.context + context.applicationStateHistory = .mockAppInBackground(since: sessionStartTime) + + // When + _ = RUMSessionScope.mockWith( + parent: parent, + startTime: sessionStartTime, + context: context, + dependencies: .mockWith(timeseriesCollector: collector) + ) + + // Then + XCTAssertEqual(collector.startCallCount, 1) + XCTAssertEqual(collector.pauseCallCount, 1) + } + func testWhenAppEntersBackground_itPausesTimeseriesCollector() { // Given let collector = TimeseriesCollectorSpy() From da04f94326c2c535057516fa7c12dd5b18d8a488 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Fri, 10 Jul 2026 15:55:38 +0200 Subject: [PATCH 067/102] Fix Timeseries file registration in Datadog.xcodeproj --- Datadog/Datadog.xcodeproj/project.pbxproj | 80 ++++++++++------------- 1 file changed, 34 insertions(+), 46 deletions(-) diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 7937569afc..4eb8ce21d8 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + A0A0A0A0A0A0A0A0A0A00004 /* TimeseriesSessionCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0A0A0A0A0A0A0A0A0A00003 /* TimeseriesSessionCollector.swift */; }; + A0A0A0A0A0A0A0A0A0A00006 /* DeltaEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0A0A0A0A0A0A0A0A0A00005 /* DeltaEncoder.swift */; }; + A0A0A0A0A0A0A0A0A0A00008 /* TimeseriesSessionCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0A0A0A0A0A0A0A0A0A00007 /* TimeseriesSessionCollectorTests.swift */; }; + A0A0A0A0A0A0A0A0A0A0000A /* DeltaEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0A0A0A0A0A0A0A0A0A00009 /* DeltaEncoderTests.swift */; }; 05B9B20299CF44BB97F4A7C8 /* HeatmapIdentifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048E335C84F4B41E3ACA3B91 /* HeatmapIdentifierTests.swift */; }; 0904F9F42EE1DA6800ED9A22 /* UIKitExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0904F9F32EE1DA6800ED9A22 /* UIKitExtensionsTests.swift */; }; 0904F9F62EE1DA6800ED9A22 /* UIKitExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0904F9F32EE1DA6800ED9A22 /* UIKitExtensionsTests.swift */; }; @@ -76,6 +80,7 @@ 1166C59E2F76D840008E34BC /* TTIDMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1166C59D2F76D839008E34BC /* TTIDMessage.swift */; }; 1166C5A22F76EBCD008E34BC /* ProfilingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1166C5A02F76EBC1008E34BC /* ProfilingOptions.swift */; }; 1166C5A52F76EC0B008E34BC /* OperationOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1166C5A32F76EC05008E34BC /* OperationOptions.swift */; }; + 1166C5AA2F76ED7A008E34BC /* ProfilingOptions+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1166C5A92F76ED6F008E34BC /* ProfilingOptions+objc.swift */; }; 116F84062CFDD06700705755 /* SampleRateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 116F84052CFDD06700705755 /* SampleRateTests.swift */; }; 117ADDD92EAA8A90008BD9D8 /* StartupTypeHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 117ADDD82EAA8A90008BD9D8 /* StartupTypeHandlerTests.swift */; }; 117E52222D91A61A00A8E930 /* DatadogSessionReplay.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6133D1F52A6ED9E100384BEF /* DatadogSessionReplay.framework */; platformFilter = ios; settings = {ATTRIBUTES = (Weak, ); }; }; @@ -747,11 +752,6 @@ 61DA8CB828647A500074A606 /* InternalLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DA8CB728647A500074A606 /* InternalLoggerTests.swift */; }; 61DB33B225DEDFC200F7EA71 /* CustomObjcViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 61DB33B125DEDFC200F7EA71 /* CustomObjcViewController.m */; }; 61DCC8472C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DCC8462C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift */; }; - BB1A2B3C4D5E6F7800000004 /* DeltaEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */; }; - BB1A2B3C4D5E6F7800000005 /* DeltaEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */; }; - 7F8C00B87F71287FB535342E /* DeltaEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */; }; - 7F8C00B87F71287FB535342F /* DeltaEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */; }; - 7F8C00B87F71287FB535342B /* TimeseriesSessionCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */; }; 61DCC84E2C071DCD00CB59E5 /* TelemetryInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DCC84D2C071DCD00CB59E5 /* TelemetryInterceptor.swift */; }; 61E262D42EB2592C0041E70F /* DatadogFlags.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5BA8C2ED2E784B3C00B1DA80 /* DatadogFlags.framework */; }; 61E5333824B84EE2003D6C4E /* DebugRUMViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E5333724B84EE2003D6C4E /* DebugRUMViewController.swift */; }; @@ -1052,16 +1052,6 @@ D233E8002E4B588B00E7CFDE /* dd_pprof.h in Headers */ = {isa = PBXBuildFile; fileRef = D233E7FE2E4B588B00E7CFDE /* dd_pprof.h */; settings = {ATTRIBUTES = (Private, ); }; }; D233E8022E4B653F00E7CFDE /* dd_pprof.cpp in Sources */ = {isa = PBXBuildFile; fileRef = D233E8012E4B653F00E7CFDE /* dd_pprof.cpp */; }; D234613228B7713000055D4C /* FeatureContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D234613028B7712F00055D4C /* FeatureContextTests.swift */; }; - D240680827CE6C9E00C04F44 /* ConsoleOutputInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61441C902461A648003D8BB8 /* ConsoleOutputInterceptor.swift */; }; - D240681E27CE6C9E00C04F44 /* ExampleAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61441C0424616DE9003D8BB8 /* ExampleAppDelegate.swift */; }; - D240682B27CE6C9E00C04F44 /* UIButton+Disabling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61441C912461A648003D8BB8 /* UIButton+Disabling.swift */; }; - D240682D27CE6C9E00C04F44 /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614CADD62510BAC000B93D2D /* Environment.swift */; }; - D240683D27CE6C9E00C04F44 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 61441C0D24616DEC003D8BB8 /* Assets.xcassets */; }; - D240685527CF5D0100C04F44 /* DatadogCore.framework in ⚙️ Embed Framework Dependencies */ = {isa = PBXBuildFile; fileRef = D2CB6ED127C50EAE00A62B57 /* DatadogCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - D240685927CF5D0100C04F44 /* DatadogCrashReporting.framework in ⚙️ Embed Framework Dependencies */ = {isa = PBXBuildFile; fileRef = D2CB6FD127C5348200A62B57 /* DatadogCrashReporting.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - D240686827CF642900C04F44 /* SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61776D4D273E6D9F00F93802 /* SwiftUI.swift */; }; - D240687127CF971C00C04F44 /* DatadogCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2CB6ED127C50EAE00A62B57 /* DatadogCore.framework */; }; - D240687227CF971C00C04F44 /* DatadogCrashReporting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2CB6FD127C5348200A62B57 /* DatadogCrashReporting.framework */; }; D240687B27CF982C00C04F44 /* DatadogCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61133B82242393DE00786299 /* DatadogCore.framework */; }; D240687C27CF982C00C04F44 /* DatadogCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 61133B82242393DE00786299 /* DatadogCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D240687D27CF982D00C04F44 /* DatadogCrashReporting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61B7885425C180CB002675B5 /* DatadogCrashReporting.framework */; }; @@ -1150,7 +1140,6 @@ D29A9F5929DD85BB005C54A4 /* RUMCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63A24BF1A4B008053F2 /* RUMCommand.swift */; }; D29A9F5A29DD85BB005C54A4 /* RUMScopeDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6122514727FDFF82004F5AE4 /* RUMScopeDependencies.swift */; }; D29A9F5B29DD85BB005C54A4 /* VitalMemoryReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BBBCB0265E71C600943419 /* VitalMemoryReader.swift */; }; - D29A9FFF29DD85BB005C54A4 /* TimeseriesSessionCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A2B3C4D5E6F7800000001 /* TimeseriesSessionCollector.swift */; }; D29A9F5C29DD85BB005C54A4 /* RUMSessionScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C20624C098FC00C0321C /* RUMSessionScope.swift */; }; D29A9F5D29DD85BB005C54A4 /* RUMCommandSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616CCE12250A1868009FED46 /* RUMCommandSubscriber.swift */; }; D29A9F5E29DD85BB005C54A4 /* UIKitRUMViewsPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F3CDA62512144600C816E5 /* UIKitRUMViewsPredicate.swift */; }; @@ -1897,6 +1886,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A0A0A0A0A0A0A0A0A0A00003 /* TimeseriesSessionCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollector.swift; sourceTree = ""; }; + A0A0A0A0A0A0A0A0A0A00005 /* DeltaEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoder.swift; sourceTree = ""; }; + A0A0A0A0A0A0A0A0A0A00007 /* TimeseriesSessionCollectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollectorTests.swift; sourceTree = ""; }; + A0A0A0A0A0A0A0A0A0A00009 /* DeltaEncoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoderTests.swift; sourceTree = ""; }; 048E335C84F4B41E3ACA3B91 /* HeatmapIdentifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeatmapIdentifierTests.swift; sourceTree = ""; }; 0904F9F32EE1DA6800ED9A22 /* UIKitExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitExtensionsTests.swift; sourceTree = ""; }; 093AC3282F56F8AA00267CE1 /* ActiveSpanProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSpanProvider.swift; sourceTree = ""; }; @@ -1960,6 +1953,7 @@ 1166C59D2F76D839008E34BC /* TTIDMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTIDMessage.swift; sourceTree = ""; }; 1166C5A02F76EBC1008E34BC /* ProfilingOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilingOptions.swift; sourceTree = ""; }; 1166C5A32F76EC05008E34BC /* OperationOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationOptions.swift; sourceTree = ""; }; + 1166C5A92F76ED6F008E34BC /* ProfilingOptions+objc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfilingOptions+objc.swift"; sourceTree = ""; }; 116F84052CFDD06700705755 /* SampleRateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleRateTests.swift; sourceTree = ""; }; 117ADDD82EAA8A90008BD9D8 /* StartupTypeHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupTypeHandlerTests.swift; sourceTree = ""; }; 118246772D90416A00E3D16F /* DirectoriesStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoriesStub.swift; sourceTree = ""; }; @@ -2891,12 +2885,6 @@ AA0001012A000007000A0001 /* UIScrollViewSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScrollViewSwizzlerTests.swift; sourceTree = ""; }; AB7890CD1234EF567890ABCD /* CALayerSnapshotOcclusionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CALayerSnapshotOcclusionTests.swift; sourceTree = ""; }; B3BBBCB0265E71C600943419 /* VitalMemoryReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VitalMemoryReader.swift; sourceTree = ""; }; - BB1A2B3C4D5E6F7800000001 /* TimeseriesSessionCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollector.swift; sourceTree = ""; }; - BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoder.swift; sourceTree = ""; }; - 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollectorTests.swift; sourceTree = ""; }; - BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoder.swift; sourceTree = ""; }; - 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollectorTests.swift; sourceTree = ""; }; - 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoderTests.swift; sourceTree = ""; }; B3BBBCBB265E71D100943419 /* VitalMemoryReaderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VitalMemoryReaderTests.swift; sourceTree = ""; }; B3E46CAA2D91B3A400BABF66 /* NetworkContextProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkContextProvider.swift; sourceTree = ""; }; B3E46CAD2D91B3FC00BABF66 /* NetworkContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkContext.swift; sourceTree = ""; }; @@ -3469,6 +3457,24 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + A0A0A0A0A0A0A0A0A0A00001 /* Timeseries */ = { + isa = PBXGroup; + children = ( + A0A0A0A0A0A0A0A0A0A00003 /* TimeseriesSessionCollector.swift */, + A0A0A0A0A0A0A0A0A0A00005 /* DeltaEncoder.swift */, + ); + path = Timeseries; + sourceTree = ""; + }; + A0A0A0A0A0A0A0A0A0A00002 /* Timeseries */ = { + isa = PBXGroup; + children = ( + A0A0A0A0A0A0A0A0A0A00007 /* TimeseriesSessionCollectorTests.swift */, + A0A0A0A0A0A0A0A0A0A00009 /* DeltaEncoderTests.swift */, + ); + path = Timeseries; + sourceTree = ""; + }; 095AE9512F7D30BD00C332AE /* Sampling */ = { isa = PBXGroup; children = ( @@ -6198,24 +6204,6 @@ path = RUMVitals; sourceTree = ""; }; - BB1A2B3C4D5E6F7800000002 /* Timeseries */ = { - isa = PBXGroup; - children = ( - BB1A2B3C4D5E6F7800000003 /* DeltaEncoder.swift */, - BB1A2B3C4D5E6F7800000001 /* TimeseriesSessionCollector.swift */, - ); - path = Timeseries; - sourceTree = ""; - }; - BD113E89E74339BD1ADB0EC3 /* Timeseries */ = { - isa = PBXGroup; - children = ( - 7F8C00B87F71287FB535342D /* DeltaEncoderTests.swift */, - 7F8C00B87F71287FB535342C /* TimeseriesSessionCollectorTests.swift */, - ); - path = Timeseries; - sourceTree = ""; - }; B3FC3C1226526F4100DEED9E /* RUMVitals */ = { isa = PBXGroup; children = ( @@ -6786,11 +6774,11 @@ 61C3E63124BF143C008053F2 /* RUMMonitor */, B3FC3C0426526EE900DEED9E /* RUMVitals */, 613E81EE25A73FB90084B751 /* Scrubbing */, - BB1A2B3C4D5E6F7800000002 /* Timeseries */, 6174D60E2BFDEA1F00EC7469 /* SDKMetrics */, D29A9F8B29DD860A005C54A4 /* Utils */, 618DCFD524C7264100589570 /* UUIDs */, EEC7BD4A3480C5A3191E7A22 /* Heatmaps */, + A0A0A0A0A0A0A0A0A0A00001 /* Timeseries */, ); name = DatadogRUM; path = ../DatadogRUM/Sources; @@ -6816,9 +6804,9 @@ 617B953B24BF4D7300E6F443 /* RUMMonitor */, 613E81F525A743470084B751 /* Scrubbing */, 6174D6182BFE447600EC7469 /* SDKMetrics */, - BD113E89E74339BD1ADB0EC3 /* Timeseries */, 61411B0E24EC15940012EAB2 /* Utils */, 67B718E85203992292E96407 /* Heatmaps */, + A0A0A0A0A0A0A0A0A0A00002 /* Timeseries */, ); name = DatadogRUMTests; path = ../DatadogRUM/Tests; @@ -9313,6 +9301,8 @@ buildActionMask = 2147483647; files = ( 6167E6D32B7F8B3300C3CA2D /* AppHangsMonitor.swift in Sources */, + A0A0A0A0A0A0A0A0A0A00004 /* TimeseriesSessionCollector.swift in Sources */, + A0A0A0A0A0A0A0A0A0A00006 /* DeltaEncoder.swift in Sources */, 615E2B8E2D39444300D85243 /* ViewEndedController.swift in Sources */, D29A9F8029DD85BB005C54A4 /* UIViewControllerHandler.swift in Sources */, D29A9F5929DD85BB005C54A4 /* RUMCommand.swift in Sources */, @@ -9320,8 +9310,6 @@ D29A9F7F29DD85BB005C54A4 /* RUMEventSanitizer.swift in Sources */, D29A9F5A29DD85BB005C54A4 /* RUMScopeDependencies.swift in Sources */, D29A9F5B29DD85BB005C54A4 /* VitalMemoryReader.swift in Sources */, - BB1A2B3C4D5E6F7800000005 /* DeltaEncoder.swift in Sources */, - D29A9FFF29DD85BB005C54A4 /* TimeseriesSessionCollector.swift in Sources */, 5B1D02862E8EB78800AB2391 /* FlagEvaluationReceiver.swift in Sources */, 962900242D8351AB008DFE39 /* TopLevelReflector.swift in Sources */, 6194B9332BB451DB00179430 /* FatalAppHangsHandler.swift in Sources */, @@ -9449,9 +9437,9 @@ buildActionMask = 2147483647; files = ( 6188697C2A4376F700E8996B /* RUMConfigurationTests.swift in Sources */, + A0A0A0A0A0A0A0A0A0A00008 /* TimeseriesSessionCollectorTests.swift in Sources */, + A0A0A0A0A0A0A0A0A0A0000A /* DeltaEncoderTests.swift in Sources */, 61DCC8472C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */, - 7F8C00B87F71287FB535342F /* DeltaEncoderTests.swift in Sources */, - 7F8C00B87F71287FB535342B /* TimeseriesSessionCollectorTests.swift in Sources */, D29A9FA629DDB483005C54A4 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, 61C4534A2C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift in Sources */, D29A9FBD29DDB483005C54A4 /* RUMSessionScopeTests.swift in Sources */, From d47cad907fab8aec4cf80d5d387983c02062c6d3 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 9 Jul 2026 17:27:28 +0200 Subject: [PATCH 068/102] Update timeseries models to object-v2 schema, remove delta compression --- Datadog/Datadog.xcodeproj/project.pbxproj | 8 - .../DataModels/RUMDataModels+objc.swift | 4 +- DatadogRUM/Sources/RUMConfiguration.swift | 8 +- .../Sources/Timeseries/DeltaEncoder.swift | 111 --------- .../TimeseriesSessionCollector.swift | 107 +++------ .../Tests/Timeseries/DeltaEncoderTests.swift | 113 ---------- .../TimeseriesSessionCollectorTests.swift | 211 ++---------------- .../Core/TimeseriesEventBuilder.swift | 12 +- .../DataProvider/CSVDataProvider.swift | 3 +- .../Models/TimeseriesEvent.swift | 21 +- .../DatadogTimeseriesRunner/main.swift | 2 +- .../CSVDataProviderTests.swift | 3 +- .../Filters/FilterComparisonTests.swift | 16 +- .../Fixtures/expected_cpu_batch1.json | 2 +- .../Fixtures/expected_cpu_batch2.json | 2 +- .../Fixtures/expected_memory_batch1.json | 2 +- .../Fixtures/expected_memory_batch2.json | 2 +- .../SkipSampleTests.swift | 16 +- .../TimeseriesEncoderTests.swift | 10 +- .../TimeseriesEventBuilderTests.swift | 8 +- .../TimeseriesEventModelTests.swift | 27 ++- .../TimeseriesPipelineTests.swift | 3 +- 22 files changed, 130 insertions(+), 561 deletions(-) delete mode 100644 DatadogRUM/Sources/Timeseries/DeltaEncoder.swift delete mode 100644 DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 4eb8ce21d8..bf5649a3e1 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -8,9 +8,7 @@ /* Begin PBXBuildFile section */ A0A0A0A0A0A0A0A0A0A00004 /* TimeseriesSessionCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0A0A0A0A0A0A0A0A0A00003 /* TimeseriesSessionCollector.swift */; }; - A0A0A0A0A0A0A0A0A0A00006 /* DeltaEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0A0A0A0A0A0A0A0A0A00005 /* DeltaEncoder.swift */; }; A0A0A0A0A0A0A0A0A0A00008 /* TimeseriesSessionCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0A0A0A0A0A0A0A0A0A00007 /* TimeseriesSessionCollectorTests.swift */; }; - A0A0A0A0A0A0A0A0A0A0000A /* DeltaEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0A0A0A0A0A0A0A0A0A00009 /* DeltaEncoderTests.swift */; }; 05B9B20299CF44BB97F4A7C8 /* HeatmapIdentifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048E335C84F4B41E3ACA3B91 /* HeatmapIdentifierTests.swift */; }; 0904F9F42EE1DA6800ED9A22 /* UIKitExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0904F9F32EE1DA6800ED9A22 /* UIKitExtensionsTests.swift */; }; 0904F9F62EE1DA6800ED9A22 /* UIKitExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0904F9F32EE1DA6800ED9A22 /* UIKitExtensionsTests.swift */; }; @@ -1887,9 +1885,7 @@ /* Begin PBXFileReference section */ A0A0A0A0A0A0A0A0A0A00003 /* TimeseriesSessionCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollector.swift; sourceTree = ""; }; - A0A0A0A0A0A0A0A0A0A00005 /* DeltaEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoder.swift; sourceTree = ""; }; A0A0A0A0A0A0A0A0A0A00007 /* TimeseriesSessionCollectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeseriesSessionCollectorTests.swift; sourceTree = ""; }; - A0A0A0A0A0A0A0A0A0A00009 /* DeltaEncoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaEncoderTests.swift; sourceTree = ""; }; 048E335C84F4B41E3ACA3B91 /* HeatmapIdentifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeatmapIdentifierTests.swift; sourceTree = ""; }; 0904F9F32EE1DA6800ED9A22 /* UIKitExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitExtensionsTests.swift; sourceTree = ""; }; 093AC3282F56F8AA00267CE1 /* ActiveSpanProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSpanProvider.swift; sourceTree = ""; }; @@ -3461,7 +3457,6 @@ isa = PBXGroup; children = ( A0A0A0A0A0A0A0A0A0A00003 /* TimeseriesSessionCollector.swift */, - A0A0A0A0A0A0A0A0A0A00005 /* DeltaEncoder.swift */, ); path = Timeseries; sourceTree = ""; @@ -3470,7 +3465,6 @@ isa = PBXGroup; children = ( A0A0A0A0A0A0A0A0A0A00007 /* TimeseriesSessionCollectorTests.swift */, - A0A0A0A0A0A0A0A0A0A00009 /* DeltaEncoderTests.swift */, ); path = Timeseries; sourceTree = ""; @@ -9302,7 +9296,6 @@ files = ( 6167E6D32B7F8B3300C3CA2D /* AppHangsMonitor.swift in Sources */, A0A0A0A0A0A0A0A0A0A00004 /* TimeseriesSessionCollector.swift in Sources */, - A0A0A0A0A0A0A0A0A0A00006 /* DeltaEncoder.swift in Sources */, 615E2B8E2D39444300D85243 /* ViewEndedController.swift in Sources */, D29A9F8029DD85BB005C54A4 /* UIViewControllerHandler.swift in Sources */, D29A9F5929DD85BB005C54A4 /* RUMCommand.swift in Sources */, @@ -9438,7 +9431,6 @@ files = ( 6188697C2A4376F700E8996B /* RUMConfigurationTests.swift in Sources */, A0A0A0A0A0A0A0A0A0A00008 /* TimeseriesSessionCollectorTests.swift in Sources */, - A0A0A0A0A0A0A0A0A0A0000A /* DeltaEncoderTests.swift in Sources */, 61DCC8472C05CD0000CB59E5 /* SessionEndedMetricControllerTests.swift in Sources */, D29A9FA629DDB483005C54A4 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, 61C4534A2C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift in Sources */, diff --git a/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift b/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift index a2aa80df61..efc0f5ffb2 100644 --- a/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift +++ b/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift @@ -7048,7 +7048,7 @@ public class objc_RUMTimeseriesCpuEventTimeseries: NSObject { public class objc_RUMTimeseriesCpuEventTimeseriesData: NSObject { internal let root: objc_RUMTimeseriesCpuEvent - internal init(root: objc_RUMTimeseriesCpuEventTimeseriesData) { + internal init(root: objc_RUMTimeseriesCpuEvent) { self.root = root } @@ -7969,7 +7969,7 @@ public class objc_RUMTimeseriesMemoryEventTimeseries: NSObject { public class objc_RUMTimeseriesMemoryEventTimeseriesData: NSObject { internal let root: objc_RUMTimeseriesMemoryEvent - internal init(root: objc_RUMTimeseriesMemoryEventTimeseriesData) { + internal init(root: objc_RUMTimeseriesMemoryEvent) { self.root = root } diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index 14c3fec8e3..bbe29d6db7 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -338,7 +338,7 @@ extension RUM { /// The number of samples collected before a timeseries batch is flushed. /// - /// Default: `30`. + /// Default: `120`. public var timeseriesBatchSize: Int /// Feature flags to preview features in RUM. @@ -568,7 +568,7 @@ extension RUM.Configuration { /// - telemetrySampleRate: The sampling rate for SDK internal telemetry utilized by Datadog. Must be a value between `0` and `100`. Default: `20`. /// - collectAccessibility: Determines whether accessibility data should be collected and included in RUM view events. Default: `false`. /// - enableTimeseries: Enables collection of memory and CPU timeseries events. Default: `false`. - /// - timeseriesBatchSize: The number of samples collected before a timeseries batch is flushed. Default: `30`. + /// - timeseriesBatchSize: The number of samples collected before a timeseries batch is flushed. Default: `120`. /// - featureFlags: Experimental feature flags. /// /// - Note: On watchOS, automatic UIKit and SwiftUI view/action tracking is unavailable. The predicate parameters will be ignored. @@ -605,7 +605,7 @@ extension RUM.Configuration { telemetrySampleRate: SampleRate = 20, collectAccessibility: Bool = false, enableTimeseries: Bool = false, - timeseriesBatchSize: Int = 30, + timeseriesBatchSize: Int = 120, featureFlags: FeatureFlags = .defaults ) { self.applicationID = applicationID @@ -664,7 +664,7 @@ extension RUM.Configuration { telemetrySampleRate: SampleRate = 20, collectAccessibility: Bool = false, enableTimeseries: Bool = false, - timeseriesBatchSize: Int = 30, + timeseriesBatchSize: Int = 120, featureFlags: FeatureFlags = .defaults ) { self.applicationID = applicationID diff --git a/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift b/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift deleted file mode 100644 index c388800c99..0000000000 --- a/DatadogRUM/Sources/Timeseries/DeltaEncoder.swift +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ - -import Foundation -import DatadogInternal - -/// Encodes timeseries batches using delta compression. -/// -/// The first value in each array is absolute; subsequent values are deltas from the previous. -/// All floating-point fields are scaled by `10^precision` and stored as `Int64`. -internal enum DeltaEncoder { - private static let precision = 4 - private static let scale = 10_000.0 - - /// Encodes a batch of memory samples using delta compression. - /// - /// Returns `nil` if the batch contains one or fewer samples. - /// - /// Output format: - /// ``` - /// { - /// "precision": 4, - /// "ts": [absoluteNs, delta1, delta2, ...], - /// "memory_footprint": [scaledInt64, delta1, delta2, ...], - /// "memory_percent": [scaledInt64, delta1, ...] - /// } - /// ``` - static func encodeMemory(_ batch: [RUMTimeseriesMemoryEvent.Timeseries.Data]) -> [String: Any]? { - guard batch.count > 1 else { - return nil - } - - var ts: [Int64] = [] - var memoryFootprint: [Int64] = [] - var memoryPercent: [Int64] = [] - - for (index, sample) in batch.enumerated() { - if index == 0 { - ts.append(sample.timestamp) - memoryFootprint.append(Int64.ddWithNoOverflow(sample.dataPoint.memoryFootprint * scale)) - memoryPercent.append(Int64.ddWithNoOverflow(sample.dataPoint.memoryPercent * scale)) - } else { - let prev = batch[index - 1] - let (tsDelta, _) = sample.timestamp.subtractingReportingOverflow(prev.timestamp) - ts.append(tsDelta) - let curMax = Int64.ddWithNoOverflow(sample.dataPoint.memoryFootprint * scale) - let prevMax = Int64.ddWithNoOverflow(prev.dataPoint.memoryFootprint * scale) - let (maxDelta, _) = curMax.subtractingReportingOverflow(prevMax) - memoryFootprint.append(maxDelta) - let curPct = Int64.ddWithNoOverflow(sample.dataPoint.memoryPercent * scale) - let prevPct = Int64.ddWithNoOverflow(prev.dataPoint.memoryPercent * scale) - let (pctDelta, _) = curPct.subtractingReportingOverflow(prevPct) - memoryPercent.append(pctDelta) - } - } - - return [ - "precision": precision, - "resolution": "ns", - "ts": ts, - "memory_footprint": memoryFootprint, - "memory_percent": memoryPercent - ] - } - - /// Encodes a batch of CPU samples using delta compression. - /// - /// Returns `nil` if the batch contains one or fewer samples. - /// - /// Output format: - /// ``` - /// { - /// "precision": 4, - /// "ts": [absoluteNs, delta1, delta2, ...], - /// "cpu_usage": [scaledInt64, delta1, delta2, ...] - /// } - /// ``` - static func encodeCPU(_ batch: [RUMTimeseriesCpuEvent.Timeseries.Data]) -> [String: Any]? { - guard batch.count > 1 else { - return nil - } - - var ts: [Int64] = [] - var cpuUsage: [Int64] = [] - - for (index, sample) in batch.enumerated() { - if index == 0 { - ts.append(sample.timestamp) - cpuUsage.append(Int64.ddWithNoOverflow(sample.dataPoint.cpuUsage * scale)) - } else { - let prev = batch[index - 1] - let (tsDelta, _) = sample.timestamp.subtractingReportingOverflow(prev.timestamp) - ts.append(tsDelta) - let cur = Int64.ddWithNoOverflow(sample.dataPoint.cpuUsage * scale) - let prv = Int64.ddWithNoOverflow(prev.dataPoint.cpuUsage * scale) - let (delta, _) = cur.subtractingReportingOverflow(prv) - cpuUsage.append(delta) - } - } - - return [ - "precision": precision, - "resolution": "ns", - "ts": ts, - "value": cpuUsage - ] - } -} diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 9ed5372cb4..5743ff2505 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -17,25 +17,33 @@ internal protocol TimeseriesCollecting: AnyObject { /// Collects memory and CPU samples at configurable intervals (default: 1 s) during a RUM session and flushes them /// as `RUMTimeseriesMemoryEvent` / `RUMTimeseriesCpuEvent` batches via the RUM feature scope. -/// -/// At session start a coin is flipped: 50% of sessions send full-array `object` schema events, -/// 50% send delta-compressed events (`delta-object` for memory, `delta-scalar` for CPU). internal class TimeseriesSessionCollector: TimeseriesCollecting { + /// A single memory sample: physical memory footprint in kilobytes and its percentage of total device RAM. + private struct MemorySample { + let timestamp: Int64 + let footprintKB: Double + let percent: Double + } + + /// A single CPU sample: usage as a percentage (0.0 to 100.0). + private struct CPUSample { + let timestamp: Int64 + let usage: Double + } + private let memoryReader: SamplingBasedVitalReader private let cpuUsageProvider: () -> Double? - private let compressionSampler: () -> Bool private let batchSize: Int private let samplingInterval: TimeInterval private let collectInBackground: Bool private let featureScope: FeatureScope private let totalRAM: Double - private var memoryBuffer: [RUMTimeseriesMemoryEvent.Timeseries.Data] = [] - private var cpuBuffer: [RUMTimeseriesCpuEvent.Timeseries.Data] = [] + private var memoryBuffer: [MemorySample] = [] + private var cpuBuffer: [CPUSample] = [] private var sessionID: String = "" private var applicationID: String = "" private var sessionType: RUMSessionType = .user - private var useDeltaCompression: Bool = false private var timer: DispatchSourceTimer? private var isPaused: Bool = false @@ -45,11 +53,10 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { init( memoryReader: SamplingBasedVitalReader, featureScope: FeatureScope, - batchSize: Int = 30, + batchSize: Int = 120, samplingInterval: TimeInterval = 1, collectInBackground: Bool = false, cpuUsageProvider: (() -> Double?)? = nil, - compressionSampler: @escaping () -> Bool = { Bool.random() }, totalRAM: Double = Double(ProcessInfo.processInfo.physicalMemory) ) { self.memoryReader = memoryReader @@ -59,7 +66,6 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.featureScope = featureScope self.totalRAM = totalRAM self.cpuUsageProvider = cpuUsageProvider ?? { TimeseriesSessionCollector.processCPU() } - self.compressionSampler = compressionSampler } /// Per-process CPU as a percentage (0–100+), summed across all app threads. @@ -117,7 +123,6 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.sessionID = sessionID self.applicationID = applicationID self.sessionType = sessionType - self.useDeltaCompression = self.compressionSampler() self.memoryBuffer = [] self.cpuBuffer = [] self.isPaused = false @@ -184,23 +189,16 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let now = Int64.ddWithNoOverflow(Date().timeIntervalSince1970 * 1_000_000_000) if let bytes = memoryReader.readVitalData() { + let footprintKB = bytes / 1_024 let memoryPercent = totalRAM > 0 ? bytes / totalRAM * 100 : 0 - let dataPoint = RUMTimeseriesMemoryEvent.Timeseries.Data( - dataPoint: .init(memoryFootprint: bytes, memoryPercent: memoryPercent), - timestamp: now - ) - memoryBuffer.append(dataPoint) + memoryBuffer.append(MemorySample(timestamp: now, footprintKB: footprintKB, percent: memoryPercent)) if memoryBuffer.count >= batchSize { flushMemory() } } if let cpuUsage = cpuUsageProvider() { - let dataPoint = RUMTimeseriesCpuEvent.Timeseries.Data( - dataPoint: .init(cpuUsage: cpuUsage), - timestamp: now - ) - cpuBuffer.append(dataPoint) + cpuBuffer.append(CPUSample(timestamp: now, usage: cpuUsage)) if cpuBuffer.count >= batchSize { flushCPU() } @@ -219,19 +217,13 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let start = batch[0].timestamp let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() - let useDelta = self.useDeltaCompression featureScope.eventWriteContext { context, writer in let offsetNs = context.serverTimeOffset.dd.toInt64Nanoseconds - let adjustedBatch = batch.map { sample in - RUMTimeseriesMemoryEvent.Timeseries.Data( - dataPoint: sample.dataPoint, - timestamp: sample.timestamp + offsetNs - ) - } + let timestamps = batch.map { $0.timestamp + offsetNs } let adjustedStart = start + offsetNs let adjustedEnd = end + offsetNs - let objectEvent = RUMTimeseriesMemoryEvent( + let event = RUMTimeseriesMemoryEvent( dd: .init(), application: .init(id: applicationID), date: (Double(start) / 1_000_000_000 + context.serverTimeOffset).dd.toInt64Milliseconds, @@ -239,29 +231,20 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { session: .init(id: sessionID, type: sessionType), source: .init(rawValue: context.source) ?? .ios, timeseries: .init( - data: adjustedBatch, + data: .init( + timestamps: timestamps, + values: .init( + memoryFootprint: batch.map { $0.footprintKB }, + memoryPercent: batch.map { $0.percent } + ) + ), end: adjustedEnd, id: eventID, - name: "memory", - schema: .object, start: adjustedStart ), version: context.version ) - - if useDelta { - if let deltaData = DeltaEncoder.encodeMemory(adjustedBatch), - let eventData = try? JSONEncoder().encode(objectEvent), - var dict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any], - var ts = dict["timeseries"] as? [String: Any] { - ts["schema"] = "delta-object" - ts["data"] = deltaData - dict["timeseries"] = ts - writer.write(value: AnyEncodable(dict)) - } - } else { - writer.write(value: objectEvent) - } + writer.write(value: event) } } @@ -277,19 +260,13 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let start = batch[0].timestamp let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() - let useDelta = self.useDeltaCompression featureScope.eventWriteContext { context, writer in let offsetNs = context.serverTimeOffset.dd.toInt64Nanoseconds - let adjustedBatch = batch.map { sample in - RUMTimeseriesCpuEvent.Timeseries.Data( - dataPoint: sample.dataPoint, - timestamp: sample.timestamp + offsetNs - ) - } + let timestamps = batch.map { $0.timestamp + offsetNs } let adjustedStart = start + offsetNs let adjustedEnd = end + offsetNs - let objectEvent = RUMTimeseriesCpuEvent( + let event = RUMTimeseriesCpuEvent( dd: .init(), application: .init(id: applicationID), date: (Double(start) / 1_000_000_000 + context.serverTimeOffset).dd.toInt64Milliseconds, @@ -297,29 +274,17 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { session: .init(id: sessionID, type: sessionType), source: .init(rawValue: context.source) ?? .ios, timeseries: .init( - data: adjustedBatch, + data: .init( + timestamps: timestamps, + values: .init(cpuUsage: batch.map { $0.usage }) + ), end: adjustedEnd, id: eventID, - name: "cpu", - schema: .object, start: adjustedStart ), version: context.version ) - - if useDelta { - if let deltaData = DeltaEncoder.encodeCPU(adjustedBatch), - let eventData = try? JSONEncoder().encode(objectEvent), - var dict = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any], - var ts = dict["timeseries"] as? [String: Any] { - ts["schema"] = "delta-scalar" - ts["data"] = deltaData - dict["timeseries"] = ts - writer.write(value: AnyEncodable(dict)) - } - } else { - writer.write(value: objectEvent) - } + writer.write(value: event) } } } diff --git a/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift b/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift deleted file mode 100644 index dc88111263..0000000000 --- a/DatadogRUM/Tests/Timeseries/DeltaEncoderTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ - -import XCTest -import TestUtilities -import DatadogInternal -@testable import DatadogRUM - -class DeltaEncoderTests: XCTestCase { - // MARK: - Memory encoding - - func testEncodeMemory_returnsNilForEmptyBatch() { - XCTAssertNil(DeltaEncoder.encodeMemory([])) - } - - func testEncodeMemory_returnsNilForSingleSample() { - let sample = RUMTimeseriesMemoryEvent.Timeseries.Data( - dataPoint: .init(memoryFootprint: 100.0, memoryPercent: 10.0), - timestamp: 1_000_000_000 - ) - XCTAssertNil(DeltaEncoder.encodeMemory([sample])) - } - - func testEncodeMemory_correctDeltaEncoding() throws { - // Given - let samples: [RUMTimeseriesMemoryEvent.Timeseries.Data] = [ - .init(dataPoint: .init(memoryFootprint: 100.0, memoryPercent: 10.0), timestamp: 1_000_000_000), - .init(dataPoint: .init(memoryFootprint: 200.5, memoryPercent: 20.0), timestamp: 2_000_000_000), - .init(dataPoint: .init(memoryFootprint: 200.5, memoryPercent: 20.5), timestamp: 3_000_000_000) - ] - - // When - let result = try XCTUnwrap(DeltaEncoder.encodeMemory(samples)) - - // Then - XCTAssertEqual(result["precision"] as? Int, 4) - XCTAssertEqual(result["resolution"] as? String, "ns") - - let ts = try XCTUnwrap(result["ts"] as? [Int64]) - XCTAssertEqual(ts, [1_000_000_000, 1_000_000_000, 1_000_000_000]) - - // memory_footprint: 100*10000=1_000_000, (200.5-100)*10000=1_005_000, 0 - let memoryFootprint = try XCTUnwrap(result["memory_footprint"] as? [Int64]) - XCTAssertEqual(memoryFootprint, [1_000_000, 1_005_000, 0]) - - // memory_percent: 10*10000=100_000, (20-10)*10000=100_000, (20.5-20)*10000=5_000 - let memoryPercent = try XCTUnwrap(result["memory_percent"] as? [Int64]) - XCTAssertEqual(memoryPercent, [100_000, 100_000, 5_000]) - } - - func testEncodeMemory_doesNotCrashOnOverflowBoundaryValues() throws { - // Values scaled to near Int64.max / Int64.min to exercise subtractingReportingOverflow - let hugeBytes = Double(Int64.max) / 10_000.0 - let samples: [RUMTimeseriesMemoryEvent.Timeseries.Data] = [ - .init(dataPoint: .init(memoryFootprint: hugeBytes, memoryPercent: 100.0), timestamp: Int64.max), - .init(dataPoint: .init(memoryFootprint: 0.0, memoryPercent: 0.0), timestamp: 0) - ] - // Should not crash - let result = try XCTUnwrap(DeltaEncoder.encodeMemory(samples)) - XCTAssertNotNil(result["memory_footprint"] as? [Int64]) - } - - // MARK: - CPU encoding - - func testEncodeCPU_doesNotCrashOnOverflowBoundaryValues() throws { - let hugeCPU = Double(Int64.max) / 10_000.0 - let samples: [RUMTimeseriesCpuEvent.Timeseries.Data] = [ - .init(dataPoint: .init(cpuUsage: hugeCPU), timestamp: Int64.max), - .init(dataPoint: .init(cpuUsage: 0.0), timestamp: 0) - ] - // Should not crash - let result = try XCTUnwrap(DeltaEncoder.encodeCPU(samples)) - XCTAssertNotNil(result["value"] as? [Int64]) - } - - func testEncodeCPU_returnsNilForEmptyBatch() { - XCTAssertNil(DeltaEncoder.encodeCPU([])) - } - - func testEncodeCPU_returnsNilForSingleSample() { - let sample = RUMTimeseriesCpuEvent.Timeseries.Data( - dataPoint: .init(cpuUsage: 42.5), - timestamp: 1_000_000_000 - ) - XCTAssertNil(DeltaEncoder.encodeCPU([sample])) - } - - func testEncodeCPU_correctDeltaEncoding() throws { - // Given - let samples: [RUMTimeseriesCpuEvent.Timeseries.Data] = [ - .init(dataPoint: .init(cpuUsage: 42.5), timestamp: 1_000_000_000), - .init(dataPoint: .init(cpuUsage: 43.0), timestamp: 2_000_000_000), - .init(dataPoint: .init(cpuUsage: 42.0), timestamp: 3_000_000_000) - ] - - // When - let result = try XCTUnwrap(DeltaEncoder.encodeCPU(samples)) - - // Then - XCTAssertEqual(result["precision"] as? Int, 4) - XCTAssertEqual(result["resolution"] as? String, "ns") - - let ts = try XCTUnwrap(result["ts"] as? [Int64]) - XCTAssertEqual(ts, [1_000_000_000, 1_000_000_000, 1_000_000_000]) - - // value: 42.5*10000=425_000, (43.0-42.5)*10000=5_000, (42.0-43.0)*10000=-10_000 - let cpuUsage = try XCTUnwrap(result["value"] as? [Int64]) - XCTAssertEqual(cpuUsage, [425_000, 5_000, -10_000]) - } -} diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 478abcc389..dc3d2a4377 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -19,14 +19,13 @@ class TimeseriesSessionCollectorTests: XCTestCase { func testWhenBatchSizeIsReached_itWritesMemoryEvent() { // Given - memoryReader.vitalData = 1_000_000 + memoryReader.vitalData = 1_024_000 let collector = TimeseriesSessionCollector( memoryReader: memoryReader, featureScope: featureScope, batchSize: 2, samplingInterval: 0.05, cpuUsageProvider: { nil }, - compressionSampler: { false }, totalRAM: 4_000_000_000 ) @@ -49,9 +48,10 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(event.session.type, .user) XCTAssertEqual(event.source, .ios) XCTAssertEqual(event.timeseries.name, "memory") - XCTAssertEqual(event.timeseries.data.count, 2) - XCTAssertEqual(event.timeseries.data[0].dataPoint.memoryFootprint, 1_000_000) - XCTAssertEqual(event.timeseries.data[0].dataPoint.memoryPercent, 0.025, accuracy: 0.001) + XCTAssertEqual(event.timeseries.schema, "object-v2") + XCTAssertEqual(event.timeseries.data.timestamps.count, 2) + XCTAssertEqual(event.timeseries.data.values.memoryFootprint[0], 1_000) + XCTAssertEqual(event.timeseries.data.values.memoryPercent[0], 0.0256, accuracy: 0.0001) } func testWhenBatchSizeIsReached_itWritesCpuEvent() { @@ -62,8 +62,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 2, samplingInterval: 0.05, - cpuUsageProvider: { 42.5 }, - compressionSampler: { false } + cpuUsageProvider: { 42.5 } ) // When @@ -85,20 +84,20 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(event.session.type, .user) XCTAssertEqual(event.source, .ios) XCTAssertEqual(event.timeseries.name, "cpu") - XCTAssertEqual(event.timeseries.data.count, 2) - XCTAssertEqual(event.timeseries.data[0].dataPoint.cpuUsage, 42.5) + XCTAssertEqual(event.timeseries.schema, "object-v2") + XCTAssertEqual(event.timeseries.data.timestamps.count, 2) + XCTAssertEqual(event.timeseries.data.values.cpuUsage[0], 42.5) } func testWhenBothReadersProvideData_itWritesBothMemoryAndCpuEvents() { // Given - memoryReader.vitalData = 2_000_000 + memoryReader.vitalData = 2_048_000 let collector = TimeseriesSessionCollector( memoryReader: memoryReader, featureScope: featureScope, batchSize: 2, samplingInterval: 0.05, cpuUsageProvider: { 75.0 }, - compressionSampler: { false }, totalRAM: 4_000_000_000 ) @@ -114,8 +113,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { // Then XCTAssertFalse(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, "Expected memory events") XCTAssertFalse(featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self).isEmpty, "Expected CPU events") - XCTAssertEqual(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self)[0].timeseries.data[0].dataPoint.memoryFootprint, 2_000_000) - XCTAssertEqual(featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self)[0].timeseries.data[0].dataPoint.cpuUsage, 75.0) + XCTAssertEqual(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self)[0].timeseries.data.values.memoryFootprint[0], 2_000) + XCTAssertEqual(featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self)[0].timeseries.data.values.cpuUsage[0], 75.0) } func testWhenServerTimeOffsetIsNonZero_itAdjustsEventDate() { @@ -128,7 +127,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { batchSize: 2, samplingInterval: 0.05, cpuUsageProvider: { nil }, - compressionSampler: { false }, totalRAM: 4_000_000_000 ) @@ -148,7 +146,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { // timeseries.start is offset-adjusted ns; date is offset-adjusted ms let expectedDateMs = event.timeseries.start / 1_000_000 XCTAssertEqual(event.date, expectedDateMs, accuracy: 100) - XCTAssertEqual(event.timeseries.data[0].timestamp, event.timeseries.start) + XCTAssertEqual(event.timeseries.data.timestamps[0], event.timeseries.start) } func testWhenContextSourceIsReactNative_itUsesContextSource() { @@ -161,7 +159,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { batchSize: 2, samplingInterval: 0.05, cpuUsageProvider: { nil }, - compressionSampler: { false }, totalRAM: 4_000_000_000 ) @@ -189,7 +186,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { batchSize: 100, // won't auto-flush samplingInterval: 0.05, cpuUsageProvider: { nil }, - compressionSampler: { false }, totalRAM: 4_000_000_000 ) @@ -223,8 +219,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 100, // large batch — won't auto-flush samplingInterval: 0.05, - cpuUsageProvider: { nil }, - compressionSampler: { false } + cpuUsageProvider: { nil } ) // When — let a few samples accumulate then stop @@ -256,8 +251,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 100, samplingInterval: 0.05, - cpuUsageProvider: { 10.0 }, - compressionSampler: { false } + cpuUsageProvider: { 10.0 } ) let expectation = self.expectation(description: "samples collected") @@ -314,8 +308,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 100, samplingInterval: 0.05, - cpuUsageProvider: { nil }, - compressionSampler: { false } + cpuUsageProvider: { nil } ) // First session @@ -344,164 +337,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(lastEvent.session.id, "session-2") } - // MARK: - Schema coin flip - - func testWhenDeltaCompressionSampled_itWritesDeltaEventForMemory() throws { - // Given - memoryReader.vitalData = 1_000_000 - let collector = TimeseriesSessionCollector( - memoryReader: memoryReader, - featureScope: featureScope, - batchSize: 3, - samplingInterval: 0.05, - cpuUsageProvider: { nil }, - compressionSampler: { true } - ) - - let expectation = self.expectation(description: "memory batch written") - expectation.assertForOverFulfill = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } - - collector.start(sessionID: "session-delta", applicationID: "app-delta", sessionType: .user) - waitForExpectations(timeout: 2) - collector.stop() - - // Then — AnyEncodable delta-object event written, no typed object event - let typedEvents = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) - XCTAssertTrue(typedEvents.isEmpty, "Object-schema typed event must not be written when delta is sampled") - - let anyEncodableEvents = featureScope.eventsWritten.compactMap { $0 as? AnyEncodable } - XCTAssertFalse(anyEncodableEvents.isEmpty, "Expected delta-schema AnyEncodable event") - - let jsonData = try JSONEncoder().encode(anyEncodableEvents[0]) - let dict = try XCTUnwrap(try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) - let tsDict = try XCTUnwrap(dict["timeseries"] as? [String: Any]) - XCTAssertEqual(tsDict["schema"] as? String, "delta-object") - let dataDict = try XCTUnwrap(tsDict["data"] as? [String: Any]) - XCTAssertEqual(dataDict["resolution"] as? String, "ns") - XCTAssertNotNil(dataDict["ts"]) - XCTAssertNotNil(dataDict["memory_footprint"]) - XCTAssertNotNil(dataDict["memory_percent"]) - } - - func testWhenObjectSchemaSampled_itWritesObjectEventForMemory() { - // Given - memoryReader.vitalData = 1_000_000 - let collector = TimeseriesSessionCollector( - memoryReader: memoryReader, - featureScope: featureScope, - batchSize: 3, - samplingInterval: 0.05, - cpuUsageProvider: { nil }, - compressionSampler: { false } - ) - - let expectation = self.expectation(description: "memory batch written") - expectation.assertForOverFulfill = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } - - collector.start(sessionID: "session-object", applicationID: "app-object", sessionType: .user) - waitForExpectations(timeout: 2) - collector.stop() - - // Then — typed object event written, no AnyEncodable delta event - let typedEvents = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) - XCTAssertFalse(typedEvents.isEmpty, "Expected object-schema typed memory event") - XCTAssertEqual(typedEvents[0].timeseries.schema, .object) - XCTAssertTrue(featureScope.eventsWritten.compactMap { $0 as? AnyEncodable }.isEmpty) - } - - func testWhenDeltaCompressionSampled_itWritesDeltaEventForCPU() throws { - // Given - memoryReader.vitalData = nil - let collector = TimeseriesSessionCollector( - memoryReader: memoryReader, - featureScope: featureScope, - batchSize: 3, - samplingInterval: 0.05, - cpuUsageProvider: { 50.0 }, - compressionSampler: { true } - ) - - let expectation = self.expectation(description: "cpu batch written") - expectation.assertForOverFulfill = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } - - collector.start(sessionID: "session-delta-cpu", applicationID: "app-delta", sessionType: .user) - waitForExpectations(timeout: 2) - collector.stop() - - // Then — AnyEncodable delta-scalar event written, no typed object event - let typedEvents = featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self) - XCTAssertTrue(typedEvents.isEmpty, "Object-schema typed event must not be written when delta is sampled") - - let anyEncodableEvents = featureScope.eventsWritten.compactMap { $0 as? AnyEncodable } - XCTAssertFalse(anyEncodableEvents.isEmpty, "Expected delta-schema AnyEncodable event") - - let jsonData = try JSONEncoder().encode(anyEncodableEvents[0]) - let dict = try XCTUnwrap(try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) - let tsDict = try XCTUnwrap(dict["timeseries"] as? [String: Any]) - XCTAssertEqual(tsDict["schema"] as? String, "delta-scalar") - let dataDict = try XCTUnwrap(tsDict["data"] as? [String: Any]) - XCTAssertEqual(dataDict["resolution"] as? String, "ns") - XCTAssertNotNil(dataDict["ts"]) - XCTAssertNotNil(dataDict["value"]) - } - - func testWhenObjectSchemaSampled_itWritesObjectEventForCPU() { - // Given - memoryReader.vitalData = nil - let collector = TimeseriesSessionCollector( - memoryReader: memoryReader, - featureScope: featureScope, - batchSize: 3, - samplingInterval: 0.05, - cpuUsageProvider: { 50.0 }, - compressionSampler: { false } - ) - - let expectation = self.expectation(description: "cpu batch written") - expectation.assertForOverFulfill = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } - - collector.start(sessionID: "session-object-cpu", applicationID: "app-object", sessionType: .user) - waitForExpectations(timeout: 2) - collector.stop() - - // Then — typed object event written, no AnyEncodable delta event - let typedEvents = featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self) - XCTAssertFalse(typedEvents.isEmpty, "Expected object-schema typed CPU event") - XCTAssertEqual(typedEvents[0].timeseries.schema, .object) - XCTAssertTrue(featureScope.eventsWritten.compactMap { $0 as? AnyEncodable }.isEmpty) - } - - func testWhenDeltaCompressionSampledWithSingleSample_itDropsTheEvent() { - // Given — delta sampled but only 1 sample: DeltaEncoder returns nil, event is dropped - memoryReader.vitalData = 1_000_000 - let collector = TimeseriesSessionCollector( - memoryReader: memoryReader, - featureScope: featureScope, - batchSize: 100, - samplingInterval: 0.05, - cpuUsageProvider: { nil }, - compressionSampler: { true } - ) - - let expectation = self.expectation(description: "one sample collected") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { expectation.fulfill() } - collector.start(sessionID: "session-drop", applicationID: "app-drop", sessionType: .user) - waitForExpectations(timeout: 2) - - let stopExpectation = self.expectation(description: "stop completed") - collector.stop() - DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.2) { stopExpectation.fulfill() } - waitForExpectations(timeout: 2) - - // Then — event is dropped entirely, nothing written - XCTAssertTrue(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty) - XCTAssertTrue(featureScope.eventsWritten.compactMap { $0 as? AnyEncodable }.isEmpty) - } - // MARK: - Pause / resume func testWhenPaused_itStopsCollectingSamples() { @@ -512,8 +347,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 2, samplingInterval: 0.05, - cpuUsageProvider: { nil }, - compressionSampler: { false } + cpuUsageProvider: { nil } ) let samplingExpectation = self.expectation(description: "initial samples collected") @@ -549,8 +383,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 2, samplingInterval: 0.05, - cpuUsageProvider: { nil }, - compressionSampler: { false } + cpuUsageProvider: { nil } ) collector.start(sessionID: "session-resume", applicationID: "app-1", sessionType: .user) @@ -584,8 +417,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { batchSize: 2, samplingInterval: 0.05, collectInBackground: true, - cpuUsageProvider: { nil }, - compressionSampler: { false } + cpuUsageProvider: { nil } ) let startExpectation = self.expectation(description: "initial samples") @@ -642,8 +474,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 3, samplingInterval: 0.05, - cpuUsageProvider: { nil }, - compressionSampler: { false } + cpuUsageProvider: { nil } ) let expectation = self.expectation(description: "first batch written") @@ -660,7 +491,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTFail("Expected at least one memory event") return } - let timestamps = event.timeseries.data.map { $0.timestamp } + let timestamps = event.timeseries.data.timestamps XCTAssertEqual(timestamps, timestamps.sorted(), "Timestamps should be monotonically increasing") XCTAssertEqual(event.timeseries.start, timestamps.first) XCTAssertEqual(event.timeseries.end, timestamps.last) diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift index c59a966c62..cfccce8b4f 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift @@ -17,12 +17,10 @@ struct TimeseriesEventBuilder { let end = samples.last?.timestamp ?? 0 let dateMs = start / 1_000_000 - let dataPoints = samples.map { sample in - TimeseriesEvent.DataPoint( - timestamp: sample.timestamp, - dataPoint: [name.rawValue: sample.value] - ) - } + let data = TimeseriesEvent.Data( + timestamps: samples.map { $0.timestamp }, + values: [name.rawValue: samples.map { $0.value }] + ) return TimeseriesEvent( dd: TimeseriesEvent.DD(formatVersion: 2), @@ -38,7 +36,7 @@ struct TimeseriesEventBuilder { name: name, start: start, end: end, - data: dataPoints + data: data ) ) } diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift index cee7a7acf5..bf6851da5a 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift @@ -11,8 +11,7 @@ public class CSVDataProvider: DataProvider { public init(csvContent: String, metric: TimeseriesName) { var parsed: [Sample] = [] - let lines = csvContent.components(separatedBy: " -") + let lines = csvContent.components(separatedBy: "\n") for line in lines.dropFirst() { // skip header let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift index d794a862e0..53d0183c51 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift @@ -48,18 +48,23 @@ public struct TimeseriesEvent: Codable { public struct Timeseries: Codable { public let id: String public let name: TimeseriesName + public let schema: String = "object-v2" public let start: Int64 public let end: Int64 - public let data: [DataPoint] - } - - public struct DataPoint: Codable { - public let timestamp: Int64 - public let dataPoint: [String: Double] + public let data: Data enum CodingKeys: String, CodingKey { - case timestamp - case dataPoint = "data_point" + case id + case name + case schema + case start + case end + case data } } + + public struct Data: Codable { + public let timestamps: [Int64] + public let values: [String: [Double]] + } } diff --git a/DatadogTimeseries/Sources/DatadogTimeseriesRunner/main.swift b/DatadogTimeseries/Sources/DatadogTimeseriesRunner/main.swift index 28f47aa59e..3fc03a2984 100644 --- a/DatadogTimeseries/Sources/DatadogTimeseriesRunner/main.swift +++ b/DatadogTimeseries/Sources/DatadogTimeseriesRunner/main.swift @@ -59,7 +59,7 @@ struct PipelineResult { let decoder = JSONDecoder() return events.compactMap { data -> Int? in let event = try? decoder.decode(TimeseriesEvent.self, from: data) - return event?.timeseries.data.count + return event?.timeseries.data.timestamps.count }.reduce(0, +) } } diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift index 88ebdd4e7c..f6876a66e6 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift @@ -57,8 +57,7 @@ final class CSVDataProviderTests: XCTestCase { } func testReturnsNilForEmptyCSV() { - let csv = "timestamp,metric,value -" + let csv = "timestamp,metric,value\n" let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) XCTAssertNil(provider.read()) } diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift index 70577300b2..14e832a366 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift @@ -73,9 +73,9 @@ final class FilterComparisonTests: XCTestCase { let firstBatch = try XCTUnwrap(results.first) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: firstBatch) as? [String: Any]) let timeseries = try XCTUnwrap(json["timeseries"] as? [String: Any]) - let data = try XCTUnwrap(timeseries["data"] as? [[String: Any]]) - let firstPoint = try XCTUnwrap(data.first) - let firstTimestamp = try XCTUnwrap(firstPoint["timestamp"] as? Int64) + let data = try XCTUnwrap(timeseries["data"] as? [String: Any]) + let timestamps = try XCTUnwrap(data["timestamps"] as? [Int64]) + let firstTimestamp = try XCTUnwrap(timestamps.first) // First window starts at the first sample timestamp (nanoseconds) XCTAssertEqual(firstTimestamp, 1_700_000_001_000_000_000) @@ -111,8 +111,9 @@ final class FilterComparisonTests: XCTestCase { XCTAssertEqual(json["type"] as? String, "timeseries") XCTAssertNotNil(json["_dd"]) let timeseries = try XCTUnwrap(json["timeseries"] as? [String: Any]) - let points = try XCTUnwrap(timeseries["data"] as? [[String: Any]]) - XCTAssertFalse(points.isEmpty) + let data = try XCTUnwrap(timeseries["data"] as? [String: Any]) + let timestamps = try XCTUnwrap(data["timestamps"] as? [Int64]) + XCTAssertFalse(timestamps.isEmpty) } } } @@ -163,8 +164,9 @@ final class FilterComparisonTests: XCTestCase { for data in results { let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) let timeseries = try XCTUnwrap(json["timeseries"] as? [String: Any]) - let points = try XCTUnwrap(timeseries["data"] as? [[String: Any]]) - total += points.count + let data = try XCTUnwrap(timeseries["data"] as? [String: Any]) + let timestamps = try XCTUnwrap(data["timestamps"] as? [Int64]) + total += timestamps.count } return total } diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json index bb22d767eb..9263d5bef3 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json @@ -1 +1 @@ -{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000001000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point":{"cpu_usage":12.5},"timestamp":1700000001000000000},{"data_point":{"cpu_usage":14.2},"timestamp":1700000002000000000},{"data_point":{"cpu_usage":11.8},"timestamp":1700000003000000000},{"data_point":{"cpu_usage":16.3},"timestamp":1700000004000000000},{"data_point":{"cpu_usage":13.7},"timestamp":1700000005000000000}],"end":1700000005000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","start":1700000001000000000},"type":"timeseries"} +{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000001000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":{"timestamps":[1700000001000000000,1700000002000000000,1700000003000000000,1700000004000000000,1700000005000000000],"values":{"cpu_usage":[12.5,14.2,11.8,16.3,13.7]}},"end":1700000005000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","schema":"object-v2","start":1700000001000000000},"type":"timeseries"} \ No newline at end of file diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json index ea8f16e249..c581f5a2a3 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json @@ -1 +1 @@ -{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000006000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point":{"cpu_usage":18.1},"timestamp":1700000006000000000},{"data_point":{"cpu_usage":22.4},"timestamp":1700000007000000000},{"data_point":{"cpu_usage":19.6},"timestamp":1700000008000000000},{"data_point":{"cpu_usage":15.9},"timestamp":1700000009000000000},{"data_point":{"cpu_usage":13.2},"timestamp":1700000010000000000}],"end":1700000010000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","start":1700000006000000000},"type":"timeseries"} +{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000006000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":{"timestamps":[1700000006000000000,1700000007000000000,1700000008000000000,1700000009000000000,1700000010000000000],"values":{"cpu_usage":[18.1,22.4,19.6,15.9,13.2]}},"end":1700000010000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","schema":"object-v2","start":1700000006000000000},"type":"timeseries"} \ No newline at end of file diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json index 83df9545f1..44e45a56fc 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json @@ -1 +1 @@ -{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000001000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point":{"memory_usage":31233300},"timestamp":1700000001000000000},{"data_point":{"memory_usage":31245500},"timestamp":1700000002000000000},{"data_point":{"memory_usage":31300800},"timestamp":1700000003000000000},{"data_point":{"memory_usage":31289100},"timestamp":1700000004000000000},{"data_point":{"memory_usage":31350000},"timestamp":1700000005000000000}],"end":1700000005000000000,"id":"00000000-0000-0000-0000-000000000000","name":"memory_usage","start":1700000001000000000},"type":"timeseries"} +{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000001000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":{"timestamps":[1700000001000000000,1700000002000000000,1700000003000000000,1700000004000000000,1700000005000000000],"values":{"memory_usage":[31233300,31245500,31300800,31289100,31350000]}},"end":1700000005000000000,"id":"00000000-0000-0000-0000-000000000000","name":"memory_usage","schema":"object-v2","start":1700000001000000000},"type":"timeseries"} \ No newline at end of file diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json index 418183c1fe..156a9d3909 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json @@ -1 +1 @@ -{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000006000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":[{"data_point":{"memory_usage":31420200},"timestamp":1700000006000000000},{"data_point":{"memory_usage":31510400},"timestamp":1700000007000000000},{"data_point":{"memory_usage":31498700},"timestamp":1700000008000000000},{"data_point":{"memory_usage":31550300},"timestamp":1700000009000000000},{"data_point":{"memory_usage":31600100},"timestamp":1700000010000000000}],"end":1700000010000000000,"id":"00000000-0000-0000-0000-000000000000","name":"memory_usage","start":1700000006000000000},"type":"timeseries"} +{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000006000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":{"timestamps":[1700000006000000000,1700000007000000000,1700000008000000000,1700000009000000000,1700000010000000000],"values":{"memory_usage":[31420200,31510400,31498700,31550300,31600100]}},"end":1700000010000000000,"id":"00000000-0000-0000-0000-000000000000","name":"memory_usage","schema":"object-v2","start":1700000006000000000},"type":"timeseries"} \ No newline at end of file diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift index 60956a30b1..5261d1380c 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift @@ -40,12 +40,13 @@ final class SkipSampleTests: XCTestCase { let json = try XCTUnwrap(JSONSerialization.jsonObject(with: results[0]) as? [String: Any]) let ts = try XCTUnwrap(json["timeseries"] as? [String: Any]) - let data = try XCTUnwrap(ts["data"] as? [[String: Any]]) + let data = try XCTUnwrap(ts["data"] as? [String: Any]) + let timestamps = try XCTUnwrap(data["timestamps"] as? [Int64]) - XCTAssertEqual(data.count, 3, "Only 3 memory_usage samples, gap reflected") - XCTAssertEqual(data[0]["timestamp"] as? Int64, 1000000000) - XCTAssertEqual(data[1]["timestamp"] as? Int64, 3000000000) // gap: 2s jumped - XCTAssertEqual(data[2]["timestamp"] as? Int64, 5000000000) + XCTAssertEqual(timestamps.count, 3, "Only 3 memory_usage samples, gap reflected") + XCTAssertEqual(timestamps[0], 1000000000) + XCTAssertEqual(timestamps[1], 3000000000) // gap: 2s jumped + XCTAssertEqual(timestamps[2], 5000000000) } func testMalformedRowsSkipped() throws { @@ -69,9 +70,10 @@ final class SkipSampleTests: XCTestCase { let json = try XCTUnwrap(JSONSerialization.jsonObject(with: results[0]) as? [String: Any]) let ts = try XCTUnwrap(json["timeseries"] as? [String: Any]) - let data = try XCTUnwrap(ts["data"] as? [[String: Any]]) + let data = try XCTUnwrap(ts["data"] as? [String: Any]) + let timestamps = try XCTUnwrap(data["timestamps"] as? [Int64]) - XCTAssertEqual(data.count, 2, "Malformed row skipped") + XCTAssertEqual(timestamps.count, 2, "Malformed row skipped") } func testTimestampsReflectGap() throws { diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift index 5235056ae5..5474603763 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift @@ -26,9 +26,8 @@ final class TimeseriesEncoderTests: XCTestCase { let json = String(data: data, encoding: .utf8)! XCTAssertTrue(json.contains("\"format_version\"")) - XCTAssertTrue(json.contains("\"data_point\"")) + XCTAssertTrue(json.contains("\"timestamps\"")) XCTAssertFalse(json.contains("\"formatVersion\"")) - XCTAssertFalse(json.contains("\"dataPoint\"")) } func testProducesValidJSON() throws { @@ -66,9 +65,10 @@ final class TimeseriesEncoderTests: XCTestCase { name: .memoryUsage, start: 1_000_000_000, end: 2_000_000_000, - data: [ - TimeseriesEvent.DataPoint(timestamp: 1_000_000_000, dataPoint: ["memory_usage": 42]), - ] + data: TimeseriesEvent.Data( + timestamps: [1_000_000_000], + values: ["memory_usage": [42]] + ) ) ) } diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift index 47863e82ca..6deb2d19e8 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift @@ -49,7 +49,7 @@ final class TimeseriesEventBuilderTests: XCTestCase { XCTAssertEqual(event.timeseries.name, .cpuUsage) XCTAssertEqual(event.timeseries.start, 5_000_000_000) XCTAssertEqual(event.timeseries.end, 7_000_000_000) - XCTAssertEqual(event.timeseries.data.count, 3) + XCTAssertEqual(event.timeseries.data.timestamps.count, 3) } func testDateIsStartTimestampConvertedToMilliseconds() { @@ -73,10 +73,8 @@ final class TimeseriesEventBuilderTests: XCTestCase { let event = builder.build(samples: samples, name: .memoryUsage, eventId: "id") - XCTAssertEqual(event.timeseries.data[0].timestamp, 1000) - XCTAssertEqual(event.timeseries.data[0].dataPoint["memory_usage"], 42.5) - XCTAssertEqual(event.timeseries.data[1].timestamp, 2000) - XCTAssertEqual(event.timeseries.data[1].dataPoint["memory_usage"], 99.9) + XCTAssertEqual(event.timeseries.data.timestamps, [1000, 2000]) + XCTAssertEqual(event.timeseries.data.values["memory_usage"], [42.5, 99.9]) } func testNilServiceAndVersion() { diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift index 659d0bd381..0c65c0492f 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift @@ -22,10 +22,10 @@ final class TimeseriesEventModelTests: XCTestCase { name: .memoryUsage, start: 1773055068831000000, end: 1773055082916000000, - data: [ - TimeseriesEvent.DataPoint(timestamp: 1773055068831000000, dataPoint: ["memory_usage": 38052032]), - TimeseriesEvent.DataPoint(timestamp: 1773055069917000000, dataPoint: ["memory_usage": 37970112]), - ] + data: TimeseriesEvent.Data( + timestamps: [1773055068831000000, 1773055069917000000], + values: ["memory_usage": [38052032, 37970112]] + ) ) ) @@ -56,14 +56,16 @@ final class TimeseriesEventModelTests: XCTestCase { let ts = try XCTUnwrap(json["timeseries"] as? [String: Any]) XCTAssertEqual(ts["id"] as? String, "ts-id-789") XCTAssertEqual(ts["name"] as? String, "memory_usage") + XCTAssertEqual(ts["schema"] as? String, "object-v2") XCTAssertEqual(ts["start"] as? Int64, 1773055068831000000) XCTAssertEqual(ts["end"] as? Int64, 1773055082916000000) - let dataPoints = try XCTUnwrap(ts["data"] as? [[String: Any]]) - XCTAssertEqual(dataPoints.count, 2) - XCTAssertEqual(dataPoints[0]["timestamp"] as? Int64, 1773055068831000000) - let dp0 = try XCTUnwrap(dataPoints[0]["data_point"] as? [String: Any]) - XCTAssertEqual(dp0["memory_usage"] as? Double, 38052032) + let tsData = try XCTUnwrap(ts["data"] as? [String: Any]) + let timestamps = try XCTUnwrap(tsData["timestamps"] as? [Int64]) + XCTAssertEqual(timestamps, [1773055068831000000, 1773055069917000000]) + let values = try XCTUnwrap(tsData["values"] as? [String: Any]) + let memoryUsage = try XCTUnwrap(values["memory_usage"] as? [Double]) + XCTAssertEqual(memoryUsage, [38052032, 37970112]) } func testTimeseriesEventOmitsNilServiceAndVersion() throws { @@ -81,9 +83,10 @@ final class TimeseriesEventModelTests: XCTestCase { name: .cpuUsage, start: 1000000000, end: 2000000000, - data: [ - TimeseriesEvent.DataPoint(timestamp: 1000000000, dataPoint: ["cpu_usage": 55.3]), - ] + data: TimeseriesEvent.Data( + timestamps: [1000000000], + values: ["cpu_usage": [55.3]] + ) ) ) diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift index 305fd09b74..a500a8792f 100644 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift +++ b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift @@ -65,8 +65,7 @@ final class TimeseriesPipelineTests: XCTestCase { } func testEmptyProviderProducesNoOutput() throws { - let csv = "timestamp,metric,value -" + let csv = "timestamp,metric,value\n" let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) let pipeline = TimeseriesPipeline( provider: provider, From 141a82c6eb94658bf13334151a4673166efafc9f Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Fri, 10 Jul 2026 17:09:48 +0200 Subject: [PATCH 069/102] Remove unused DatadogTimeseries POC package --- DatadogTimeseries/Package.swift | 33 --- DatadogTimeseries/Scripts/generate-fixture.py | 121 ---------- DatadogTimeseries/Scripts/run-pipeline.sh | 165 -------------- .../Core/TimeseriesBatcher.swift | 34 --- .../Core/TimeseriesConfig.swift | 24 -- .../Core/TimeseriesEventBuilder.swift | 43 ---- .../DataProvider/CSVDataProvider.swift | 39 ---- .../DataProvider/DataProvider.swift | 10 - .../Encoding/TimeseriesEncoder.swift | 20 -- .../Filters/DeadbandFilter.swift | 45 ---- .../Filters/PassThroughFilter.swift | 20 -- .../Filters/SampleFilter.swift | 28 --- .../Filters/WindowAggregateFilter.swift | 67 ------ .../DatadogTimeseries/Models/Sample.swift | 19 -- .../Models/TimeseriesEvent.swift | 70 ------ .../Models/TimeseriesName.swift | 11 - .../TimeseriesPipeline.swift | 53 ----- .../DatadogTimeseriesRunner/main.swift | 209 ------------------ .../CSVDataProviderTests.swift | 83 ------- .../EndToEndVerificationTests.swift | 119 ---------- .../Filters/DeadbandFilterTests.swift | 99 --------- .../Filters/FilterComparisonTests.swift | 173 --------------- .../Filters/PassThroughFilterTests.swift | 41 ---- .../Filters/WindowAggregateFilterTests.swift | 103 --------- .../Fixtures/expected_cpu_batch1.json | 1 - .../Fixtures/expected_cpu_batch2.json | 1 - .../Fixtures/expected_memory_batch1.json | 1 - .../Fixtures/expected_memory_batch2.json | 1 - .../Fixtures/input_memory_cpu.csv | 21 -- .../Fixtures/input_realistic_60s.csv | 121 ---------- .../SkipSampleTests.swift | 101 --------- .../TimeseriesBatcherTests.swift | 76 ------- .../TimeseriesEncoderTests.swift | 75 ------- .../TimeseriesEventBuilderTests.swift | 99 --------- .../TimeseriesEventModelTests.swift | 116 ---------- .../TimeseriesPipelineTests.swift | 80 ------- DatadogTimeseries/output/deadband_cpu.ndjson | 1 - .../output/deadband_memory.ndjson | 1 - .../output/passthrough_cpu.ndjson | 2 - .../output/passthrough_memory.ndjson | 2 - DatadogTimeseries/output/window_cpu.ndjson | 1 - DatadogTimeseries/output/window_memory.ndjson | 1 - 42 files changed, 2330 deletions(-) delete mode 100644 DatadogTimeseries/Package.swift delete mode 100644 DatadogTimeseries/Scripts/generate-fixture.py delete mode 100755 DatadogTimeseries/Scripts/run-pipeline.sh delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesBatcher.swift delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Encoding/TimeseriesEncoder.swift delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Filters/DeadbandFilter.swift delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Filters/PassThroughFilter.swift delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Filters/SampleFilter.swift delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Filters/WindowAggregateFilter.swift delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift delete mode 100644 DatadogTimeseries/Sources/DatadogTimeseriesRunner/main.swift delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/EndToEndVerificationTests.swift delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/DeadbandFilterTests.swift delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/PassThroughFilterTests.swift delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/WindowAggregateFilterTests.swift delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_memory_cpu.csv delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesBatcherTests.swift delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift delete mode 100644 DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift delete mode 100644 DatadogTimeseries/output/deadband_cpu.ndjson delete mode 100644 DatadogTimeseries/output/deadband_memory.ndjson delete mode 100644 DatadogTimeseries/output/passthrough_cpu.ndjson delete mode 100644 DatadogTimeseries/output/passthrough_memory.ndjson delete mode 100644 DatadogTimeseries/output/window_cpu.ndjson delete mode 100644 DatadogTimeseries/output/window_memory.ndjson diff --git a/DatadogTimeseries/Package.swift b/DatadogTimeseries/Package.swift deleted file mode 100644 index b5759d9ba6..0000000000 --- a/DatadogTimeseries/Package.swift +++ /dev/null @@ -1,33 +0,0 @@ -// swift-tools-version: 5.9 - -import PackageDescription - -let package = Package( - name: "DatadogTimeseries", - products: [ - .library( - name: "DatadogTimeseries", - targets: ["DatadogTimeseries"] - ), - ], - targets: [ - .target( - name: "DatadogTimeseries", - dependencies: [], - path: "Sources/DatadogTimeseries" - ), - .executableTarget( - name: "DatadogTimeseriesRunner", - dependencies: ["DatadogTimeseries"], - path: "Sources/DatadogTimeseriesRunner" - ), - .testTarget( - name: "DatadogTimeseriesTests", - dependencies: ["DatadogTimeseries"], - path: "Tests/DatadogTimeseriesTests", - resources: [ - .copy("Fixtures"), - ] - ), - ] -) diff --git a/DatadogTimeseries/Scripts/generate-fixture.py b/DatadogTimeseries/Scripts/generate-fixture.py deleted file mode 100644 index c164def3d1..0000000000 --- a/DatadogTimeseries/Scripts/generate-fixture.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python3 -# ----------------------------------------------------------- -# Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. -# This product includes software developed at Datadog (https://www.datadoghq.com/). -# Copyright 2019-Present Datadog, Inc. -# ----------------------------------------------------------- -""" -Generates a realistic 60-sample CSV fixture for DatadogTimeseries tests. - -Output: Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv - -CSV format: timestamp,metric,value -- 60 seconds of data (t=1 to t=60), interleaved memory then cpu per second -- 120 data rows total + 1 header row - -Memory shape: -- Base: 31_000_000 bytes (~31MB) -- Slow drift: deterministic +500..+2000 bytes/s (cycle of 8 fixed offsets) -- Allocation jumps at t=12 (+1_500_000), t=30 (+2_000_000), t=50 (+1_000_000) -- Deallocation at t=40 (-800_000) - -CPU shape: -- Baseline: cycling through [5.0, 7.5, 6.0, 8.5, 5.5, 9.0] -- Burst at t=15..17: [65.0, 72.0, 58.0] -- Burst at t=35..37: [80.0, 75.0, 68.0] -- Burst at t=55..57: [55.0, 62.0, 50.0] -""" - -import os - -BASE_TIMESTAMP = 1700000001000000000 -NS_PER_SECOND = 1_000_000_000 - -OUTPUT_PATH = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "Tests", "DatadogTimeseriesTests", "Fixtures", "input_realistic_60s.csv" -) - -# Deterministic per-second drift values (cycle of 8, repeating) -DRIFT_CYCLE = [500, 800, 1200, 1500, 700, 1000, 2000, 600] - -# Allocation events: {second -> delta} -ALLOC_EVENTS = { - 12: +1_500_000, - 30: +2_000_000, - 40: -800_000, - 50: +1_000_000, -} - -# CPU baseline cycling values -CPU_BASELINE = [5.0, 7.5, 6.0, 8.5, 5.5, 9.0] - -# CPU burst overrides: {second -> value} -CPU_BURSTS = { - 15: 65.0, - 16: 72.0, - 17: 58.0, - 35: 80.0, - 36: 75.0, - 37: 68.0, - 55: 55.0, - 56: 62.0, - 57: 50.0, -} - - -def generate_rows(): - rows = [] - memory = 31_000_000 - baseline_index = 0 - - for i, second in enumerate(range(1, 61)): - timestamp = BASE_TIMESTAMP + (second - 1) * NS_PER_SECOND - - # --- Memory --- - drift = DRIFT_CYCLE[i % len(DRIFT_CYCLE)] - memory += drift - if second in ALLOC_EVENTS: - memory += ALLOC_EVENTS[second] - rows.append((timestamp, "memory_usage", memory)) - - # --- CPU --- - if second in CPU_BURSTS: - cpu = CPU_BURSTS[second] - else: - cpu = CPU_BASELINE[baseline_index % len(CPU_BASELINE)] - baseline_index += 1 - - # Format cpu: no trailing zeros for whole numbers, keep one decimal otherwise - if cpu == int(cpu): - cpu_str = f"{int(cpu)}.0" - else: - cpu_str = str(cpu) - - rows.append((timestamp, "cpu_usage", cpu_str)) - - return rows - - -def main(): - rows = generate_rows() - - os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) - - with open(OUTPUT_PATH, "w") as f: - f.write("timestamp,metric,value\n") - for timestamp, metric, value in rows: - f.write(f"{timestamp},{metric},{value}\n") - - data_rows = len(rows) - print(f"Generated: {OUTPUT_PATH}") - print(f"Data rows: {data_rows} (expected 120)") - - # Quick sanity check on first few rows - print("\nFirst 6 rows:") - for row in rows[:6]: - print(f" {row[0]},{row[1]},{row[2]}") - - -if __name__ == "__main__": - main() diff --git a/DatadogTimeseries/Scripts/run-pipeline.sh b/DatadogTimeseries/Scripts/run-pipeline.sh deleted file mode 100755 index c884672e53..0000000000 --- a/DatadogTimeseries/Scripts/run-pipeline.sh +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env bash -# run-pipeline.sh — Run the DatadogTimeseries filter pipeline against the 60s fixture, -# print a comparison table, and write per-filter NDJSON output files. -# -# Usage: -# ./scripts/run-pipeline.sh [options] -# -# Options: -# --filter passthrough|deadband|window Filter to pretty-print the first event from (default: passthrough) -# --threshold Deadband threshold in bytes (default: 1000000) -# --heartbeat Deadband heartbeat interval in seconds (default: 30) -# --window Window aggregate duration in seconds (default: 5) -# --aggregate max|avg|min|last Window aggregate function (default: max) - -set -euo pipefail - -# --------------------------------------------------------------------------- -# Defaults -# --------------------------------------------------------------------------- -FILTER="passthrough" -THRESHOLD=1000000 -HEARTBEAT=30 -WINDOW=5 -AGGREGATE="max" - -# --------------------------------------------------------------------------- -# Argument parsing -# --------------------------------------------------------------------------- -while [[ $# -gt 0 ]]; do - case "$1" in - --filter) FILTER="$2"; shift 2 ;; - --threshold) THRESHOLD="$2"; shift 2 ;; - --heartbeat) HEARTBEAT="$2"; shift 2 ;; - --window) WINDOW="$2"; shift 2 ;; - --aggregate) AGGREGATE="$2"; shift 2 ;; - *) echo "Unknown argument: $1" >&2; exit 1 ;; - esac -done - -# --------------------------------------------------------------------------- -# Resolve package root (the directory containing Package.swift) -# --------------------------------------------------------------------------- -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PKG_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -FIXTURE_PATH="$PKG_ROOT/Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv" -OUTPUT_DIR="$PKG_ROOT/output" - -# --------------------------------------------------------------------------- -# Clear output directory -# --------------------------------------------------------------------------- -echo "Clearing output/ directory..." -rm -rf "$OUTPUT_DIR" -mkdir -p "$OUTPUT_DIR" - -# --------------------------------------------------------------------------- -# Run tests -# --------------------------------------------------------------------------- -echo "" -echo "Running swift test..." -cd "$PKG_ROOT" -if ! swift test 2>&1; then - echo "" >&2 - echo "ERROR: swift test failed. Aborting." >&2 - exit 1 -fi -echo "All tests passed." - -# --------------------------------------------------------------------------- -# Run runner and capture JSON output to a temp file -# --------------------------------------------------------------------------- -echo "" -echo "Running pipeline across all filters..." -RUNNER_TMP="$(mktemp /tmp/ts-runner-output.XXXXXX.json)" -trap 'rm -f "$RUNNER_TMP"' EXIT - -swift run DatadogTimeseriesRunner -- \ - --fixture-path "$FIXTURE_PATH" \ - --output-dir "$OUTPUT_DIR" \ - --threshold "$THRESHOLD" \ - --heartbeat "$HEARTBEAT" \ - --window "$WINDOW" \ - --aggregate "$AGGREGATE" > "$RUNNER_TMP" - -# --------------------------------------------------------------------------- -# Parse stats and print comparison table -# --------------------------------------------------------------------------- -python3 - "$RUNNER_TMP" <<'PYEOF' -import json, sys - -with open(sys.argv[1]) as f: - data = json.load(f) - -stats = data["stats"] -filters = ["passthrough", "deadband", "window"] -labels = {"passthrough": "PassThrough", "deadband": "Deadband", "window": "WindowAggregate"} - -pt_dp_mem = stats["passthrough"]["memory"]["dataPointCount"] -pt_dp_cpu = stats["passthrough"]["cpu"]["dataPointCount"] -pt_total = pt_dp_mem + pt_dp_cpu - -rows = [] -for f in filters: - ev_mem = stats[f]["memory"]["eventCount"] - dp_mem = stats[f]["memory"]["dataPointCount"] - ev_cpu = stats[f]["cpu"]["eventCount"] - dp_cpu = stats[f]["cpu"]["dataPointCount"] - total_dp = dp_mem + dp_cpu - reduction = round((1.0 - total_dp / pt_total) * 100, 1) if pt_total > 0 else 0.0 - rows.append((labels[f], ev_mem, dp_mem, ev_cpu, dp_cpu, reduction)) - -col_w = [20, 13, 13, 13, 13, 13] -header = ( - f"{'Filter':<{col_w[0]}}" - f"{'Events(mem)':>{col_w[1]}}" - f"{'Points(mem)':>{col_w[2]}}" - f"{'Events(cpu)':>{col_w[3]}}" - f"{'Points(cpu)':>{col_w[4]}}" - f"{'Reduction %':>{col_w[5]}}" -) -sep = "-" * sum(col_w) - -print("") -print("Filter comparison (fixture: input_realistic_60s.csv)") -print(sep) -print(header) -print(sep) -for (label, em, dm, ec, dc, red) in rows: - print( - f"{label:<{col_w[0]}}" - f"{em:>{col_w[1]}}" - f"{dm:>{col_w[2]}}" - f"{ec:>{col_w[3]}}" - f"{dc:>{col_w[4]}}" - f"{str(red) + '%':>{col_w[5]}}" - ) -print(sep) -PYEOF - -# --------------------------------------------------------------------------- -# List written output files -# --------------------------------------------------------------------------- -echo "" -echo "Output files written to output/:" -ls -1 "$OUTPUT_DIR" - -# --------------------------------------------------------------------------- -# Pretty-print first event from selected filter (memory metric) -# --------------------------------------------------------------------------- -python3 - "$RUNNER_TMP" "$FILTER" <<'PYEOF' -import json, sys - -with open(sys.argv[1]) as f: - data = json.load(f) - -selected_filter = sys.argv[2] -key = f"{selected_filter}_memory" -raw = data.get("firstEvents", {}).get(key, "") -print(f"\nFirst event from filter '{selected_filter}' (memory_usage):") -if raw: - parsed = json.loads(raw) - print(json.dumps(parsed, indent=2, sort_keys=True)) -else: - print("(no event)") -PYEOF diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesBatcher.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesBatcher.swift deleted file mode 100644 index a22dd10050..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesBatcher.swift +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import Foundation - -class TimeseriesBatcher { - private let batchSize: Int - private var buffer: [Sample] = [] - - init(batchSize: Int = 30) { - self.batchSize = batchSize - } - - func add(_ sample: Sample) { - buffer.append(sample) - } - - func shouldFlush() -> Bool { - buffer.count >= batchSize - } - - func flush() -> [Sample] { - let batch = buffer - buffer = [] - return batch - } - - func flushRemaining() -> [Sample]? { - guard !buffer.isEmpty else { return nil } - return flush() - } -} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift deleted file mode 100644 index 46a57fee66..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesConfig.swift +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import Foundation - -public struct TimeseriesConfig { - public let applicationId: String - public let sessionId: String - public let sessionType: String - public let source: String - public let service: String? - public let version: String? - - public init(applicationId: String, sessionId: String, sessionType: String, source: String, service: String?, version: String?) { - self.applicationId = applicationId - self.sessionId = sessionId - self.sessionType = sessionType - self.source = source - self.service = service - self.version = version - } -} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift deleted file mode 100644 index cfccce8b4f..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Core/TimeseriesEventBuilder.swift +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import Foundation - -struct TimeseriesEventBuilder { - private let config: TimeseriesConfig - - init(config: TimeseriesConfig) { - self.config = config - } - - func build(samples: [Sample], name: TimeseriesName, eventId: String) -> TimeseriesEvent { - let start = samples.first?.timestamp ?? 0 - let end = samples.last?.timestamp ?? 0 - let dateMs = start / 1_000_000 - - let data = TimeseriesEvent.Data( - timestamps: samples.map { $0.timestamp }, - values: [name.rawValue: samples.map { $0.value }] - ) - - return TimeseriesEvent( - dd: TimeseriesEvent.DD(formatVersion: 2), - application: TimeseriesEvent.Application(id: config.applicationId), - date: dateMs, - session: TimeseriesEvent.Session(id: config.sessionId, type: config.sessionType), - source: config.source, - type: "timeseries", - service: config.service, - version: config.version, - timeseries: TimeseriesEvent.Timeseries( - id: eventId, - name: name, - start: start, - end: end, - data: data - ) - ) - } -} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift deleted file mode 100644 index bf6851da5a..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/CSVDataProvider.swift +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import Foundation - -public class CSVDataProvider: DataProvider { - private var samples: [Sample] - private var index: Int = 0 - - public init(csvContent: String, metric: TimeseriesName) { - var parsed: [Sample] = [] - let lines = csvContent.components(separatedBy: "\n") - - for line in lines.dropFirst() { // skip header - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - - let columns = trimmed.components(separatedBy: ",") - guard columns.count == 3 else { continue } - - guard columns[1] == metric.rawValue else { continue } - guard let timestamp = Int64(columns[0]), - let value = Double(columns[2]) else { continue } - - parsed.append(Sample(timestamp: timestamp, value: value)) - } - - self.samples = parsed - } - - public func read() -> Sample? { - guard index < samples.count else { return nil } - let sample = samples[index] - index += 1 - return sample - } -} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift b/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift deleted file mode 100644 index 29198c7f35..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseries/DataProvider/DataProvider.swift +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import Foundation - -public protocol DataProvider { - func read() -> Sample? -} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Encoding/TimeseriesEncoder.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Encoding/TimeseriesEncoder.swift deleted file mode 100644 index 4a25ed6cc0..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Encoding/TimeseriesEncoder.swift +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import Foundation - -struct TimeseriesEncoder { - private let encoder: JSONEncoder - - init() { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - self.encoder = encoder - } - - func encode(_ event: TimeseriesEvent) throws -> Data { - try encoder.encode(event) - } -} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Filters/DeadbandFilter.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Filters/DeadbandFilter.swift deleted file mode 100644 index f1b9135b69..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Filters/DeadbandFilter.swift +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ - -import Foundation - -/// Suppresses samples whose value has not changed meaningfully since the last emitted sample. -/// Use for slow-changing metrics like memory usage and battery level. -public final class DeadbandFilter: SampleFilter { - private let threshold: Double - private let heartbeatInterval: Int64? - - private var lastEmittedValue: Double? - private var lastEmittedTimestamp: Int64? - - public init(threshold: Double, heartbeatInterval: Int64? = nil) { - self.threshold = threshold - self.heartbeatInterval = heartbeatInterval - } - - public func process(_ sample: Sample) -> [Sample] { - guard let lastValue = lastEmittedValue, let lastTimestamp = lastEmittedTimestamp else { - lastEmittedValue = sample.value - lastEmittedTimestamp = sample.timestamp - return [sample] - } - - let valueChanged = abs(sample.value - lastValue) >= threshold - let heartbeatDue = heartbeatInterval.map { sample.timestamp - lastTimestamp >= $0 } ?? false - - if valueChanged || heartbeatDue { - lastEmittedValue = sample.value - lastEmittedTimestamp = sample.timestamp - return [sample] - } - - return [] - } - - public func flush() -> [Sample] { - return [] - } -} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Filters/PassThroughFilter.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Filters/PassThroughFilter.swift deleted file mode 100644 index 7ecec5ffa5..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Filters/PassThroughFilter.swift +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ - -import Foundation - -/// Passes every sample through unchanged. Represents the baseline pipeline behaviour with no sampling strategy applied. -public final class PassThroughFilter: SampleFilter { - public init() {} - - public func process(_ sample: Sample) -> [Sample] { - return [sample] - } - - public func flush() -> [Sample] { - return [] - } -} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Filters/SampleFilter.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Filters/SampleFilter.swift deleted file mode 100644 index afa95dd87b..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Filters/SampleFilter.swift +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ - -import Foundation - -/// A stateful filter that processes samples from a provider before they are forwarded to the batcher. -/// -/// Filters are class-only (reference semantics) because they maintain internal state across calls. -public protocol SampleFilter: AnyObject { - /// Called for each sample emitted by the provider. - /// - /// - Parameter sample: The incoming sample to process. - /// - Returns: The samples to forward to the batcher. Return an empty array to suppress the sample, - /// return `[sample]` to forward it unchanged, or return multiple samples to expand it. - func process(_ sample: Sample) -> [Sample] - - /// Called once when the provider is exhausted, signalling end-of-stream. - /// - /// Use this method to flush any internally buffered samples that have not yet been forwarded. - /// Most filters return an empty array here. Aggregating filters (e.g. `WindowAggregateFilter`) - /// use this to emit the final partial window that would otherwise be held back. - /// - /// - Returns: Any remaining samples that should be forwarded to the batcher. - func flush() -> [Sample] -} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Filters/WindowAggregateFilter.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Filters/WindowAggregateFilter.swift deleted file mode 100644 index 05e37099d7..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Filters/WindowAggregateFilter.swift +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ - -import Foundation - -public enum AggregateFunction { - case avg, min, max, last -} - -/// Aggregates samples into fixed time windows and emits one value per window. -/// Use for noisy continuous metrics like CPU usage and frame rate. -public final class WindowAggregateFilter: SampleFilter { - private let windowDuration: Int64 - private let function: AggregateFunction - - private var windowStart: Int64? - private var buffer: [Sample] = [] - - public init(windowDuration: Int64, function: AggregateFunction = .max) { - self.windowDuration = windowDuration - self.function = function - } - - public func process(_ sample: Sample) -> [Sample] { - guard let start = windowStart else { - windowStart = sample.timestamp - buffer.append(sample) - return [] - } - - if sample.timestamp - start >= windowDuration { - let aggregateSample = Sample(timestamp: start, value: aggregate(buffer)) - windowStart = sample.timestamp - buffer = [sample] - return [aggregateSample] - } - - buffer.append(sample) - return [] - } - - public func flush() -> [Sample] { - guard !buffer.isEmpty else { - return [] - } - - let aggregateSample = Sample(timestamp: windowStart!, value: aggregate(buffer)) - buffer = [] - return [aggregateSample] - } - - private func aggregate(_ samples: [Sample]) -> Double { - switch function { - case .avg: - return samples.reduce(0.0) { $0 + $1.value } / Double(samples.count) - case .min: - return samples.min(by: { $0.value < $1.value })!.value - case .max: - return samples.max(by: { $0.value < $1.value })!.value - case .last: - return samples.last!.value - } - } -} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift deleted file mode 100644 index efbca8844e..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Models/Sample.swift +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import Foundation - -/// A single timestamped performance sample. -public struct Sample { - /// Timestamp in nanoseconds. - public let timestamp: Int64 - /// Metric value (e.g. bytes for memory, percent for CPU). - public let value: Double - - public init(timestamp: Int64, value: Double) { - self.timestamp = timestamp - self.value = value - } -} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift deleted file mode 100644 index 53d0183c51..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import Foundation - -public struct TimeseriesEvent: Codable { - public let dd: DD - public let application: Application - public let date: Int64 - public let session: Session - public let source: String - public let type: String - public let service: String? - public let version: String? - public let timeseries: Timeseries - - enum CodingKeys: String, CodingKey { - case dd = "_dd" - case application - case date - case session - case source - case type - case service - case version - case timeseries - } - - public struct DD: Codable { - public let formatVersion: Int - - enum CodingKeys: String, CodingKey { - case formatVersion = "format_version" - } - } - - public struct Application: Codable { - public let id: String - } - - public struct Session: Codable { - public let id: String - public let type: String - } - - public struct Timeseries: Codable { - public let id: String - public let name: TimeseriesName - public let schema: String = "object-v2" - public let start: Int64 - public let end: Int64 - public let data: Data - - enum CodingKeys: String, CodingKey { - case id - case name - case schema - case start - case end - case data - } - } - - public struct Data: Codable { - public let timestamps: [Int64] - public let values: [String: [Double]] - } -} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift b/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift deleted file mode 100644 index 932711e348..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesName.swift +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import Foundation - -public enum TimeseriesName: String, Codable { - case memoryUsage = "memory_usage" - case cpuUsage = "cpu_usage" -} diff --git a/DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift b/DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift deleted file mode 100644 index f30abd4aa0..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseries/TimeseriesPipeline.swift +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import Foundation - -public struct TimeseriesPipeline { - private let provider: DataProvider - private let config: TimeseriesConfig - private let metricName: TimeseriesName - private let batchSize: Int - private let filter: SampleFilter - - public init(provider: DataProvider, config: TimeseriesConfig, metricName: TimeseriesName, batchSize: Int = 30, filter: SampleFilter = PassThroughFilter()) { - self.provider = provider - self.config = config - self.metricName = metricName - self.batchSize = batchSize - self.filter = filter - } - - public func processAll() throws -> [Data] { - let batcher = TimeseriesBatcher(batchSize: batchSize) - let builder = TimeseriesEventBuilder(config: config) - let encoder = TimeseriesEncoder() - var results: [Data] = [] - - func processSamples(_ samples: [Sample]) throws { - for sample in samples { - batcher.add(sample) - if batcher.shouldFlush() { - let batch = batcher.flush() - let event = builder.build(samples: batch, name: metricName, eventId: UUID().uuidString.lowercased()) - results.append(try encoder.encode(event)) - } - } - } - - while let raw = provider.read() { - try processSamples(filter.process(raw)) - } - - try processSamples(filter.flush()) - - if let remaining = batcher.flushRemaining() { - let event = builder.build(samples: remaining, name: metricName, eventId: UUID().uuidString.lowercased()) - results.append(try encoder.encode(event)) - } - - return results - } -} diff --git a/DatadogTimeseries/Sources/DatadogTimeseriesRunner/main.swift b/DatadogTimeseries/Sources/DatadogTimeseriesRunner/main.swift deleted file mode 100644 index 3fc03a2984..0000000000 --- a/DatadogTimeseries/Sources/DatadogTimeseriesRunner/main.swift +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ - -import Foundation -import DatadogTimeseries - -// MARK: - Argument parsing - -struct RunnerArgs { - var fixturePath: String = "" - var outputDir: String = "" - var threshold: Double = 1_000_000 - var heartbeat: Int64 = 30 - var windowSeconds: Int64 = 5 - var aggregate: AggregateFunction = .max -} - -func parseArgs() -> RunnerArgs { - var args = RunnerArgs() - var i = 1 - let argv = CommandLine.arguments - while i < argv.count { - switch argv[i] { - case "--fixture-path": - i += 1; args.fixturePath = argv[i] - case "--output-dir": - i += 1; args.outputDir = argv[i] - case "--threshold": - i += 1; args.threshold = Double(argv[i]) ?? args.threshold - case "--heartbeat": - i += 1; args.heartbeat = Int64(argv[i]) ?? args.heartbeat - case "--window": - i += 1; args.windowSeconds = Int64(argv[i]) ?? args.windowSeconds - case "--aggregate": - i += 1 - switch argv[i] { - case "avg": args.aggregate = .avg - case "min": args.aggregate = .min - case "last": args.aggregate = .last - default: args.aggregate = .max - } - default: - break - } - i += 1 - } - return args -} - -// MARK: - Pipeline helpers - -struct PipelineResult { - let events: [Data] - var eventCount: Int { events.count } - var dataPointCount: Int { - let decoder = JSONDecoder() - return events.compactMap { data -> Int? in - let event = try? decoder.decode(TimeseriesEvent.self, from: data) - return event?.timeseries.data.timestamps.count - }.reduce(0, +) - } -} - -func runPipeline(csvContent: String, metric: TimeseriesName, filter: SampleFilter) throws -> PipelineResult { - let config = TimeseriesConfig( - applicationId: "runner-app-id", - sessionId: "runner-session-id", - sessionType: "user", - source: "ios", - service: nil, - version: nil - ) - let provider = CSVDataProvider(csvContent: csvContent, metric: metric) - let pipeline = TimeseriesPipeline(provider: provider, config: config, metricName: metric, filter: filter) - let events = try pipeline.processAll() - return PipelineResult(events: events) -} - -// MARK: - Output - -struct MetricStats: Codable { - let eventCount: Int - let dataPointCount: Int -} - -struct FilterStats: Codable { - let memory: MetricStats - let cpu: MetricStats -} - -struct RunnerOutput: Codable { - let passthrough: FilterStats - let deadband: FilterStats - let window: FilterStats -} - -// MARK: - Main - -let args = parseArgs() - -guard !args.fixturePath.isEmpty, !args.outputDir.isEmpty else { - fputs("Error: --fixture-path and --output-dir are required\n", stderr) - exit(1) -} - -let csvContent: String -do { - csvContent = try String(contentsOfFile: args.fixturePath, encoding: .utf8) -} catch { - fputs("Error reading fixture: \(error)\n", stderr) - exit(1) -} - -// heartbeat interval in nanoseconds (fixture timestamps are in nanoseconds) -let heartbeatNs = args.heartbeat * 1_000_000_000 -let windowNs = args.windowSeconds * 1_000_000_000 - -let filters: [(name: String, filter: SampleFilter)] = [ - ("passthrough", PassThroughFilter()), - ("deadband", DeadbandFilter(threshold: args.threshold, heartbeatInterval: heartbeatNs)), - ("window", WindowAggregateFilter(windowDuration: windowNs, function: args.aggregate)), -] - -var allResults: [(name: String, memory: PipelineResult, cpu: PipelineResult)] = [] - -for entry in filters { - // Re-instantiate per-metric because filters are stateful - let memFilter: SampleFilter - let cpuFilter: SampleFilter - switch entry.name { - case "deadband": - memFilter = DeadbandFilter(threshold: args.threshold, heartbeatInterval: heartbeatNs) - cpuFilter = DeadbandFilter(threshold: args.threshold, heartbeatInterval: heartbeatNs) - case "window": - memFilter = WindowAggregateFilter(windowDuration: windowNs, function: args.aggregate) - cpuFilter = WindowAggregateFilter(windowDuration: windowNs, function: args.aggregate) - default: - memFilter = PassThroughFilter() - cpuFilter = PassThroughFilter() - } - - do { - let memResult = try runPipeline(csvContent: csvContent, metric: .memoryUsage, filter: memFilter) - let cpuResult = try runPipeline(csvContent: csvContent, metric: .cpuUsage, filter: cpuFilter) - allResults.append((name: entry.name, memory: memResult, cpu: cpuResult)) - } catch { - fputs("Error running \(entry.name) pipeline: \(error)\n", stderr) - exit(1) - } -} - -// Write ndjson output files -let fm = FileManager.default -try? fm.createDirectory(atPath: args.outputDir, withIntermediateDirectories: true) - -for entry in allResults { - for (metricName, result) in [("memory", entry.memory), ("cpu", entry.cpu)] { - let filename = "\(entry.name)_\(metricName).ndjson" - let path = (args.outputDir as NSString).appendingPathComponent(filename) - let lines = result.events.compactMap { String(data: $0, encoding: .utf8) } - let content = lines.joined(separator: "\n") + (lines.isEmpty ? "" : "\n") - try content.write(toFile: path, atomically: true, encoding: .utf8) - } -} - -// Build JSON summary output -func statsFor(name: String) -> FilterStats { - let entry = allResults.first { $0.name == name }! - return FilterStats( - memory: MetricStats(eventCount: entry.memory.eventCount, dataPointCount: entry.memory.dataPointCount), - cpu: MetricStats(eventCount: entry.cpu.eventCount, dataPointCount: entry.cpu.dataPointCount) - ) -} - -let output = RunnerOutput( - passthrough: statsFor(name: "passthrough"), - deadband: statsFor(name: "deadband"), - window: statsFor(name: "window") -) - -// Also emit first events for each filter as "first_event_" keys -// We emit everything as a single JSON object to stdout -struct FullOutput: Codable { - let stats: RunnerOutput - let firstEvents: [String: String] -} - -var firstEvents: [String: String] = [:] -for entry in allResults { - if let firstData = entry.memory.events.first, let str = String(data: firstData, encoding: .utf8) { - firstEvents["\(entry.name)_memory"] = str - } - if let firstData = entry.cpu.events.first, let str = String(data: firstData, encoding: .utf8) { - firstEvents["\(entry.name)_cpu"] = str - } -} - -let fullOutput = FullOutput(stats: output, firstEvents: firstEvents) -let encoder = JSONEncoder() -encoder.outputFormatting = [.prettyPrinted, .sortedKeys] -if let jsonData = try? encoder.encode(fullOutput), let jsonStr = String(data: jsonData, encoding: .utf8) { - print(jsonStr) -} else { - fputs("Error encoding output JSON\n", stderr) - exit(1) -} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift deleted file mode 100644 index f6876a66e6..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/CSVDataProviderTests.swift +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import XCTest -@testable import DatadogTimeseries - -final class CSVDataProviderTests: XCTestCase { - func testReadsFilteredSamplesFromCSV() throws { - let csv = """ - timestamp,metric,value - 1000000000,memory_usage,30000000 - 1000000000,cpu_usage,12.5 - 2000000000,memory_usage,31000000 - 2000000000,cpu_usage,15.0 - 3000000000,memory_usage,32000000 - """ - - let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) - - let s1 = provider.read() - XCTAssertEqual(s1?.timestamp, 1000000000) - XCTAssertEqual(s1?.value, 30000000) - - let s2 = provider.read() - XCTAssertEqual(s2?.timestamp, 2000000000) - XCTAssertEqual(s2?.value, 31000000) - - let s3 = provider.read() - XCTAssertEqual(s3?.timestamp, 3000000000) - XCTAssertEqual(s3?.value, 32000000) - - let s4 = provider.read() - XCTAssertNil(s4) - } - - func testFiltersByCPUUsage() throws { - let csv = """ - timestamp,metric,value - 1000000000,memory_usage,30000000 - 1000000000,cpu_usage,12.5 - 2000000000,cpu_usage,15.0 - """ - - let provider = CSVDataProvider(csvContent: csv, metric: .cpuUsage) - - let s1 = provider.read() - XCTAssertEqual(s1?.timestamp, 1000000000) - XCTAssertEqual(s1?.value, 12.5) - - let s2 = provider.read() - XCTAssertEqual(s2?.timestamp, 2000000000) - XCTAssertEqual(s2?.value, 15.0) - - XCTAssertNil(provider.read()) - } - - func testReturnsNilForEmptyCSV() { - let csv = "timestamp,metric,value\n" - let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) - XCTAssertNil(provider.read()) - } - - func testSkipsMalformedRows() { - let csv = """ - timestamp,metric,value - 1000000000,memory_usage,30000000 - bad_row - 2000000000,memory_usage,31000000 - """ - - let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) - - let s1 = provider.read() - XCTAssertEqual(s1?.timestamp, 1000000000) - - let s2 = provider.read() - XCTAssertEqual(s2?.timestamp, 2000000000) - - XCTAssertNil(provider.read()) - } -} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/EndToEndVerificationTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/EndToEndVerificationTests.swift deleted file mode 100644 index feab150fe4..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/EndToEndVerificationTests.swift +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import XCTest -@testable import DatadogTimeseries - -final class EndToEndVerificationTests: XCTestCase { - private let config = TimeseriesConfig( - applicationId: "00000000-0000-0000-0000-000000000000", - sessionId: "00000000-0000-0000-0000-000000000000", - sessionType: "user", - source: "ios", - service: nil, - version: nil - ) - - // MARK: - Memory - - func testMemoryBatch1MatchesFixture() throws { - let actual = try processMetric(.memoryUsage, batchIndex: 0) - let expected = try loadFixture("expected_memory_batch1") - XCTAssertEqual(actual, expected, "Memory batch 1 does not match fixture") - } - - func testMemoryBatch2MatchesFixture() throws { - let actual = try processMetric(.memoryUsage, batchIndex: 1) - let expected = try loadFixture("expected_memory_batch2") - XCTAssertEqual(actual, expected, "Memory batch 2 does not match fixture") - } - - // MARK: - CPU - - func testCPUBatch1MatchesFixture() throws { - let actual = try processMetric(.cpuUsage, batchIndex: 0) - let expected = try loadFixture("expected_cpu_batch1") - XCTAssertEqual(actual, expected, "CPU batch 1 does not match fixture") - } - - func testCPUBatch2MatchesFixture() throws { - let actual = try processMetric(.cpuUsage, batchIndex: 1) - let expected = try loadFixture("expected_cpu_batch2") - XCTAssertEqual(actual, expected, "CPU batch 2 does not match fixture") - } - - // MARK: - Structural validation - - func testOutputContainsRequiredFields() throws { - let results = try runPipeline(metric: .memoryUsage) - for data in results { - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - XCTAssertNotNil(json["_dd"]) - XCTAssertNotNil(json["application"]) - XCTAssertNotNil(json["date"]) - XCTAssertNotNil(json["session"]) - XCTAssertNotNil(json["source"]) - XCTAssertNotNil(json["timeseries"]) - XCTAssertEqual(json["type"] as? String, "timeseries") - - let dd = try XCTUnwrap(json["_dd"] as? [String: Any]) - XCTAssertEqual(dd["format_version"] as? Int, 2) - - let session = try XCTUnwrap(json["session"] as? [String: Any]) - XCTAssertEqual(session["type"] as? String, "user") - } - } - - func testMemoryProducesTwoBatches() throws { - let results = try runPipeline(metric: .memoryUsage) - XCTAssertEqual(results.count, 2, "10 samples / batchSize 5 = 2 batches") - } - - func testCPUProducesTwoBatches() throws { - let results = try runPipeline(metric: .cpuUsage) - XCTAssertEqual(results.count, 2, "10 samples / batchSize 5 = 2 batches") - } - - // MARK: - Helpers - - private func runPipeline(metric: TimeseriesName) throws -> [Data] { - let csvURL = try XCTUnwrap( - Bundle.module.url(forResource: "input_memory_cpu", withExtension: "csv", subdirectory: "Fixtures") - ) - let csvContent = try String(contentsOf: csvURL) - let provider = CSVDataProvider(csvContent: csvContent, metric: metric) - let pipeline = TimeseriesPipeline( - provider: provider, - config: config, - metricName: metric, - batchSize: 5 - ) - return try pipeline.processAll() - } - - private func processMetric(_ metric: TimeseriesName, batchIndex: Int) throws -> String { - let results = try runPipeline(metric: metric) - let jsonString = String(data: results[batchIndex], encoding: .utf8)! - return maskUUIDs(jsonString) - } - - private func loadFixture(_ name: String) throws -> String { - let url = try XCTUnwrap( - Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures") - ) - return try String(contentsOf: url).trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func maskUUIDs(_ string: String) -> String { - let pattern = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" - let regex = try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) - let range = NSRange(string.startIndex..., in: string) - return regex.stringByReplacingMatches( - in: string, - range: range, - withTemplate: "00000000-0000-0000-0000-000000000000" - ) - } -} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/DeadbandFilterTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/DeadbandFilterTests.swift deleted file mode 100644 index 3864a5567a..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/DeadbandFilterTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import XCTest -@testable import DatadogTimeseries - -final class DeadbandFilterTests: XCTestCase { - func testAlwaysEmitsFirstSample() { - let filter = DeadbandFilter(threshold: 10.0) - let sample = Sample(timestamp: 1_000_000_000, value: 50.0) - - let result = filter.process(sample) - - XCTAssertEqual(result.count, 1) - XCTAssertEqual(result[0].value, 50.0) - } - - func testSuppressesSampleBelowThreshold() { - let filter = DeadbandFilter(threshold: 10.0) - _ = filter.process(Sample(timestamp: 1_000_000_000, value: 50.0)) - - let result = filter.process(Sample(timestamp: 2_000_000_000, value: 55.0)) - - XCTAssertTrue(result.isEmpty) - } - - func testEmitsAtExactThreshold() { - let filter = DeadbandFilter(threshold: 10.0) - _ = filter.process(Sample(timestamp: 1_000_000_000, value: 50.0)) - - let result = filter.process(Sample(timestamp: 2_000_000_000, value: 60.0)) - - XCTAssertEqual(result.count, 1) - XCTAssertEqual(result[0].value, 60.0) - } - - func testEmitsOnNegativeDelta() { - let filter = DeadbandFilter(threshold: 10.0) - _ = filter.process(Sample(timestamp: 1_000_000_000, value: 50.0)) - - let result = filter.process(Sample(timestamp: 2_000_000_000, value: 35.0)) - - XCTAssertEqual(result.count, 1) - XCTAssertEqual(result[0].value, 35.0) - } - - func testReferencesLastEmittedNotLastSeen() { - let filter = DeadbandFilter(threshold: 10.0) - // Emit at 50 - _ = filter.process(Sample(timestamp: 1_000_000_000, value: 50.0)) - // Suppress 54 (diff from 50 = 4) - let suppress1 = filter.process(Sample(timestamp: 2_000_000_000, value: 54.0)) - // Suppress 58 (diff from 50 = 8, not from 54) - let suppress2 = filter.process(Sample(timestamp: 3_000_000_000, value: 58.0)) - // Emit 61 (diff from 50 = 11) - let emit = filter.process(Sample(timestamp: 4_000_000_000, value: 61.0)) - - XCTAssertTrue(suppress1.isEmpty) - XCTAssertTrue(suppress2.isEmpty) - XCTAssertEqual(emit.count, 1) - XCTAssertEqual(emit[0].value, 61.0) - } - - func testHeartbeatFiresAfterSilenceInterval() { - let heartbeat: Int64 = 5_000_000_000 // 5s - let filter = DeadbandFilter(threshold: 10.0, heartbeatInterval: heartbeat) - // Emit first sample at t=0 - _ = filter.process(Sample(timestamp: 0, value: 50.0)) - // Suppress at t=3s (value barely moved, not past heartbeat) - let suppress = filter.process(Sample(timestamp: 3_000_000_000, value: 51.0)) - // Emit at t=6s (heartbeat due, even though value barely moved) - let emit = filter.process(Sample(timestamp: 6_000_000_000, value: 52.0)) - - XCTAssertTrue(suppress.isEmpty) - XCTAssertEqual(emit.count, 1) - XCTAssertEqual(emit[0].value, 52.0) - } - - func testNoHeartbeatWithoutIntervalConfigured() { - let filter = DeadbandFilter(threshold: 10.0) - _ = filter.process(Sample(timestamp: 0, value: 50.0)) - - // 60s later, value barely moved — no heartbeat configured - let result = filter.process(Sample(timestamp: 60_000_000_000, value: 51.0)) - - XCTAssertTrue(result.isEmpty) - } - - func testFlushReturnsNothing() { - let filter = DeadbandFilter(threshold: 10.0) - _ = filter.process(Sample(timestamp: 1_000_000_000, value: 50.0)) - - let result = filter.flush() - - XCTAssertTrue(result.isEmpty) - } -} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift deleted file mode 100644 index 14e832a366..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import XCTest -@testable import DatadogTimeseries - -final class FilterComparisonTests: XCTestCase { - private let config = TimeseriesConfig( - applicationId: "test-app", - sessionId: "test-session", - sessionType: "user", - source: "ios", - service: nil, - version: nil - ) - - // MARK: - PassThroughFilter - - func testPassThroughEmitsAllSamples() throws { - let count = try totalDataPoints(filter: PassThroughFilter(), metric: .memoryUsage) - XCTAssertEqual(count, 60) - } - - func testPassThroughCPUEmitsAllSamples() throws { - let count = try totalDataPoints(filter: PassThroughFilter(), metric: .cpuUsage) - XCTAssertEqual(count, 60) - } - - // MARK: - DeadbandFilter - - func testDeadbandReducesMemorySamples() throws { - let count = try totalDataPoints(filter: DeadbandFilter(threshold: 1_000_000), metric: .memoryUsage) - XCTAssertLessThan(count, 60) - XCTAssertGreaterThanOrEqual(count, 1) - } - - func testDeadbandCapturesAllocationJumps() throws { - // First sample always emitted + allocation jumps of 1-2 MB each cross the 1 MB threshold - let count = try totalDataPoints(filter: DeadbandFilter(threshold: 1_000_000), metric: .memoryUsage) - XCTAssertGreaterThanOrEqual(count, 3) - } - - // MARK: - WindowAggregateFilter - - func testWindowAggregateReducesCPUSamples() throws { - // 60 samples at 1s intervals / 5s window = 12 windows - let count = try totalDataPoints( - filter: WindowAggregateFilter(windowDuration: 5_000_000_000, function: .max), - metric: .cpuUsage - ) - XCTAssertEqual(count, 12) - } - - func testWindowAggregateTimestampIsWindowStart() throws { - let csvURL = try XCTUnwrap( - Bundle.module.url(forResource: "input_realistic_60s", withExtension: "csv", subdirectory: "Fixtures") - ) - let csvContent = try String(contentsOf: csvURL) - - let filter = WindowAggregateFilter(windowDuration: 5_000_000_000, function: .max) - let provider = CSVDataProvider(csvContent: csvContent, metric: .cpuUsage) - let pipeline = TimeseriesPipeline( - provider: provider, - config: config, - metricName: .cpuUsage, - batchSize: 100, - filter: filter - ) - - let results = try pipeline.processAll() - let firstBatch = try XCTUnwrap(results.first) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: firstBatch) as? [String: Any]) - let timeseries = try XCTUnwrap(json["timeseries"] as? [String: Any]) - let data = try XCTUnwrap(timeseries["data"] as? [String: Any]) - let timestamps = try XCTUnwrap(data["timestamps"] as? [Int64]) - let firstTimestamp = try XCTUnwrap(timestamps.first) - - // First window starts at the first sample timestamp (nanoseconds) - XCTAssertEqual(firstTimestamp, 1_700_000_001_000_000_000) - } - - // MARK: - JSON validity across all filters - - func testAllFiltersProduceValidJSON() throws { - let filters: [SampleFilter] = [ - PassThroughFilter(), - DeadbandFilter(threshold: 1_000_000), - WindowAggregateFilter(windowDuration: 5_000_000_000, function: .max), - ] - - for filter in filters { - let csvURL = try XCTUnwrap( - Bundle.module.url(forResource: "input_realistic_60s", withExtension: "csv", subdirectory: "Fixtures") - ) - let csvContent = try String(contentsOf: csvURL) - let provider = CSVDataProvider(csvContent: csvContent, metric: .memoryUsage) - let pipeline = TimeseriesPipeline( - provider: provider, - config: config, - metricName: .memoryUsage, - batchSize: 100, - filter: filter - ) - - let results = try pipeline.processAll() - - for data in results { - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - XCTAssertEqual(json["type"] as? String, "timeseries") - XCTAssertNotNil(json["_dd"]) - let timeseries = try XCTUnwrap(json["timeseries"] as? [String: Any]) - let data = try XCTUnwrap(timeseries["data"] as? [String: Any]) - let timestamps = try XCTUnwrap(data["timestamps"] as? [Int64]) - XCTAssertFalse(timestamps.isEmpty) - } - } - } - - // MARK: - Regression guard - - func testPipelineDefaultIsPassThrough() throws { - // Creates pipeline WITHOUT passing filter arg — verifies default PassThroughFilter behaviour - let csvURL = try XCTUnwrap( - Bundle.module.url(forResource: "input_realistic_60s", withExtension: "csv", subdirectory: "Fixtures") - ) - let csvContent = try String(contentsOf: csvURL) - let provider = CSVDataProvider(csvContent: csvContent, metric: .memoryUsage) - let pipeline = TimeseriesPipeline( - provider: provider, - config: config, - metricName: .memoryUsage, - batchSize: 100 - ) - - let results = try pipeline.processAll() - let count = try dataPointCount(in: results) - XCTAssertEqual(count, 60) - } - - // MARK: - Helpers - - private func totalDataPoints(filter: SampleFilter, metric: TimeseriesName) throws -> Int { - let csvURL = try XCTUnwrap( - Bundle.module.url(forResource: "input_realistic_60s", withExtension: "csv", subdirectory: "Fixtures") - ) - let csvContent = try String(contentsOf: csvURL) - let provider = CSVDataProvider(csvContent: csvContent, metric: metric) - let pipeline = TimeseriesPipeline( - provider: provider, - config: config, - metricName: metric, - batchSize: 100, - filter: filter - ) - - let results = try pipeline.processAll() - return try dataPointCount(in: results) - } - - private func dataPointCount(in results: [Data]) throws -> Int { - var total = 0 - for data in results { - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - let timeseries = try XCTUnwrap(json["timeseries"] as? [String: Any]) - let data = try XCTUnwrap(timeseries["data"] as? [String: Any]) - let timestamps = try XCTUnwrap(data["timestamps"] as? [Int64]) - total += timestamps.count - } - return total - } -} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/PassThroughFilterTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/PassThroughFilterTests.swift deleted file mode 100644 index d883a72840..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/PassThroughFilterTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import XCTest -@testable import DatadogTimeseries - -final class PassThroughFilterTests: XCTestCase { - func testPassesEverySampleThrough() { - let filter = PassThroughFilter() - let sample = Sample(timestamp: 1_000_000_000, value: 42.0) - - let result = filter.process(sample) - - XCTAssertEqual(result.count, 1) - XCTAssertEqual(result[0].timestamp, sample.timestamp) - XCTAssertEqual(result[0].value, sample.value) - } - - func testPassesAllConsecutiveSamples() { - let filter = PassThroughFilter() - let samples = [ - Sample(timestamp: 1_000_000_000, value: 10.0), - Sample(timestamp: 2_000_000_000, value: 20.0), - Sample(timestamp: 3_000_000_000, value: 30.0), - ] - - let result = samples.flatMap { filter.process($0) } - - XCTAssertEqual(result.count, 3) - } - - func testFlushReturnsEmptyArray() { - let filter = PassThroughFilter() - - let result = filter.flush() - - XCTAssertTrue(result.isEmpty) - } -} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/WindowAggregateFilterTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/WindowAggregateFilterTests.swift deleted file mode 100644 index f9db7f2df5..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Filters/WindowAggregateFilterTests.swift +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import XCTest -@testable import DatadogTimeseries - -final class WindowAggregateFilterTests: XCTestCase { - private let windowDuration: Int64 = 3_000_000_000 // 3s - - func testDoesNotEmitUntilWindowCloses() { - let filter = WindowAggregateFilter(windowDuration: windowDuration) - let result1 = filter.process(Sample(timestamp: 0, value: 10.0)) - let result2 = filter.process(Sample(timestamp: 1_000_000_000, value: 20.0)) - - XCTAssertTrue(result1.isEmpty) - XCTAssertTrue(result2.isEmpty) - } - - func testEmitsWhenWindowCloses() { - let filter = WindowAggregateFilter(windowDuration: windowDuration) - _ = filter.process(Sample(timestamp: 0, value: 10.0)) - _ = filter.process(Sample(timestamp: 1_000_000_000, value: 20.0)) - // 3rd sample at 4s crosses the 3s boundary - let result = filter.process(Sample(timestamp: 4_000_000_000, value: 30.0)) - - XCTAssertEqual(result.count, 1) - } - - func testEmittedTimestampIsStartOfWindow() { - let filter = WindowAggregateFilter(windowDuration: windowDuration) - let firstTimestamp: Int64 = 1_000_000_000 - _ = filter.process(Sample(timestamp: firstTimestamp, value: 10.0)) - _ = filter.process(Sample(timestamp: 2_000_000_000, value: 20.0)) - let result = filter.process(Sample(timestamp: 5_000_000_000, value: 30.0)) - - XCTAssertEqual(result.count, 1) - XCTAssertEqual(result[0].timestamp, firstTimestamp) - } - - func testFlushEmitsPartialWindow() { - let filter = WindowAggregateFilter(windowDuration: windowDuration) - _ = filter.process(Sample(timestamp: 0, value: 10.0)) - _ = filter.process(Sample(timestamp: 1_000_000_000, value: 20.0)) - - let result = filter.flush() - - XCTAssertEqual(result.count, 1) - } - - func testFlushOnEmptyBufferReturnsNothing() { - let filter = WindowAggregateFilter(windowDuration: windowDuration) - - let result = filter.flush() - - XCTAssertTrue(result.isEmpty) - } - - func testMultipleWindowsEachEmitOnce() { - let filter = WindowAggregateFilter(windowDuration: windowDuration) - _ = filter.process(Sample(timestamp: 0, value: 10.0)) - // First window closes - let result1 = filter.process(Sample(timestamp: 3_000_000_000, value: 20.0)) - // Second window closes - let result2 = filter.process(Sample(timestamp: 6_000_000_000, value: 30.0)) - - XCTAssertEqual(result1.count, 1) - XCTAssertEqual(result2.count, 1) - } - - func testAvgAggregate() { - let filter = WindowAggregateFilter(windowDuration: windowDuration, function: .avg) - _ = filter.process(Sample(timestamp: 0, value: 10.0)) - _ = filter.process(Sample(timestamp: 1_000_000_000, value: 30.0)) - let result = filter.process(Sample(timestamp: 4_000_000_000, value: 0.0)) - - XCTAssertEqual(result.count, 1) - XCTAssertEqual(result[0].value, 20.0, accuracy: 0.001) - } - - func testMinAggregate() { - let filter = WindowAggregateFilter(windowDuration: windowDuration, function: .min) - _ = filter.process(Sample(timestamp: 0, value: 50.0)) - _ = filter.process(Sample(timestamp: 1_000_000_000, value: 20.0)) - _ = filter.process(Sample(timestamp: 2_000_000_000, value: 80.0)) - let result = filter.process(Sample(timestamp: 4_000_000_000, value: 0.0)) - - XCTAssertEqual(result.count, 1) - XCTAssertEqual(result[0].value, 20.0, accuracy: 0.001) - } - - func testMaxAggregate() { - let filter = WindowAggregateFilter(windowDuration: windowDuration, function: .max) - _ = filter.process(Sample(timestamp: 0, value: 50.0)) - _ = filter.process(Sample(timestamp: 1_000_000_000, value: 20.0)) - _ = filter.process(Sample(timestamp: 2_000_000_000, value: 80.0)) - let result = filter.process(Sample(timestamp: 4_000_000_000, value: 0.0)) - - XCTAssertEqual(result.count, 1) - XCTAssertEqual(result[0].value, 80.0, accuracy: 0.001) - } -} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json deleted file mode 100644 index 9263d5bef3..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json +++ /dev/null @@ -1 +0,0 @@ -{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000001000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":{"timestamps":[1700000001000000000,1700000002000000000,1700000003000000000,1700000004000000000,1700000005000000000],"values":{"cpu_usage":[12.5,14.2,11.8,16.3,13.7]}},"end":1700000005000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","schema":"object-v2","start":1700000001000000000},"type":"timeseries"} \ No newline at end of file diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json deleted file mode 100644 index c581f5a2a3..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json +++ /dev/null @@ -1 +0,0 @@ -{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000006000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":{"timestamps":[1700000006000000000,1700000007000000000,1700000008000000000,1700000009000000000,1700000010000000000],"values":{"cpu_usage":[18.1,22.4,19.6,15.9,13.2]}},"end":1700000010000000000,"id":"00000000-0000-0000-0000-000000000000","name":"cpu_usage","schema":"object-v2","start":1700000006000000000},"type":"timeseries"} \ No newline at end of file diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json deleted file mode 100644 index 44e45a56fc..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json +++ /dev/null @@ -1 +0,0 @@ -{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000001000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":{"timestamps":[1700000001000000000,1700000002000000000,1700000003000000000,1700000004000000000,1700000005000000000],"values":{"memory_usage":[31233300,31245500,31300800,31289100,31350000]}},"end":1700000005000000000,"id":"00000000-0000-0000-0000-000000000000","name":"memory_usage","schema":"object-v2","start":1700000001000000000},"type":"timeseries"} \ No newline at end of file diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json deleted file mode 100644 index 156a9d3909..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json +++ /dev/null @@ -1 +0,0 @@ -{"_dd":{"format_version":2},"application":{"id":"00000000-0000-0000-0000-000000000000"},"date":1700000006000,"session":{"id":"00000000-0000-0000-0000-000000000000","type":"user"},"source":"ios","timeseries":{"data":{"timestamps":[1700000006000000000,1700000007000000000,1700000008000000000,1700000009000000000,1700000010000000000],"values":{"memory_usage":[31420200,31510400,31498700,31550300,31600100]}},"end":1700000010000000000,"id":"00000000-0000-0000-0000-000000000000","name":"memory_usage","schema":"object-v2","start":1700000006000000000},"type":"timeseries"} \ No newline at end of file diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_memory_cpu.csv b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_memory_cpu.csv deleted file mode 100644 index 992dead619..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_memory_cpu.csv +++ /dev/null @@ -1,21 +0,0 @@ -timestamp,metric,value -1700000001000000000,memory_usage,31233300 -1700000001000000000,cpu_usage,12.5 -1700000002000000000,memory_usage,31245500 -1700000002000000000,cpu_usage,14.2 -1700000003000000000,memory_usage,31300800 -1700000003000000000,cpu_usage,11.8 -1700000004000000000,memory_usage,31289100 -1700000004000000000,cpu_usage,16.3 -1700000005000000000,memory_usage,31350000 -1700000005000000000,cpu_usage,13.7 -1700000006000000000,memory_usage,31420200 -1700000006000000000,cpu_usage,18.1 -1700000007000000000,memory_usage,31510400 -1700000007000000000,cpu_usage,22.4 -1700000008000000000,memory_usage,31498700 -1700000008000000000,cpu_usage,19.6 -1700000009000000000,memory_usage,31550300 -1700000009000000000,cpu_usage,15.9 -1700000010000000000,memory_usage,31600100 -1700000010000000000,cpu_usage,13.2 diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv b/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv deleted file mode 100644 index bdfed3994b..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv +++ /dev/null @@ -1,121 +0,0 @@ -timestamp,metric,value -1700000001000000000,memory_usage,31000500 -1700000001000000000,cpu_usage,5.0 -1700000002000000000,memory_usage,31001300 -1700000002000000000,cpu_usage,7.5 -1700000003000000000,memory_usage,31002500 -1700000003000000000,cpu_usage,6.0 -1700000004000000000,memory_usage,31004000 -1700000004000000000,cpu_usage,8.5 -1700000005000000000,memory_usage,31004700 -1700000005000000000,cpu_usage,5.5 -1700000006000000000,memory_usage,31005700 -1700000006000000000,cpu_usage,9.0 -1700000007000000000,memory_usage,31007700 -1700000007000000000,cpu_usage,5.0 -1700000008000000000,memory_usage,31008300 -1700000008000000000,cpu_usage,7.5 -1700000009000000000,memory_usage,31008800 -1700000009000000000,cpu_usage,6.0 -1700000010000000000,memory_usage,31009600 -1700000010000000000,cpu_usage,8.5 -1700000011000000000,memory_usage,31010800 -1700000011000000000,cpu_usage,5.5 -1700000012000000000,memory_usage,32512300 -1700000012000000000,cpu_usage,9.0 -1700000013000000000,memory_usage,32513000 -1700000013000000000,cpu_usage,5.0 -1700000014000000000,memory_usage,32514000 -1700000014000000000,cpu_usage,7.5 -1700000015000000000,memory_usage,32516000 -1700000015000000000,cpu_usage,65.0 -1700000016000000000,memory_usage,32516600 -1700000016000000000,cpu_usage,72.0 -1700000017000000000,memory_usage,32517100 -1700000017000000000,cpu_usage,58.0 -1700000018000000000,memory_usage,32517900 -1700000018000000000,cpu_usage,6.0 -1700000019000000000,memory_usage,32519100 -1700000019000000000,cpu_usage,8.5 -1700000020000000000,memory_usage,32520600 -1700000020000000000,cpu_usage,5.5 -1700000021000000000,memory_usage,32521300 -1700000021000000000,cpu_usage,9.0 -1700000022000000000,memory_usage,32522300 -1700000022000000000,cpu_usage,5.0 -1700000023000000000,memory_usage,32524300 -1700000023000000000,cpu_usage,7.5 -1700000024000000000,memory_usage,32524900 -1700000024000000000,cpu_usage,6.0 -1700000025000000000,memory_usage,32525400 -1700000025000000000,cpu_usage,8.5 -1700000026000000000,memory_usage,32526200 -1700000026000000000,cpu_usage,5.5 -1700000027000000000,memory_usage,32527400 -1700000027000000000,cpu_usage,9.0 -1700000028000000000,memory_usage,32528900 -1700000028000000000,cpu_usage,5.0 -1700000029000000000,memory_usage,32529600 -1700000029000000000,cpu_usage,7.5 -1700000030000000000,memory_usage,34530600 -1700000030000000000,cpu_usage,6.0 -1700000031000000000,memory_usage,34532600 -1700000031000000000,cpu_usage,8.5 -1700000032000000000,memory_usage,34533200 -1700000032000000000,cpu_usage,5.5 -1700000033000000000,memory_usage,34533700 -1700000033000000000,cpu_usage,9.0 -1700000034000000000,memory_usage,34534500 -1700000034000000000,cpu_usage,5.0 -1700000035000000000,memory_usage,34535700 -1700000035000000000,cpu_usage,80.0 -1700000036000000000,memory_usage,34537200 -1700000036000000000,cpu_usage,75.0 -1700000037000000000,memory_usage,34537900 -1700000037000000000,cpu_usage,68.0 -1700000038000000000,memory_usage,34538900 -1700000038000000000,cpu_usage,7.5 -1700000039000000000,memory_usage,34540900 -1700000039000000000,cpu_usage,6.0 -1700000040000000000,memory_usage,33741500 -1700000040000000000,cpu_usage,8.5 -1700000041000000000,memory_usage,33742000 -1700000041000000000,cpu_usage,5.5 -1700000042000000000,memory_usage,33742800 -1700000042000000000,cpu_usage,9.0 -1700000043000000000,memory_usage,33744000 -1700000043000000000,cpu_usage,5.0 -1700000044000000000,memory_usage,33745500 -1700000044000000000,cpu_usage,7.5 -1700000045000000000,memory_usage,33746200 -1700000045000000000,cpu_usage,6.0 -1700000046000000000,memory_usage,33747200 -1700000046000000000,cpu_usage,8.5 -1700000047000000000,memory_usage,33749200 -1700000047000000000,cpu_usage,5.5 -1700000048000000000,memory_usage,33749800 -1700000048000000000,cpu_usage,9.0 -1700000049000000000,memory_usage,33750300 -1700000049000000000,cpu_usage,5.0 -1700000050000000000,memory_usage,34751100 -1700000050000000000,cpu_usage,7.5 -1700000051000000000,memory_usage,34752300 -1700000051000000000,cpu_usage,6.0 -1700000052000000000,memory_usage,34753800 -1700000052000000000,cpu_usage,8.5 -1700000053000000000,memory_usage,34754500 -1700000053000000000,cpu_usage,5.5 -1700000054000000000,memory_usage,34755500 -1700000054000000000,cpu_usage,9.0 -1700000055000000000,memory_usage,34757500 -1700000055000000000,cpu_usage,55.0 -1700000056000000000,memory_usage,34758100 -1700000056000000000,cpu_usage,62.0 -1700000057000000000,memory_usage,34758600 -1700000057000000000,cpu_usage,50.0 -1700000058000000000,memory_usage,34759400 -1700000058000000000,cpu_usage,5.0 -1700000059000000000,memory_usage,34760600 -1700000059000000000,cpu_usage,7.5 -1700000060000000000,memory_usage,34762100 -1700000060000000000,cpu_usage,6.0 diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift deleted file mode 100644 index 5261d1380c..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/SkipSampleTests.swift +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import XCTest -@testable import DatadogTimeseries - -final class SkipSampleTests: XCTestCase { - private let config = TimeseriesConfig( - applicationId: "app-id", - sessionId: "session-id", - sessionType: "user", - source: "ios", - service: nil, - version: nil - ) - - func testGapInCSVProducesFewerDataPoints() throws { - // 5 rows but only 3 are memory_usage (gap at timestamps 2 and 4) - let csv = """ - timestamp,metric,value - 1000000000,memory_usage,100 - 2000000000,cpu_usage,10 - 3000000000,memory_usage,300 - 4000000000,cpu_usage,20 - 5000000000,memory_usage,500 - """ - - let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) - let pipeline = TimeseriesPipeline( - provider: provider, - config: config, - metricName: .memoryUsage, - batchSize: 5 - ) - - let results = try pipeline.processAll() - XCTAssertEqual(results.count, 1) // 3 samples < batchSize 5 → 1 remaining batch - - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: results[0]) as? [String: Any]) - let ts = try XCTUnwrap(json["timeseries"] as? [String: Any]) - let data = try XCTUnwrap(ts["data"] as? [String: Any]) - let timestamps = try XCTUnwrap(data["timestamps"] as? [Int64]) - - XCTAssertEqual(timestamps.count, 3, "Only 3 memory_usage samples, gap reflected") - XCTAssertEqual(timestamps[0], 1000000000) - XCTAssertEqual(timestamps[1], 3000000000) // gap: 2s jumped - XCTAssertEqual(timestamps[2], 5000000000) - } - - func testMalformedRowsSkipped() throws { - let csv = """ - timestamp,metric,value - 1000000000,memory_usage,100 - not_a_number,memory_usage,bad - 3000000000,memory_usage,300 - """ - - let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) - let pipeline = TimeseriesPipeline( - provider: provider, - config: config, - metricName: .memoryUsage, - batchSize: 5 - ) - - let results = try pipeline.processAll() - XCTAssertEqual(results.count, 1) - - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: results[0]) as? [String: Any]) - let ts = try XCTUnwrap(json["timeseries"] as? [String: Any]) - let data = try XCTUnwrap(ts["data"] as? [String: Any]) - let timestamps = try XCTUnwrap(data["timestamps"] as? [Int64]) - - XCTAssertEqual(timestamps.count, 2, "Malformed row skipped") - } - - func testTimestampsReflectGap() throws { - let csv = """ - timestamp,metric,value - 1000000000,memory_usage,100 - 5000000000,memory_usage,500 - """ - - let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) - let pipeline = TimeseriesPipeline( - provider: provider, - config: config, - metricName: .memoryUsage, - batchSize: 5 - ) - - let results = try pipeline.processAll() - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: results[0]) as? [String: Any]) - let ts = try XCTUnwrap(json["timeseries"] as? [String: Any]) - - XCTAssertEqual(ts["start"] as? Int64, 1000000000) - XCTAssertEqual(ts["end"] as? Int64, 5000000000) - } -} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesBatcherTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesBatcherTests.swift deleted file mode 100644 index 3fade2ec24..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesBatcherTests.swift +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import XCTest -@testable import DatadogTimeseries - -final class TimeseriesBatcherTests: XCTestCase { - func testDoesNotFlushBeforeBatchSize() { - let batcher = TimeseriesBatcher(batchSize: 3) - batcher.add(Sample(timestamp: 1, value: 10)) - batcher.add(Sample(timestamp: 2, value: 20)) - - XCTAssertFalse(batcher.shouldFlush()) - } - - func testFlushesAtBatchSize() { - let batcher = TimeseriesBatcher(batchSize: 3) - batcher.add(Sample(timestamp: 1, value: 10)) - batcher.add(Sample(timestamp: 2, value: 20)) - batcher.add(Sample(timestamp: 3, value: 30)) - - XCTAssertTrue(batcher.shouldFlush()) - - let batch = batcher.flush() - XCTAssertEqual(batch.count, 3) - XCTAssertEqual(batch[0].timestamp, 1) - XCTAssertEqual(batch[1].timestamp, 2) - XCTAssertEqual(batch[2].timestamp, 3) - } - - func testFlushClearsBuffer() { - let batcher = TimeseriesBatcher(batchSize: 2) - batcher.add(Sample(timestamp: 1, value: 10)) - batcher.add(Sample(timestamp: 2, value: 20)) - - _ = batcher.flush() - - XCTAssertFalse(batcher.shouldFlush()) - XCTAssertTrue(batcher.flush().isEmpty) - } - - func testFlushRemainingReturnsSamples() { - let batcher = TimeseriesBatcher(batchSize: 5) - batcher.add(Sample(timestamp: 1, value: 10)) - batcher.add(Sample(timestamp: 2, value: 20)) - - let remaining = batcher.flushRemaining() - XCTAssertNotNil(remaining) - XCTAssertEqual(remaining?.count, 2) - } - - func testFlushRemainingReturnsNilWhenEmpty() { - let batcher = TimeseriesBatcher(batchSize: 5) - XCTAssertNil(batcher.flushRemaining()) - } - - func testMultipleBatches() { - let batcher = TimeseriesBatcher(batchSize: 2) - batcher.add(Sample(timestamp: 1, value: 10)) - batcher.add(Sample(timestamp: 2, value: 20)) - - XCTAssertTrue(batcher.shouldFlush()) - let batch1 = batcher.flush() - XCTAssertEqual(batch1.count, 2) - - batcher.add(Sample(timestamp: 3, value: 30)) - batcher.add(Sample(timestamp: 4, value: 40)) - - XCTAssertTrue(batcher.shouldFlush()) - let batch2 = batcher.flush() - XCTAssertEqual(batch2.count, 2) - XCTAssertEqual(batch2[0].timestamp, 3) - } -} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift deleted file mode 100644 index 5474603763..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEncoderTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import XCTest -@testable import DatadogTimeseries - -final class TimeseriesEncoderTests: XCTestCase { - func testProducesSortedKeys() throws { - let event = makeSimpleEvent() - let encoder = TimeseriesEncoder() - let data = try encoder.encode(event) - let json = String(data: data, encoding: .utf8)! - - // _dd should come before application (underscore sorts first in ASCII) - let ddRange = json.range(of: "\"_dd\"")! - let appRange = json.range(of: "\"application\"")! - XCTAssertTrue(ddRange.lowerBound < appRange.lowerBound, "Keys should be sorted") - } - - func testProducesSnakeCaseKeys() throws { - let event = makeSimpleEvent() - let encoder = TimeseriesEncoder() - let data = try encoder.encode(event) - let json = String(data: data, encoding: .utf8)! - - XCTAssertTrue(json.contains("\"format_version\"")) - XCTAssertTrue(json.contains("\"timestamps\"")) - XCTAssertFalse(json.contains("\"formatVersion\"")) - } - - func testProducesValidJSON() throws { - let event = makeSimpleEvent() - let encoder = TimeseriesEncoder() - let data = try encoder.encode(event) - - let parsed = try JSONSerialization.jsonObject(with: data) - XCTAssertTrue(parsed is [String: Any]) - } - - func testDeterministicOutput() throws { - let event = makeSimpleEvent() - let encoder = TimeseriesEncoder() - let data1 = try encoder.encode(event) - let data2 = try encoder.encode(event) - - XCTAssertEqual(data1, data2, "Encoding the same event should produce identical bytes") - } - - // MARK: - Helpers - - private func makeSimpleEvent() -> TimeseriesEvent { - TimeseriesEvent( - dd: TimeseriesEvent.DD(formatVersion: 2), - application: TimeseriesEvent.Application(id: "app"), - date: 1000, - session: TimeseriesEvent.Session(id: "sess", type: "user"), - source: "ios", - type: "timeseries", - service: nil, - version: nil, - timeseries: TimeseriesEvent.Timeseries( - id: "ts-id", - name: .memoryUsage, - start: 1_000_000_000, - end: 2_000_000_000, - data: TimeseriesEvent.Data( - timestamps: [1_000_000_000], - values: ["memory_usage": [42]] - ) - ) - ) - } -} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift deleted file mode 100644 index 6deb2d19e8..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventBuilderTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import XCTest -@testable import DatadogTimeseries - -final class TimeseriesEventBuilderTests: XCTestCase { - private let config = TimeseriesConfig( - applicationId: "app-123", - sessionId: "session-456", - sessionType: "user", - source: "ios", - service: "test-service", - version: "2.0.0" - ) - - func testBuildsEventWithCorrectEnvelope() { - let builder = TimeseriesEventBuilder(config: config) - let samples = [ - Sample(timestamp: 5_000_000_000, value: 100), - Sample(timestamp: 6_000_000_000, value: 200), - ] - - let event = builder.build(samples: samples, name: .memoryUsage, eventId: "evt-id") - - XCTAssertEqual(event.dd.formatVersion, 2) - XCTAssertEqual(event.application.id, "app-123") - XCTAssertEqual(event.session.id, "session-456") - XCTAssertEqual(event.session.type, "user") - XCTAssertEqual(event.source, "ios") - XCTAssertEqual(event.type, "timeseries") - XCTAssertEqual(event.service, "test-service") - XCTAssertEqual(event.version, "2.0.0") - } - - func testBuildsEventWithCorrectTimeseries() { - let builder = TimeseriesEventBuilder(config: config) - let samples = [ - Sample(timestamp: 5_000_000_000, value: 100), - Sample(timestamp: 6_000_000_000, value: 200), - Sample(timestamp: 7_000_000_000, value: 300), - ] - - let event = builder.build(samples: samples, name: .cpuUsage, eventId: "my-uuid") - - XCTAssertEqual(event.timeseries.id, "my-uuid") - XCTAssertEqual(event.timeseries.name, .cpuUsage) - XCTAssertEqual(event.timeseries.start, 5_000_000_000) - XCTAssertEqual(event.timeseries.end, 7_000_000_000) - XCTAssertEqual(event.timeseries.data.timestamps.count, 3) - } - - func testDateIsStartTimestampConvertedToMilliseconds() { - let builder = TimeseriesEventBuilder(config: config) - let samples = [ - Sample(timestamp: 1_773_055_068_831_000_000, value: 42), - ] - - let event = builder.build(samples: samples, name: .memoryUsage, eventId: "id") - - // 1_773_055_068_831_000_000 ns / 1_000_000 = 1_773_055_068_831 ms - XCTAssertEqual(event.date, 1_773_055_068_831) - } - - func testDataPointsMatchSamples() { - let builder = TimeseriesEventBuilder(config: config) - let samples = [ - Sample(timestamp: 1000, value: 42.5), - Sample(timestamp: 2000, value: 99.9), - ] - - let event = builder.build(samples: samples, name: .memoryUsage, eventId: "id") - - XCTAssertEqual(event.timeseries.data.timestamps, [1000, 2000]) - XCTAssertEqual(event.timeseries.data.values["memory_usage"], [42.5, 99.9]) - } - - func testNilServiceAndVersion() { - let configNoOptionals = TimeseriesConfig( - applicationId: "app", - sessionId: "sess", - sessionType: "user", - source: "ios", - service: nil, - version: nil - ) - let builder = TimeseriesEventBuilder(config: configNoOptionals) - let event = builder.build( - samples: [Sample(timestamp: 1, value: 1)], - name: .cpuUsage, - eventId: "id" - ) - - XCTAssertNil(event.service) - XCTAssertNil(event.version) - } -} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift deleted file mode 100644 index 0c65c0492f..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesEventModelTests.swift +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import XCTest -@testable import DatadogTimeseries - -final class TimeseriesEventModelTests: XCTestCase { - func testTimeseriesEventEncodesToExpectedJSON() throws { - let event = TimeseriesEvent( - dd: TimeseriesEvent.DD(formatVersion: 2), - application: TimeseriesEvent.Application(id: "app-id-123"), - date: 1773055119487, - session: TimeseriesEvent.Session(id: "session-id-456", type: "user"), - source: "ios", - type: "timeseries", - service: "my-service", - version: "1.0.0", - timeseries: TimeseriesEvent.Timeseries( - id: "ts-id-789", - name: .memoryUsage, - start: 1773055068831000000, - end: 1773055082916000000, - data: TimeseriesEvent.Data( - timestamps: [1773055068831000000, 1773055069917000000], - values: ["memory_usage": [38052032, 37970112]] - ) - ) - ) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(event) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - - // Root-level fields - let dd = try XCTUnwrap(json["_dd"] as? [String: Any]) - XCTAssertEqual(dd["format_version"] as? Int, 2) - - let application = try XCTUnwrap(json["application"] as? [String: Any]) - XCTAssertEqual(application["id"] as? String, "app-id-123") - - XCTAssertEqual(json["date"] as? Int64, 1773055119487) - - let session = try XCTUnwrap(json["session"] as? [String: Any]) - XCTAssertEqual(session["id"] as? String, "session-id-456") - XCTAssertEqual(session["type"] as? String, "user") - - XCTAssertEqual(json["source"] as? String, "ios") - XCTAssertEqual(json["type"] as? String, "timeseries") - XCTAssertEqual(json["service"] as? String, "my-service") - XCTAssertEqual(json["version"] as? String, "1.0.0") - - // Timeseries nested object - let ts = try XCTUnwrap(json["timeseries"] as? [String: Any]) - XCTAssertEqual(ts["id"] as? String, "ts-id-789") - XCTAssertEqual(ts["name"] as? String, "memory_usage") - XCTAssertEqual(ts["schema"] as? String, "object-v2") - XCTAssertEqual(ts["start"] as? Int64, 1773055068831000000) - XCTAssertEqual(ts["end"] as? Int64, 1773055082916000000) - - let tsData = try XCTUnwrap(ts["data"] as? [String: Any]) - let timestamps = try XCTUnwrap(tsData["timestamps"] as? [Int64]) - XCTAssertEqual(timestamps, [1773055068831000000, 1773055069917000000]) - let values = try XCTUnwrap(tsData["values"] as? [String: Any]) - let memoryUsage = try XCTUnwrap(values["memory_usage"] as? [Double]) - XCTAssertEqual(memoryUsage, [38052032, 37970112]) - } - - func testTimeseriesEventOmitsNilServiceAndVersion() throws { - let event = TimeseriesEvent( - dd: TimeseriesEvent.DD(formatVersion: 2), - application: TimeseriesEvent.Application(id: "app-id"), - date: 1000, - session: TimeseriesEvent.Session(id: "sess-id", type: "user"), - source: "ios", - type: "timeseries", - service: nil, - version: nil, - timeseries: TimeseriesEvent.Timeseries( - id: "ts-id", - name: .cpuUsage, - start: 1000000000, - end: 2000000000, - data: TimeseriesEvent.Data( - timestamps: [1000000000], - values: ["cpu_usage": [55.3]] - ) - ) - ) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(event) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - - XCTAssertNil(json["service"]) - XCTAssertNil(json["version"]) - XCTAssertEqual(json["type"] as? String, "timeseries") - - let ts = try XCTUnwrap(json["timeseries"] as? [String: Any]) - XCTAssertEqual(ts["name"] as? String, "cpu_usage") - } - - func testTimeseriesNameRawValues() { - XCTAssertEqual(TimeseriesName.memoryUsage.rawValue, "memory_usage") - XCTAssertEqual(TimeseriesName.cpuUsage.rawValue, "cpu_usage") - } - - func testSampleStoresValues() { - let sample = Sample(timestamp: 5_000_000_000, value: 123.456) - XCTAssertEqual(sample.timestamp, 5_000_000_000) - XCTAssertEqual(sample.value, 123.456) - } -} diff --git a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift b/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift deleted file mode 100644 index a500a8792f..0000000000 --- a/DatadogTimeseries/Tests/DatadogTimeseriesTests/TimeseriesPipelineTests.swift +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ -import XCTest -@testable import DatadogTimeseries - -final class TimeseriesPipelineTests: XCTestCase { - private let config = TimeseriesConfig( - applicationId: "app-id", - sessionId: "session-id", - sessionType: "user", - source: "ios", - service: nil, - version: nil - ) - - func testProcessAllProducesCorrectNumberOfBatches() throws { - let csv = """ - timestamp,metric,value - 1000000000,memory_usage,100 - 2000000000,memory_usage,200 - 3000000000,memory_usage,300 - 4000000000,memory_usage,400 - 5000000000,memory_usage,500 - 6000000000,memory_usage,600 - 7000000000,memory_usage,700 - """ - - let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) - let pipeline = TimeseriesPipeline( - provider: provider, - config: config, - metricName: .memoryUsage, - batchSize: 3 - ) - - let results = try pipeline.processAll() - // 7 samples / 3 batch size = 2 full batches + 1 remaining batch (1 sample) - XCTAssertEqual(results.count, 3) - } - - func testProcessAllProducesValidJSON() throws { - let csv = """ - timestamp,metric,value - 1000000000,memory_usage,100 - 2000000000,memory_usage,200 - """ - - let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) - let pipeline = TimeseriesPipeline( - provider: provider, - config: config, - metricName: .memoryUsage, - batchSize: 5 - ) - - let results = try pipeline.processAll() - // 2 samples < batchSize 5 → 1 remaining batch - XCTAssertEqual(results.count, 1) - - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: results[0]) as? [String: Any]) - XCTAssertEqual(json["type"] as? String, "timeseries") - } - - func testEmptyProviderProducesNoOutput() throws { - let csv = "timestamp,metric,value\n" - let provider = CSVDataProvider(csvContent: csv, metric: .memoryUsage) - let pipeline = TimeseriesPipeline( - provider: provider, - config: config, - metricName: .memoryUsage, - batchSize: 5 - ) - - let results = try pipeline.processAll() - XCTAssertTrue(results.isEmpty) - } -} diff --git a/DatadogTimeseries/output/deadband_cpu.ndjson b/DatadogTimeseries/output/deadband_cpu.ndjson deleted file mode 100644 index d700cb29b5..0000000000 --- a/DatadogTimeseries/output/deadband_cpu.ndjson +++ /dev/null @@ -1 +0,0 @@ -{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000001000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":5,"timestamp":1700000001000000000},{"data_point_value":8.5,"timestamp":1700000031000000000}],"end":1700000031000000000,"id":"42e7e1d8-4418-41c0-a500-1cdc32619cc0","name":"cpu_usage","start":1700000001000000000},"type":"timeseries"} diff --git a/DatadogTimeseries/output/deadband_memory.ndjson b/DatadogTimeseries/output/deadband_memory.ndjson deleted file mode 100644 index ed50efc1ad..0000000000 --- a/DatadogTimeseries/output/deadband_memory.ndjson +++ /dev/null @@ -1 +0,0 @@ -{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000001000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":31000500,"timestamp":1700000001000000000},{"data_point_value":32512300,"timestamp":1700000012000000000},{"data_point_value":34530600,"timestamp":1700000030000000000},{"data_point_value":34762100,"timestamp":1700000060000000000}],"end":1700000060000000000,"id":"952c53c4-bcaa-40a8-8d33-2fe8ee6a0582","name":"memory_usage","start":1700000001000000000},"type":"timeseries"} diff --git a/DatadogTimeseries/output/passthrough_cpu.ndjson b/DatadogTimeseries/output/passthrough_cpu.ndjson deleted file mode 100644 index f404d1148d..0000000000 --- a/DatadogTimeseries/output/passthrough_cpu.ndjson +++ /dev/null @@ -1,2 +0,0 @@ -{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000001000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":5,"timestamp":1700000001000000000},{"data_point_value":7.5,"timestamp":1700000002000000000},{"data_point_value":6,"timestamp":1700000003000000000},{"data_point_value":8.5,"timestamp":1700000004000000000},{"data_point_value":5.5,"timestamp":1700000005000000000},{"data_point_value":9,"timestamp":1700000006000000000},{"data_point_value":5,"timestamp":1700000007000000000},{"data_point_value":7.5,"timestamp":1700000008000000000},{"data_point_value":6,"timestamp":1700000009000000000},{"data_point_value":8.5,"timestamp":1700000010000000000},{"data_point_value":5.5,"timestamp":1700000011000000000},{"data_point_value":9,"timestamp":1700000012000000000},{"data_point_value":5,"timestamp":1700000013000000000},{"data_point_value":7.5,"timestamp":1700000014000000000},{"data_point_value":65,"timestamp":1700000015000000000},{"data_point_value":72,"timestamp":1700000016000000000},{"data_point_value":58,"timestamp":1700000017000000000},{"data_point_value":6,"timestamp":1700000018000000000},{"data_point_value":8.5,"timestamp":1700000019000000000},{"data_point_value":5.5,"timestamp":1700000020000000000},{"data_point_value":9,"timestamp":1700000021000000000},{"data_point_value":5,"timestamp":1700000022000000000},{"data_point_value":7.5,"timestamp":1700000023000000000},{"data_point_value":6,"timestamp":1700000024000000000},{"data_point_value":8.5,"timestamp":1700000025000000000},{"data_point_value":5.5,"timestamp":1700000026000000000},{"data_point_value":9,"timestamp":1700000027000000000},{"data_point_value":5,"timestamp":1700000028000000000},{"data_point_value":7.5,"timestamp":1700000029000000000},{"data_point_value":6,"timestamp":1700000030000000000}],"end":1700000030000000000,"id":"7161f959-327e-4c39-a990-c585462a0610","name":"cpu_usage","start":1700000001000000000},"type":"timeseries"} -{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000031000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":8.5,"timestamp":1700000031000000000},{"data_point_value":5.5,"timestamp":1700000032000000000},{"data_point_value":9,"timestamp":1700000033000000000},{"data_point_value":5,"timestamp":1700000034000000000},{"data_point_value":80,"timestamp":1700000035000000000},{"data_point_value":75,"timestamp":1700000036000000000},{"data_point_value":68,"timestamp":1700000037000000000},{"data_point_value":7.5,"timestamp":1700000038000000000},{"data_point_value":6,"timestamp":1700000039000000000},{"data_point_value":8.5,"timestamp":1700000040000000000},{"data_point_value":5.5,"timestamp":1700000041000000000},{"data_point_value":9,"timestamp":1700000042000000000},{"data_point_value":5,"timestamp":1700000043000000000},{"data_point_value":7.5,"timestamp":1700000044000000000},{"data_point_value":6,"timestamp":1700000045000000000},{"data_point_value":8.5,"timestamp":1700000046000000000},{"data_point_value":5.5,"timestamp":1700000047000000000},{"data_point_value":9,"timestamp":1700000048000000000},{"data_point_value":5,"timestamp":1700000049000000000},{"data_point_value":7.5,"timestamp":1700000050000000000},{"data_point_value":6,"timestamp":1700000051000000000},{"data_point_value":8.5,"timestamp":1700000052000000000},{"data_point_value":5.5,"timestamp":1700000053000000000},{"data_point_value":9,"timestamp":1700000054000000000},{"data_point_value":55,"timestamp":1700000055000000000},{"data_point_value":62,"timestamp":1700000056000000000},{"data_point_value":50,"timestamp":1700000057000000000},{"data_point_value":5,"timestamp":1700000058000000000},{"data_point_value":7.5,"timestamp":1700000059000000000},{"data_point_value":6,"timestamp":1700000060000000000}],"end":1700000060000000000,"id":"8dcbe4cf-3278-41d3-91c4-f682f7e7bdd6","name":"cpu_usage","start":1700000031000000000},"type":"timeseries"} diff --git a/DatadogTimeseries/output/passthrough_memory.ndjson b/DatadogTimeseries/output/passthrough_memory.ndjson deleted file mode 100644 index 1dcbf1d6a9..0000000000 --- a/DatadogTimeseries/output/passthrough_memory.ndjson +++ /dev/null @@ -1,2 +0,0 @@ -{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000001000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":31000500,"timestamp":1700000001000000000},{"data_point_value":31001300,"timestamp":1700000002000000000},{"data_point_value":31002500,"timestamp":1700000003000000000},{"data_point_value":31004000,"timestamp":1700000004000000000},{"data_point_value":31004700,"timestamp":1700000005000000000},{"data_point_value":31005700,"timestamp":1700000006000000000},{"data_point_value":31007700,"timestamp":1700000007000000000},{"data_point_value":31008300,"timestamp":1700000008000000000},{"data_point_value":31008800,"timestamp":1700000009000000000},{"data_point_value":31009600,"timestamp":1700000010000000000},{"data_point_value":31010800,"timestamp":1700000011000000000},{"data_point_value":32512300,"timestamp":1700000012000000000},{"data_point_value":32513000,"timestamp":1700000013000000000},{"data_point_value":32514000,"timestamp":1700000014000000000},{"data_point_value":32516000,"timestamp":1700000015000000000},{"data_point_value":32516600,"timestamp":1700000016000000000},{"data_point_value":32517100,"timestamp":1700000017000000000},{"data_point_value":32517900,"timestamp":1700000018000000000},{"data_point_value":32519100,"timestamp":1700000019000000000},{"data_point_value":32520600,"timestamp":1700000020000000000},{"data_point_value":32521300,"timestamp":1700000021000000000},{"data_point_value":32522300,"timestamp":1700000022000000000},{"data_point_value":32524300,"timestamp":1700000023000000000},{"data_point_value":32524900,"timestamp":1700000024000000000},{"data_point_value":32525400,"timestamp":1700000025000000000},{"data_point_value":32526200,"timestamp":1700000026000000000},{"data_point_value":32527400,"timestamp":1700000027000000000},{"data_point_value":32528900,"timestamp":1700000028000000000},{"data_point_value":32529600,"timestamp":1700000029000000000},{"data_point_value":34530600,"timestamp":1700000030000000000}],"end":1700000030000000000,"id":"3f409177-e549-4061-a6f2-6b04e54b6378","name":"memory_usage","start":1700000001000000000},"type":"timeseries"} -{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000031000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":34532600,"timestamp":1700000031000000000},{"data_point_value":34533200,"timestamp":1700000032000000000},{"data_point_value":34533700,"timestamp":1700000033000000000},{"data_point_value":34534500,"timestamp":1700000034000000000},{"data_point_value":34535700,"timestamp":1700000035000000000},{"data_point_value":34537200,"timestamp":1700000036000000000},{"data_point_value":34537900,"timestamp":1700000037000000000},{"data_point_value":34538900,"timestamp":1700000038000000000},{"data_point_value":34540900,"timestamp":1700000039000000000},{"data_point_value":33741500,"timestamp":1700000040000000000},{"data_point_value":33742000,"timestamp":1700000041000000000},{"data_point_value":33742800,"timestamp":1700000042000000000},{"data_point_value":33744000,"timestamp":1700000043000000000},{"data_point_value":33745500,"timestamp":1700000044000000000},{"data_point_value":33746200,"timestamp":1700000045000000000},{"data_point_value":33747200,"timestamp":1700000046000000000},{"data_point_value":33749200,"timestamp":1700000047000000000},{"data_point_value":33749800,"timestamp":1700000048000000000},{"data_point_value":33750300,"timestamp":1700000049000000000},{"data_point_value":34751100,"timestamp":1700000050000000000},{"data_point_value":34752300,"timestamp":1700000051000000000},{"data_point_value":34753800,"timestamp":1700000052000000000},{"data_point_value":34754500,"timestamp":1700000053000000000},{"data_point_value":34755500,"timestamp":1700000054000000000},{"data_point_value":34757500,"timestamp":1700000055000000000},{"data_point_value":34758100,"timestamp":1700000056000000000},{"data_point_value":34758600,"timestamp":1700000057000000000},{"data_point_value":34759400,"timestamp":1700000058000000000},{"data_point_value":34760600,"timestamp":1700000059000000000},{"data_point_value":34762100,"timestamp":1700000060000000000}],"end":1700000060000000000,"id":"c921ca6c-c752-45d4-a9a3-1e60ab2851f9","name":"memory_usage","start":1700000031000000000},"type":"timeseries"} diff --git a/DatadogTimeseries/output/window_cpu.ndjson b/DatadogTimeseries/output/window_cpu.ndjson deleted file mode 100644 index 6a982deef3..0000000000 --- a/DatadogTimeseries/output/window_cpu.ndjson +++ /dev/null @@ -1 +0,0 @@ -{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000001000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":8.5,"timestamp":1700000001000000000},{"data_point_value":9,"timestamp":1700000006000000000},{"data_point_value":65,"timestamp":1700000011000000000},{"data_point_value":72,"timestamp":1700000016000000000},{"data_point_value":9,"timestamp":1700000021000000000},{"data_point_value":9,"timestamp":1700000026000000000},{"data_point_value":80,"timestamp":1700000031000000000},{"data_point_value":75,"timestamp":1700000036000000000},{"data_point_value":9,"timestamp":1700000041000000000},{"data_point_value":9,"timestamp":1700000046000000000},{"data_point_value":55,"timestamp":1700000051000000000},{"data_point_value":62,"timestamp":1700000056000000000}],"end":1700000056000000000,"id":"12b169d2-6515-4187-8146-4571d286b88f","name":"cpu_usage","start":1700000001000000000},"type":"timeseries"} diff --git a/DatadogTimeseries/output/window_memory.ndjson b/DatadogTimeseries/output/window_memory.ndjson deleted file mode 100644 index 7e7e31a033..0000000000 --- a/DatadogTimeseries/output/window_memory.ndjson +++ /dev/null @@ -1 +0,0 @@ -{"_dd":{"format_version":2},"application":{"id":"runner-app-id"},"date":1700000001000,"session":{"id":"runner-session-id","type":"user"},"source":"ios","timeseries":{"data":[{"data_point_value":31004700,"timestamp":1700000001000000000},{"data_point_value":31009600,"timestamp":1700000006000000000},{"data_point_value":32516000,"timestamp":1700000011000000000},{"data_point_value":32520600,"timestamp":1700000016000000000},{"data_point_value":32525400,"timestamp":1700000021000000000},{"data_point_value":34530600,"timestamp":1700000026000000000},{"data_point_value":34535700,"timestamp":1700000031000000000},{"data_point_value":34540900,"timestamp":1700000036000000000},{"data_point_value":33746200,"timestamp":1700000041000000000},{"data_point_value":34751100,"timestamp":1700000046000000000},{"data_point_value":34757500,"timestamp":1700000051000000000},{"data_point_value":34762100,"timestamp":1700000056000000000}],"end":1700000056000000000,"id":"b80b567a-a2fe-46b0-963e-ba93aced3e8c","name":"memory_usage","start":1700000001000000000},"type":"timeseries"} From 0956a52e3612b3416496823dc2597328fe6414b9 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Fri, 10 Jul 2026 17:17:03 +0200 Subject: [PATCH 070/102] Extract timeseriesBatchSize default into defaultTimeseriesBatchSize constant --- DatadogRUM/Sources/RUMConfiguration.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index bbe29d6db7..35ce15b5fc 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -336,6 +336,9 @@ extension RUM { /// Default: `false`. public var enableTimeseries: Bool + /// The default number of samples collected before a timeseries batch is flushed. + public static let defaultTimeseriesBatchSize = 120 + /// The number of samples collected before a timeseries batch is flushed. /// /// Default: `120`. @@ -605,7 +608,7 @@ extension RUM.Configuration { telemetrySampleRate: SampleRate = 20, collectAccessibility: Bool = false, enableTimeseries: Bool = false, - timeseriesBatchSize: Int = 120, + timeseriesBatchSize: Int = RUM.Configuration.defaultTimeseriesBatchSize, featureFlags: FeatureFlags = .defaults ) { self.applicationID = applicationID @@ -664,7 +667,7 @@ extension RUM.Configuration { telemetrySampleRate: SampleRate = 20, collectAccessibility: Bool = false, enableTimeseries: Bool = false, - timeseriesBatchSize: Int = 120, + timeseriesBatchSize: Int = RUM.Configuration.defaultTimeseriesBatchSize, featureFlags: FeatureFlags = .defaults ) { self.applicationID = applicationID From efdefc4dbd426aa15a41d6e804cca79cd10e64d1 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Fri, 10 Jul 2026 17:17:03 +0200 Subject: [PATCH 071/102] Regenerate API surface after object-v2 schema migration --- api-surface-swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api-surface-swift b/api-surface-swift index 417e5e07bf..f05aa41454 100644 --- a/api-surface-swift +++ b/api-surface-swift @@ -422,6 +422,7 @@ public enum RUM public var telemetrySampleRate: SampleRate public var collectAccessibility: Bool public var enableTimeseries: Bool + public static let defaultTimeseriesBatchSize = 120 public var timeseriesBatchSize: Int public var featureFlags: FeatureFlags public struct URLSessionTracking @@ -445,7 +446,7 @@ public enum RUM case matchHeaders([String]) public init(firstPartyHostsTracing: RUM.Configuration.URLSessionTracking.FirstPartyHostsTracing? = nil,resourceAttributesProvider: RUM.ResourceAttributesProvider? = nil,trackResourceHeaders: TrackResourceHeaders = .disabled) [?] extension RUM.Configuration - public init(applicationID: String,sessionSampleRate: SampleRate = .maxSampleRate,uiKitViewsPredicate: UIKitRUMViewsPredicate? = nil,uiKitActionsPredicate: UIKitRUMActionsPredicate? = nil,swiftUIViewsPredicate: SwiftUIRUMViewsPredicate? = nil,swiftUIActionsPredicate: SwiftUIRUMActionsPredicate? = nil,urlSessionTracking: URLSessionTracking? = nil,trackFrustrations: Bool = true,trackBackgroundEvents: Bool = false,longTaskThreshold: TimeInterval? = 0.1,appHangThreshold: TimeInterval? = nil,trackWatchdogTerminations: Bool = false,vitalsUpdateFrequency: VitalsFrequency? = .average,networkSettledResourcePredicate: NetworkSettledResourcePredicate = TimeBasedTNSResourcePredicate(),nextViewActionPredicate: NextViewActionPredicate? = TimeBasedINVActionPredicate(),viewEventMapper: RUM.ViewEventMapper? = nil,resourceEventMapper: RUM.ResourceEventMapper? = nil,actionEventMapper: RUM.ActionEventMapper? = nil,errorEventMapper: RUM.ErrorEventMapper? = nil,longTaskEventMapper: RUM.LongTaskEventMapper? = nil,onSessionStart: RUM.SessionListener? = nil,customEndpoint: URL? = nil,trackAnonymousUser: Bool = true,trackMemoryWarnings: Bool = true,trackSlowFrames: Bool = true,telemetrySampleRate: SampleRate = 20,collectAccessibility: Bool = false,enableTimeseries: Bool = false,timeseriesBatchSize: Int = 30,featureFlags: FeatureFlags = .defaults) + public init(applicationID: String,sessionSampleRate: SampleRate = .maxSampleRate,uiKitViewsPredicate: UIKitRUMViewsPredicate? = nil,uiKitActionsPredicate: UIKitRUMActionsPredicate? = nil,swiftUIViewsPredicate: SwiftUIRUMViewsPredicate? = nil,swiftUIActionsPredicate: SwiftUIRUMActionsPredicate? = nil,urlSessionTracking: URLSessionTracking? = nil,trackFrustrations: Bool = true,trackBackgroundEvents: Bool = false,longTaskThreshold: TimeInterval? = 0.1,appHangThreshold: TimeInterval? = nil,trackWatchdogTerminations: Bool = false,vitalsUpdateFrequency: VitalsFrequency? = .average,networkSettledResourcePredicate: NetworkSettledResourcePredicate = TimeBasedTNSResourcePredicate(),nextViewActionPredicate: NextViewActionPredicate? = TimeBasedINVActionPredicate(),viewEventMapper: RUM.ViewEventMapper? = nil,resourceEventMapper: RUM.ResourceEventMapper? = nil,actionEventMapper: RUM.ActionEventMapper? = nil,errorEventMapper: RUM.ErrorEventMapper? = nil,longTaskEventMapper: RUM.LongTaskEventMapper? = nil,onSessionStart: RUM.SessionListener? = nil,customEndpoint: URL? = nil,trackAnonymousUser: Bool = true,trackMemoryWarnings: Bool = true,trackSlowFrames: Bool = true,telemetrySampleRate: SampleRate = 20,collectAccessibility: Bool = false,enableTimeseries: Bool = false,timeseriesBatchSize: Int = RUM.Configuration.defaultTimeseriesBatchSize,featureFlags: FeatureFlags = .defaults) [?] extension InternalExtension where ExtendedType == RUM.Configuration public var configurationTelemetrySampleRate: Float [?] extension RUM.Configuration From cf677ad5fe34de3e19acf36d2311becdfa34617a Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Fri, 10 Jul 2026 17:35:06 +0200 Subject: [PATCH 072/102] Apply CR suggestions --- .../TimeseriesSessionCollectorTests.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index dc3d2a4377..7de37655ef 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -50,6 +50,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(event.timeseries.name, "memory") XCTAssertEqual(event.timeseries.schema, "object-v2") XCTAssertEqual(event.timeseries.data.timestamps.count, 2) + XCTAssertEqual(event.timeseries.data.values.memoryFootprint.count, event.timeseries.data.timestamps.count, "Number of memoryFootprint data points must match timestamps") + XCTAssertEqual(event.timeseries.data.values.memoryPercent.count, event.timeseries.data.timestamps.count, "Number of memoryPercent data points must match timestamps") XCTAssertEqual(event.timeseries.data.values.memoryFootprint[0], 1_000) XCTAssertEqual(event.timeseries.data.values.memoryPercent[0], 0.0256, accuracy: 0.0001) } @@ -86,6 +88,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(event.timeseries.name, "cpu") XCTAssertEqual(event.timeseries.schema, "object-v2") XCTAssertEqual(event.timeseries.data.timestamps.count, 2) + XCTAssertEqual(event.timeseries.data.values.cpuUsage.count, event.timeseries.data.timestamps.count, "Number of cpuUsage data points must match timestamps") XCTAssertEqual(event.timeseries.data.values.cpuUsage[0], 42.5) } @@ -113,8 +116,13 @@ class TimeseriesSessionCollectorTests: XCTestCase { // Then XCTAssertFalse(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, "Expected memory events") XCTAssertFalse(featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self).isEmpty, "Expected CPU events") - XCTAssertEqual(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self)[0].timeseries.data.values.memoryFootprint[0], 2_000) - XCTAssertEqual(featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self)[0].timeseries.data.values.cpuUsage[0], 75.0) + let memoryEvent = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self)[0] + let cpuEvent = featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self)[0] + XCTAssertEqual(memoryEvent.timeseries.data.values.memoryFootprint.count, memoryEvent.timeseries.data.timestamps.count, "Number of memoryFootprint data points must match timestamps") + XCTAssertEqual(memoryEvent.timeseries.data.values.memoryPercent.count, memoryEvent.timeseries.data.timestamps.count, "Number of memoryPercent data points must match timestamps") + XCTAssertEqual(cpuEvent.timeseries.data.values.cpuUsage.count, cpuEvent.timeseries.data.timestamps.count, "Number of cpuUsage data points must match timestamps") + XCTAssertEqual(memoryEvent.timeseries.data.values.memoryFootprint[0], 2_000) + XCTAssertEqual(cpuEvent.timeseries.data.values.cpuUsage[0], 75.0) } func testWhenServerTimeOffsetIsNonZero_itAdjustsEventDate() { @@ -241,6 +249,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(events[0].session.id, "session-xyz") XCTAssertEqual(events[0].application.id, "app-456") XCTAssertEqual(events[0].session.type, .synthetics) + XCTAssertEqual(events[0].timeseries.data.values.memoryFootprint.count, events[0].timeseries.data.timestamps.count, "Number of memoryFootprint data points must match timestamps") + XCTAssertEqual(events[0].timeseries.data.values.memoryPercent.count, events[0].timeseries.data.timestamps.count, "Number of memoryPercent data points must match timestamps") } func testWhenStopIsCalled_itFlushesPartialCpuBuffer() { @@ -270,6 +280,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { let events = featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self) XCTAssertFalse(events.isEmpty, "Expected partial CPU buffer to be flushed on stop") XCTAssertEqual(events[0].session.type, .ciTest) + XCTAssertEqual(events[0].timeseries.data.values.cpuUsage.count, events[0].timeseries.data.timestamps.count, "Number of cpuUsage data points must match timestamps") } // MARK: - No-data readers From 606d8b7d5f1047c12d89c61354b8cb54dee552ea Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Fri, 10 Jul 2026 19:00:06 +0200 Subject: [PATCH 073/102] Fix ObjC generator to resolve root swift type via swiftTypeName --- .../Sources/CodeGeneration/Print/ObjcInteropPrinter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/rum-models-generator/Sources/CodeGeneration/Print/ObjcInteropPrinter.swift b/tools/rum-models-generator/Sources/CodeGeneration/Print/ObjcInteropPrinter.swift index 0999490069..5598b09814 100644 --- a/tools/rum-models-generator/Sources/CodeGeneration/Print/ObjcInteropPrinter.swift +++ b/tools/rum-models-generator/Sources/CodeGeneration/Print/ObjcInteropPrinter.swift @@ -657,7 +657,7 @@ public class ObjcInteropPrinter: BasePrinter, CodePrinter { } let identity = AssociatedTypeEnumPropertyIdentity( - rootSwiftTypeName: owner.objcRootClass.bridgedSwiftStruct.name, + rootSwiftTypeName: owner.objcRootClass.swiftTypeName, ownerObjcTypeName: owner.objcTypeName, propertyName: propertyWrapper.bridgedSwiftProperty.name, associatedTypeEnumName: nestedObjcAssociatedTypeEnum.bridgedSwiftAssociatedTypeEnum.name From 342a714f8851c7bcaef629eadb8743739a106db5 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Fri, 10 Jul 2026 19:00:07 +0200 Subject: [PATCH 074/102] Regenerate RUM models and API surface from object-v2 schema --- .../Sources/Models/RUM/RUMDataModels.swift | 14 -------------- .../Sources/DataModels/RUMDataModels+objc.swift | 8 -------- api-surface-objc | 2 -- 3 files changed, 24 deletions(-) diff --git a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift index 2f72012efc..637a781d68 100644 --- a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift +++ b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift @@ -6762,9 +6762,6 @@ public struct RUMViewEvent: RUMDataModel { /// The id of the remote configuration applied to the SDK, if any public let remoteConfigurationId: String? - /// Session Replay experimental features enabled in the SDK configuration - public let sessionReplayExperimentalFeatures: [String]? - /// The percentage of sessions with RUM & Session Replay pricing tracked public let sessionReplaySampleRate: Double? @@ -6780,7 +6777,6 @@ public struct RUMViewEvent: RUMDataModel { public enum CodingKeys: String, CodingKey { case profilingSampleRate = "profiling_sample_rate" case remoteConfigurationId = "remote_configuration_id" - case sessionReplayExperimentalFeatures = "session_replay_experimental_features" case sessionReplaySampleRate = "session_replay_sample_rate" case sessionSampleRate = "session_sample_rate" case startSessionReplayRecordingManually = "start_session_replay_recording_manually" @@ -6792,7 +6788,6 @@ public struct RUMViewEvent: RUMDataModel { /// - Parameters: /// - profilingSampleRate: The percentage of sessions profiled /// - remoteConfigurationId: The id of the remote configuration applied to the SDK, if any - /// - sessionReplayExperimentalFeatures: Session Replay experimental features enabled in the SDK configuration /// - sessionReplaySampleRate: The percentage of sessions with RUM & Session Replay pricing tracked /// - sessionSampleRate: The percentage of sessions tracked /// - startSessionReplayRecordingManually: Whether session replay recording configured to start manually @@ -6800,7 +6795,6 @@ public struct RUMViewEvent: RUMDataModel { public init( profilingSampleRate: Double? = nil, remoteConfigurationId: String? = nil, - sessionReplayExperimentalFeatures: [String]? = nil, sessionReplaySampleRate: Double? = nil, sessionSampleRate: Double, startSessionReplayRecordingManually: Bool? = nil, @@ -6808,7 +6802,6 @@ public struct RUMViewEvent: RUMDataModel { ) { self.profilingSampleRate = profilingSampleRate self.remoteConfigurationId = remoteConfigurationId - self.sessionReplayExperimentalFeatures = sessionReplayExperimentalFeatures self.sessionReplaySampleRate = sessionReplaySampleRate self.sessionSampleRate = sessionSampleRate self.startSessionReplayRecordingManually = startSessionReplayRecordingManually @@ -8936,9 +8929,6 @@ public struct RUMViewUpdateEvent: RUMDataModel { /// The id of the remote configuration applied to the SDK, if any public let remoteConfigurationId: String? - /// Session Replay experimental features enabled in the SDK configuration - public let sessionReplayExperimentalFeatures: [String]? - /// The percentage of sessions with RUM & Session Replay pricing tracked public let sessionReplaySampleRate: Double? @@ -8954,7 +8944,6 @@ public struct RUMViewUpdateEvent: RUMDataModel { public enum CodingKeys: String, CodingKey { case profilingSampleRate = "profiling_sample_rate" case remoteConfigurationId = "remote_configuration_id" - case sessionReplayExperimentalFeatures = "session_replay_experimental_features" case sessionReplaySampleRate = "session_replay_sample_rate" case sessionSampleRate = "session_sample_rate" case startSessionReplayRecordingManually = "start_session_replay_recording_manually" @@ -8966,7 +8955,6 @@ public struct RUMViewUpdateEvent: RUMDataModel { /// - Parameters: /// - profilingSampleRate: The percentage of sessions profiled /// - remoteConfigurationId: The id of the remote configuration applied to the SDK, if any - /// - sessionReplayExperimentalFeatures: Session Replay experimental features enabled in the SDK configuration /// - sessionReplaySampleRate: The percentage of sessions with RUM & Session Replay pricing tracked /// - sessionSampleRate: The percentage of sessions tracked /// - startSessionReplayRecordingManually: Whether session replay recording configured to start manually @@ -8974,7 +8962,6 @@ public struct RUMViewUpdateEvent: RUMDataModel { public init( profilingSampleRate: Double? = nil, remoteConfigurationId: String? = nil, - sessionReplayExperimentalFeatures: [String]? = nil, sessionReplaySampleRate: Double? = nil, sessionSampleRate: Double, startSessionReplayRecordingManually: Bool? = nil, @@ -8982,7 +8969,6 @@ public struct RUMViewUpdateEvent: RUMDataModel { ) { self.profilingSampleRate = profilingSampleRate self.remoteConfigurationId = remoteConfigurationId - self.sessionReplayExperimentalFeatures = sessionReplayExperimentalFeatures self.sessionReplaySampleRate = sessionReplaySampleRate self.sessionSampleRate = sessionSampleRate self.startSessionReplayRecordingManually = startSessionReplayRecordingManually diff --git a/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift b/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift index efc0f5ffb2..3621a998b7 100644 --- a/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift +++ b/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift @@ -8263,10 +8263,6 @@ public class objc_RUMViewEventDDConfiguration: NSObject { root.swiftModel.dd.configuration!.remoteConfigurationId } - public var sessionReplayExperimentalFeatures: [String]? { - root.swiftModel.dd.configuration!.sessionReplayExperimentalFeatures - } - public var sessionReplaySampleRate: NSNumber? { root.swiftModel.dd.configuration!.sessionReplaySampleRate as NSNumber? } @@ -10424,10 +10420,6 @@ public class objc_RUMViewUpdateEventDDConfiguration: NSObject { root.swiftModel.dd.configuration!.remoteConfigurationId } - public var sessionReplayExperimentalFeatures: [String]? { - root.swiftModel.dd.configuration!.sessionReplayExperimentalFeatures - } - public var sessionReplaySampleRate: NSNumber? { root.swiftModel.dd.configuration!.sessionReplaySampleRate as NSNumber? } diff --git a/api-surface-objc b/api-surface-objc index 2b63dabb88..ea568f7b53 100644 --- a/api-surface-objc +++ b/api-surface-objc @@ -2010,7 +2010,6 @@ public class objc_RUMViewEventDDCLS: NSObject public class objc_RUMViewEventDDConfiguration: NSObject public var profilingSampleRate: NSNumber? public var remoteConfigurationId: String? - public var sessionReplayExperimentalFeatures: [String]? public var sessionReplaySampleRate: NSNumber? public var sessionSampleRate: NSNumber public var startSessionReplayRecordingManually: NSNumber? @@ -2438,7 +2437,6 @@ public class objc_RUMViewUpdateEventDDCLS: NSObject public class objc_RUMViewUpdateEventDDConfiguration: NSObject public var profilingSampleRate: NSNumber? public var remoteConfigurationId: String? - public var sessionReplayExperimentalFeatures: [String]? public var sessionReplaySampleRate: NSNumber? public var sessionSampleRate: NSNumber public var startSessionReplayRecordingManually: NSNumber? From 6c59e8fd8ee829d7f5ab1bf6eb49346b0969e365 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 23 Jul 2026 13:56:48 +0200 Subject: [PATCH 075/102] Regenerate RUM models from timeseries common schema fix --- .../Sources/Models/RUM/RUMDataModels.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift index 637a781d68..702c837f71 100644 --- a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift +++ b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift @@ -5776,6 +5776,47 @@ public struct RUMTimeseriesCpuEvent: RUMDataModel { self.url = url } } + + /// View properties + public struct View: Codable { + /// UUID of the view + public let id: String + + /// User defined name of the view + public var name: String? + + /// URL that linked to the initial view of the page + public var referrer: String? + + /// URL of the view + public var url: String + + public enum CodingKeys: String, CodingKey { + case id = "id" + case name = "name" + case referrer = "referrer" + case url = "url" + } + + /// View properties + /// + /// - Parameters: + /// - id: UUID of the view + /// - name: User defined name of the view + /// - referrer: URL that linked to the initial view of the page + /// - url: URL of the view + public init( + id: String, + name: String? = nil, + referrer: String? = nil, + url: String + ) { + self.id = id + self.name = name + self.referrer = referrer + self.url = url + } + } } /// Schema for a memory timeseries event. From 4766c9c2a053db881ef96f6952e0b45bf8d88f50 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 23 Jul 2026 14:07:55 +0200 Subject: [PATCH 076/102] Attach active view context to timeseries events now required by schema --- .../TimeseriesSessionCollector.swift | 20 +++++- .../TimeseriesSessionCollectorTests.swift | 62 ++++++++++++++++++- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 5743ff2505..08a52dc126 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -219,6 +219,13 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let eventID = UUID().uuidString.lowercased() featureScope.eventWriteContext { context, writer in + // Timeseries batches are flushed regardless of an active view; without one there is no + // `view.id`/`view.url` to report, which the RUM event format requires, so the batch is dropped. + let rum = context.additionalContext(ofType: RUMCoreContext.self) + guard let viewID = rum?.viewID else { + return + } + let viewPath = rum?.viewPath ?? "" let offsetNs = context.serverTimeOffset.dd.toInt64Nanoseconds let timestamps = batch.map { $0.timestamp + offsetNs } let adjustedStart = start + offsetNs @@ -242,7 +249,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { id: eventID, start: adjustedStart ), - version: context.version + version: context.version, + view: .init(id: viewID, url: viewPath) ) writer.write(value: event) } @@ -262,6 +270,13 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let eventID = UUID().uuidString.lowercased() featureScope.eventWriteContext { context, writer in + // Timeseries batches are flushed regardless of an active view; without one there is no + // `view.id`/`view.url` to report, which the RUM event format requires, so the batch is dropped. + let rum = context.additionalContext(ofType: RUMCoreContext.self) + guard let viewID = rum?.viewID else { + return + } + let viewPath = rum?.viewPath ?? "" let offsetNs = context.serverTimeOffset.dd.toInt64Nanoseconds let timestamps = batch.map { $0.timestamp + offsetNs } let adjustedStart = start + offsetNs @@ -282,7 +297,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { id: eventID, start: adjustedStart ), - version: context.version + version: context.version, + view: .init(id: viewID, url: viewPath) ) writer.write(value: event) } diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 7de37655ef..2d44705967 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -12,7 +12,7 @@ import DatadogInternal @testable import DatadogRUM class TimeseriesSessionCollectorTests: XCTestCase { - private let featureScope = FeatureScopeMock() + private let featureScope = FeatureScopeMock(context: .mockWith(additionalContext: [RUMCoreContext.mockAny()])) private let memoryReader = SamplingBasedVitalReaderMock() // MARK: - Memory events @@ -128,7 +128,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { func testWhenServerTimeOffsetIsNonZero_itAdjustsEventDate() { // Given memoryReader.vitalData = 1_000_000 - let scope = FeatureScopeMock(context: .mockWith(serverTimeOffset: 5.0)) + let scope = FeatureScopeMock(context: .mockWith(serverTimeOffset: 5.0, additionalContext: [RUMCoreContext.mockAny()])) let collector = TimeseriesSessionCollector( memoryReader: memoryReader, featureScope: scope, @@ -160,7 +160,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { func testWhenContextSourceIsReactNative_itUsesContextSource() { // Given memoryReader.vitalData = 1_000_000 - let scope = FeatureScopeMock(context: .mockWith(source: "react-native")) + let scope = FeatureScopeMock(context: .mockWith(source: "react-native", additionalContext: [RUMCoreContext.mockAny()])) let collector = TimeseriesSessionCollector( memoryReader: memoryReader, featureScope: scope, @@ -185,6 +185,62 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(events[0].source, .reactNative) } + func testWhenActiveViewExists_itAttachesViewToEvent() { + // Given + memoryReader.vitalData = 1_000_000 + let rum: RUMCoreContext = .mockWith(viewID: "view-abc", viewPath: "/view/abc") + let scope = FeatureScopeMock(context: .mockWith(additionalContext: [rum])) + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: scope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + totalRAM: 4_000_000_000 + ) + + // When + let expectation = self.expectation(description: "batch written") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + + collector.start(sessionID: "session-view", applicationID: "app-view", sessionType: .user) + waitForExpectations(timeout: 2) + collector.stop() + + // Then + let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + XCTAssertFalse(events.isEmpty) + XCTAssertEqual(events[0].view.id, "view-abc") + XCTAssertEqual(events[0].view.url, "/view/abc") + } + + func testWhenNoActiveView_itDropsEvent() { + // Given + memoryReader.vitalData = 1_000_000 + let scope = FeatureScopeMock(context: .mockWith(additionalContext: [])) + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: scope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + totalRAM: 4_000_000_000 + ) + + // When + let expectation = self.expectation(description: "samples collected") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + + collector.start(sessionID: "session-no-view", applicationID: "app-no-view", sessionType: .user) + waitForExpectations(timeout: 2) + collector.stop() + + // Then — no view context means no `view.id`/`view.url` to report, so the batch is dropped + XCTAssertTrue(scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty) + } + func testWhenStartIsCalledWithoutStop_itFlushesPartialBufferBeforeNewSession() { // Given memoryReader.vitalData = 1_000_000 From 704751cc96d1ad4052c217c655cb35fb2b76a5c4 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 23 Jul 2026 14:36:11 +0200 Subject: [PATCH 077/102] Attribute timeseries batches to the view they were sampled under, not the view at flush time --- .../TimeseriesSessionCollector.swift | 77 ++++++++++++++----- .../TimeseriesSessionCollectorTests.swift | 36 +++++++++ 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 08a52dc126..0b7370b0bb 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -23,12 +23,18 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let timestamp: Int64 let footprintKB: Double let percent: Double + /// The view active when this sample was collected, if any. + let viewID: String? + let viewPath: String? } /// A single CPU sample: usage as a percentage (0.0 to 100.0). private struct CPUSample { let timestamp: Int64 let usage: Double + /// The view active when this sample was collected, if any. + let viewID: String? + let viewPath: String? } private let memoryReader: SamplingBasedVitalReader @@ -47,6 +53,12 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private var timer: DispatchSourceTimer? private var isPaused: Bool = false + /// The most recently known active view, refreshed on every sample tick. Read and written only on `queue`. + /// Samples are tagged with this cached value rather than the view at flush time, so a batch collected + /// under an active view isn't dropped just because that view happened to end right before it was flushed. + private var cachedViewID: String? + private var cachedViewPath: String? + /// All buffer mutations and timer events run on this queue. private let queue = DispatchQueue(label: "com.datadoghq.timeseries-collector", qos: .utility) @@ -175,6 +187,17 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } } + /// Returns the most recently active view among the given samples, searching from the end of the batch + /// backwards, or `nil` if no sample in the batch had a view. + private static func lastKnownView(in samples: [(viewID: String?, viewPath: String?)]) -> (id: String, path: String)? { + for sample in samples.reversed() { + if let id = sample.viewID { + return (id: id, path: sample.viewPath ?? "") + } + } + return nil + } + private func makeTimer() -> DispatchSourceTimer { let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + samplingInterval, repeating: samplingInterval) @@ -187,22 +210,40 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private func sample() { let now = Int64.ddWithNoOverflow(Date().timeIntervalSince1970 * 1_000_000_000) + let viewID = cachedViewID + let viewPath = cachedViewPath if let bytes = memoryReader.readVitalData() { let footprintKB = bytes / 1_024 let memoryPercent = totalRAM > 0 ? bytes / totalRAM * 100 : 0 - memoryBuffer.append(MemorySample(timestamp: now, footprintKB: footprintKB, percent: memoryPercent)) + memoryBuffer.append( + MemorySample(timestamp: now, footprintKB: footprintKB, percent: memoryPercent, viewID: viewID, viewPath: viewPath) + ) if memoryBuffer.count >= batchSize { flushMemory() } } if let cpuUsage = cpuUsageProvider() { - cpuBuffer.append(CPUSample(timestamp: now, usage: cpuUsage)) + cpuBuffer.append(CPUSample(timestamp: now, usage: cpuUsage, viewID: viewID, viewPath: viewPath)) if cpuBuffer.count >= batchSize { flushCPU() } } + + refreshCachedView() + } + + /// Fetches the currently active view and caches it for tagging future samples. Runs asynchronously and + /// hops back onto `queue` to apply the result, so it never blocks sampling. + private func refreshCachedView() { + featureScope.context { [weak self] context in + let rum = context.additionalContext(ofType: RUMCoreContext.self) + self?.queue.async { + self?.cachedViewID = rum?.viewID + self?.cachedViewPath = rum?.viewPath + } + } } private func flushMemory() { @@ -218,14 +259,14 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() + // The batch is attributed to the most recently active view among its samples, not the view active + // at flush time — a view ending right before a scheduled flush shouldn't drop data that was + // genuinely collected while it was active. Only dropped if no sample in the batch had a view. + guard let view = Self.lastKnownView(in: batch.map { (viewID: $0.viewID, viewPath: $0.viewPath) }) else { + return + } + featureScope.eventWriteContext { context, writer in - // Timeseries batches are flushed regardless of an active view; without one there is no - // `view.id`/`view.url` to report, which the RUM event format requires, so the batch is dropped. - let rum = context.additionalContext(ofType: RUMCoreContext.self) - guard let viewID = rum?.viewID else { - return - } - let viewPath = rum?.viewPath ?? "" let offsetNs = context.serverTimeOffset.dd.toInt64Nanoseconds let timestamps = batch.map { $0.timestamp + offsetNs } let adjustedStart = start + offsetNs @@ -250,7 +291,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { start: adjustedStart ), version: context.version, - view: .init(id: viewID, url: viewPath) + view: .init(id: view.id, url: view.path) ) writer.write(value: event) } @@ -269,14 +310,14 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() + // The batch is attributed to the most recently active view among its samples, not the view active + // at flush time — a view ending right before a scheduled flush shouldn't drop data that was + // genuinely collected while it was active. Only dropped if no sample in the batch had a view. + guard let view = Self.lastKnownView(in: batch.map { (viewID: $0.viewID, viewPath: $0.viewPath) }) else { + return + } + featureScope.eventWriteContext { context, writer in - // Timeseries batches are flushed regardless of an active view; without one there is no - // `view.id`/`view.url` to report, which the RUM event format requires, so the batch is dropped. - let rum = context.additionalContext(ofType: RUMCoreContext.self) - guard let viewID = rum?.viewID else { - return - } - let viewPath = rum?.viewPath ?? "" let offsetNs = context.serverTimeOffset.dd.toInt64Nanoseconds let timestamps = batch.map { $0.timestamp + offsetNs } let adjustedStart = start + offsetNs @@ -298,7 +339,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { start: adjustedStart ), version: context.version, - view: .init(id: viewID, url: viewPath) + view: .init(id: view.id, url: view.path) ) writer.write(value: event) } diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 2d44705967..692bc79273 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -215,6 +215,42 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(events[0].view.url, "/view/abc") } + func testWhenViewEndsRightBeforeFlush_itStillAttachesTheViewSamplesWereCollectedUnder() { + // Given + memoryReader.vitalData = 1_000_000 + let rum: RUMCoreContext = .mockWith(viewID: "view-ending", viewPath: "/view/ending") + let scope = FeatureScopeMock(context: .mockWith(additionalContext: [rum])) + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: scope, + batchSize: 100, // won't auto-flush — only the explicit stop() below triggers the flush + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + totalRAM: 4_000_000_000 + ) + + // When — samples are collected while a view is active + let expectation = self.expectation(description: "samples collected under an active view") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } + collector.start(sessionID: "session-ending-view", applicationID: "app-ending-view", sessionType: .user) + waitForExpectations(timeout: 2) + + // The view ends right before the batch is flushed + scope.contextMock = .mockWith(additionalContext: []) + + let syncExpectation = self.expectation(description: "stop completed") + collector.stop() + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.2) { syncExpectation.fulfill() } + waitForExpectations(timeout: 2) + + // Then — the batch is still written, attributed to the view it was actually collected under + let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + XCTAssertFalse(events.isEmpty, "Expected the batch collected under an active view to be flushed, not dropped") + XCTAssertEqual(events[0].view.id, "view-ending") + XCTAssertEqual(events[0].view.url, "/view/ending") + } + func testWhenNoActiveView_itDropsEvent() { // Given memoryReader.vitalData = 1_000_000 From 4f3216a30db00d99cccc1475294ec77c72ff8d1e Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 23 Jul 2026 14:39:45 +0200 Subject: [PATCH 078/102] Regenerate API surface for timeseries schema changes --- api-surface-objc | 2 -- 1 file changed, 2 deletions(-) diff --git a/api-surface-objc b/api-surface-objc index ea568f7b53..32cd2f8a33 100644 --- a/api-surface-objc +++ b/api-surface-objc @@ -1635,7 +1635,6 @@ public class objc_RUMTimeseriesCpuEventDD: NSObject public var session: objc_RUMTimeseriesCpuEventDDSession? public class objc_RUMTimeseriesCpuEventDDConfiguration: NSObject public var profilingSampleRate: NSNumber? - public var sessionReplayExperimentalFeatures: [String]? public var sessionReplaySampleRate: NSNumber? public var sessionSampleRate: NSNumber public var traceSampleRate: NSNumber? @@ -1817,7 +1816,6 @@ public class objc_RUMTimeseriesMemoryEventDD: NSObject public var session: objc_RUMTimeseriesMemoryEventDDSession? public class objc_RUMTimeseriesMemoryEventDDConfiguration: NSObject public var profilingSampleRate: NSNumber? - public var sessionReplayExperimentalFeatures: [String]? public var sessionReplaySampleRate: NSNumber? public var sessionSampleRate: NSNumber public var traceSampleRate: NSNumber? From 91264ec8f1bb0892a03811d847ecf4d6c9d3e8fe Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 27 Jul 2026 10:35:37 +0200 Subject: [PATCH 079/102] Cache active view from message bus instead of polling context per sample tick --- DatadogRUM/Sources/Feature/RUMFeature.swift | 22 +++++++---- .../TimeseriesSessionCollector.swift | 39 +++++++++++-------- .../TimeseriesSessionCollectorTests.swift | 17 ++++++++ 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 2464d74553..10fb85e898 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -123,6 +123,15 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider VitalsReaders(frequency: $0.timeInterval, telemetry: core.telemetry) } + let timeseriesCollector: TimeseriesSessionCollector? = configuration.enableTimeseries ? vitalsReaders.map { + TimeseriesSessionCollector( + memoryReader: $0.memory, + featureScope: featureScope, + batchSize: configuration.timeseriesBatchSize, + collectInBackground: configuration.trackBackgroundEvents + ) + } : nil + let dependencies = RUMScopeDependencies( featureScope: featureScope, rumApplicationID: configuration.applicationID, @@ -197,14 +206,7 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider ) }, sessionType: configuration.sessionTypeOverride.flatMap { RUMSessionType(rawValue: $0) }, - timeseriesCollector: configuration.enableTimeseries ? vitalsReaders.map { - TimeseriesSessionCollector( - memoryReader: $0.memory, - featureScope: featureScope, - batchSize: configuration.timeseriesBatchSize, - collectInBackground: configuration.trackBackgroundEvents - ) - } : nil + timeseriesCollector: timeseriesCollector ) self.monitor = Monitor( @@ -318,6 +320,10 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider messageReceivers.append(watchdogTermination) } + if let timeseriesCollector = timeseriesCollector { + messageReceivers.append(timeseriesCollector) + } + self.messageReceiver = CombinedFeatureMessageReceiver(messageReceivers) // Forward instrumentation calls to monitor: diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 0b7370b0bb..cb13ea6515 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -53,9 +53,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private var timer: DispatchSourceTimer? private var isPaused: Bool = false - /// The most recently known active view, refreshed on every sample tick. Read and written only on `queue`. - /// Samples are tagged with this cached value rather than the view at flush time, so a batch collected - /// under an active view isn't dropped just because that view happened to end right before it was flushed. + /// The most recently known active view, updated from the message bus on every core context change. + /// Cached rather than read at flush time so a batch isn't dropped just because its view ended right before flush. private var cachedViewID: String? private var cachedViewPath: String? @@ -137,6 +136,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.sessionType = sessionType self.memoryBuffer = [] self.cpuBuffer = [] + self.cachedViewID = nil + self.cachedViewPath = nil self.isPaused = false self.timer?.cancel() @@ -184,6 +185,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.isPaused = false self.flushMemory() self.flushCPU() + self.cachedViewID = nil + self.cachedViewPath = nil } } @@ -230,20 +233,6 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { flushCPU() } } - - refreshCachedView() - } - - /// Fetches the currently active view and caches it for tagging future samples. Runs asynchronously and - /// hops back onto `queue` to apply the result, so it never blocks sampling. - private func refreshCachedView() { - featureScope.context { [weak self] context in - let rum = context.additionalContext(ofType: RUMCoreContext.self) - self?.queue.async { - self?.cachedViewID = rum?.viewID - self?.cachedViewPath = rum?.viewPath - } - } } private func flushMemory() { @@ -345,3 +334,19 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } } } + +extension TimeseriesSessionCollector: FeatureMessageReceiver { + /// Receives the currently active view from the core context message and caches it for tagging future samples. + /// - Returns: Always `false`, because it doesn't block message propagation to other receivers. + func receive(message: FeatureMessage, from core: DatadogCoreProtocol) -> Bool { + guard case .context(let context) = message else { + return false + } + let rum = context.additionalContext(ofType: RUMCoreContext.self) + queue.async { [weak self] in + self?.cachedViewID = rum?.viewID + self?.cachedViewPath = rum?.viewPath + } + return false + } +} diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 692bc79273..b953f1312b 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -35,6 +35,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-abc", applicationID: "app-123", sessionType: .user) + _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -73,6 +74,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-abc", applicationID: "app-123", sessionType: .user) + _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -110,6 +112,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-both", applicationID: "app-both", sessionType: .user) + _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -144,6 +147,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-offset", applicationID: "app-offset", sessionType: .user) + _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -176,6 +180,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-rn", applicationID: "app-rn", sessionType: .user) + _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -205,6 +210,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-view", applicationID: "app-view", sessionType: .user) + _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -234,10 +240,12 @@ class TimeseriesSessionCollectorTests: XCTestCase { expectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } collector.start(sessionID: "session-ending-view", applicationID: "app-ending-view", sessionType: .user) + _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) // The view ends right before the batch is flushed scope.contextMock = .mockWith(additionalContext: []) + _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) let syncExpectation = self.expectation(description: "stop completed") collector.stop() @@ -294,6 +302,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { firstExpectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { firstExpectation.fulfill() } collector.start(sessionID: "session-1", applicationID: "app-1", sessionType: .user) + _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) // Start session-2 without calling stop() first @@ -328,6 +337,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } collector.start(sessionID: "session-xyz", applicationID: "app-456", sessionType: .synthetics) + _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) let syncExpectation = self.expectation(description: "stop completed") @@ -361,6 +371,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } collector.start(sessionID: "session-xyz", applicationID: "app-456", sessionType: .ciTest) + _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) let syncExpectation = self.expectation(description: "stop completed") @@ -419,6 +430,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { firstExpectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { firstExpectation.fulfill() } collector.start(sessionID: "session-1", applicationID: "app-1", sessionType: .user) + _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) // Second session — start() resets buffers and updates metadata @@ -426,6 +438,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { secondExpectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { secondExpectation.fulfill() } collector.start(sessionID: "session-2", applicationID: "app-1", sessionType: .user) + _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) // Flush second session @@ -458,6 +471,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { samplingExpectation.fulfill() } collector.start(sessionID: "session-pause", applicationID: "app-1", sessionType: .user) + _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) let countBeforePause = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count @@ -490,6 +504,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { ) collector.start(sessionID: "session-resume", applicationID: "app-1", sessionType: .user) + _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) let pauseExpectation = self.expectation(description: "pause settled") collector.pause() @@ -528,6 +543,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { startExpectation.fulfill() } collector.start(sessionID: "session-bg", applicationID: "app-1", sessionType: .user) + _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) let countBeforePause = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count @@ -585,6 +601,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-abc", applicationID: "app-123", sessionType: .user) + _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() From d671d2a000e8163bb1bdbb08c4e8a5686e4587e5 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 27 Jul 2026 12:14:44 +0200 Subject: [PATCH 080/102] Populate common RUM schema fields on timeseries events --- DatadogRUM/Sources/Feature/RUMFeature.swift | 25 +++++---- .../TimeseriesSessionCollector.swift | 32 ++++++++++- .../TimeseriesSessionCollectorTests.swift | 54 +++++++++++++++++++ 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 10fb85e898..8b5b8483e6 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -123,12 +123,24 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider VitalsReaders(frequency: $0.timeInterval, telemetry: core.telemetry) } + let ciTest = configuration.ciTestExecutionID.map { RUMCITest(testExecutionId: $0) } + let syntheticsTest: RUMSyntheticsTest? = { + if let testId = configuration.syntheticsTestId, + let resultId = configuration.syntheticsResultId { + return RUMSyntheticsTest(injected: nil, resultId: resultId, testId: testId, syntheticsInfo: [:]) + } else { + return nil + } + }() + let timeseriesCollector: TimeseriesSessionCollector? = configuration.enableTimeseries ? vitalsReaders.map { TimeseriesSessionCollector( memoryReader: $0.memory, featureScope: featureScope, batchSize: configuration.timeseriesBatchSize, - collectInBackground: configuration.trackBackgroundEvents + collectInBackground: configuration.trackBackgroundEvents, + ciTest: ciTest, + syntheticsTest: syntheticsTest ) } : nil @@ -146,15 +158,8 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider ), rumUUIDGenerator: configuration.uuidGenerator, backtraceReporter: core.backtraceReporter, - ciTest: configuration.ciTestExecutionID.map { RUMCITest(testExecutionId: $0) }, - syntheticsTest: { - if let testId = configuration.syntheticsTestId, - let resultId = configuration.syntheticsResultId { - return RUMSyntheticsTest(injected: nil, resultId: resultId, testId: testId, syntheticsInfo: [:]) - } else { - return nil - } - }(), + ciTest: ciTest, + syntheticsTest: syntheticsTest, renderLoopObserver: renderLoopObserver, firstFrameReader: firstFrameReader, viewHitchesReaderFactory: { diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index cb13ea6515..167cd3cfd4 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -44,6 +44,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private let collectInBackground: Bool private let featureScope: FeatureScope private let totalRAM: Double + private let ciTest: RUMCITest? + private let syntheticsTest: RUMSyntheticsTest? private var memoryBuffer: [MemorySample] = [] private var cpuBuffer: [CPUSample] = [] @@ -68,7 +70,9 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { samplingInterval: TimeInterval = 1, collectInBackground: Bool = false, cpuUsageProvider: (() -> Double?)? = nil, - totalRAM: Double = Double(ProcessInfo.processInfo.physicalMemory) + totalRAM: Double = Double(ProcessInfo.processInfo.physicalMemory), + ciTest: RUMCITest? = nil, + syntheticsTest: RUMSyntheticsTest? = nil ) { self.memoryReader = memoryReader self.batchSize = max(2, batchSize) @@ -76,6 +80,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.collectInBackground = collectInBackground self.featureScope = featureScope self.totalRAM = totalRAM + self.ciTest = ciTest + self.syntheticsTest = syntheticsTest self.cpuUsageProvider = cpuUsageProvider ?? { TimeseriesSessionCollector.processCPU() } } @@ -244,6 +250,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let sessionID = self.sessionID let applicationID = self.applicationID let sessionType = self.sessionType + let ciTest = self.ciTest + let syntheticsTest = self.syntheticsTest let start = batch[0].timestamp let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() @@ -262,11 +270,20 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let adjustedEnd = end + offsetNs let event = RUMTimeseriesMemoryEvent( dd: .init(), + account: .init(context: context), application: .init(id: applicationID), + buildId: context.buildId, + buildVersion: context.buildNumber, + ciTest: ciTest, + connectivity: .init(context: context), date: (Double(start) / 1_000_000_000 + context.serverTimeOffset).dd.toInt64Milliseconds, + ddtags: context.ddTags, + device: context.normalizedDevice(), + os: context.os, service: context.service, session: .init(id: sessionID, type: sessionType), source: .init(rawValue: context.source) ?? .ios, + synthetics: syntheticsTest, timeseries: .init( data: .init( timestamps: timestamps, @@ -279,6 +296,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { id: eventID, start: adjustedStart ), + usr: .init(context: context), version: context.version, view: .init(id: view.id, url: view.path) ) @@ -295,6 +313,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let sessionID = self.sessionID let applicationID = self.applicationID let sessionType = self.sessionType + let ciTest = self.ciTest + let syntheticsTest = self.syntheticsTest let start = batch[0].timestamp let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() @@ -313,11 +333,20 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let adjustedEnd = end + offsetNs let event = RUMTimeseriesCpuEvent( dd: .init(), + account: .init(context: context), application: .init(id: applicationID), + buildId: context.buildId, + buildVersion: context.buildNumber, + ciTest: ciTest, + connectivity: .init(context: context), date: (Double(start) / 1_000_000_000 + context.serverTimeOffset).dd.toInt64Milliseconds, + ddtags: context.ddTags, + device: context.normalizedDevice(), + os: context.os, service: context.service, session: .init(id: sessionID, type: sessionType), source: .init(rawValue: context.source) ?? .ios, + synthetics: syntheticsTest, timeseries: .init( data: .init( timestamps: timestamps, @@ -327,6 +356,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { id: eventID, start: adjustedStart ), + usr: .init(context: context), version: context.version, view: .init(id: view.id, url: view.path) ) diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index b953f1312b..8e093a3e4e 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -583,6 +583,60 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertTrue(featureScope.eventsWritten.isEmpty) } + // MARK: - Common schema fields + + func testItPopulatesCommonSchemaFieldsFromContextAndConstructorInjectedDependencies() { + // Given + memoryReader.vitalData = 1_000_000 + let userInfo = UserInfo(id: "user-abc", name: "Jane", email: "jane@example.com", extraInfo: [:]) + let accountInfo = AccountInfo(id: "account-abc", name: "Acme", extraInfo: [:]) + let scope = FeatureScopeMock( + context: .mockWith( + buildNumber: "42", + buildId: "build-abc", + userInfo: userInfo, + accountInfo: accountInfo, + additionalContext: [RUMCoreContext.mockAny()] + ) + ) + let ciTest = RUMCITest(testExecutionId: "ci-exec-abc") + let syntheticsTest = RUMSyntheticsTest(injected: nil, resultId: "synthetics-result", testId: "synthetics-test", syntheticsInfo: [:]) + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: scope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + totalRAM: 4_000_000_000, + ciTest: ciTest, + syntheticsTest: syntheticsTest + ) + + // When + let expectation = self.expectation(description: "batch written") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + + collector.start(sessionID: "session-common", applicationID: "app-common", sessionType: .user) + _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) + waitForExpectations(timeout: 2) + collector.stop() + + // Then + let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + XCTAssertFalse(events.isEmpty) + let event = events[0] + XCTAssertEqual(event.usr?.id, "user-abc") + XCTAssertEqual(event.account?.id, "account-abc") + XCTAssertNotNil(event.connectivity) + XCTAssertNotNil(event.device) + XCTAssertNotNil(event.os) + XCTAssertEqual(event.buildId, "build-abc") + XCTAssertEqual(event.buildVersion, "42") + XCTAssertEqual(event.ciTest?.testExecutionId, "ci-exec-abc") + XCTAssertEqual(event.synthetics?.testId, "synthetics-test") + } + // MARK: - Timeseries range func testTimestampsAreMonotonicallyIncreasing() { From 843fbf5a6449a8c236751f6f2f8c5580219cc38a Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 27 Jul 2026 13:22:20 +0200 Subject: [PATCH 081/102] Populate context field on timeseries events from monitor global attributes --- DatadogRUM/Sources/Feature/RUMFeature.swift | 2 + DatadogRUM/Sources/RUMMonitor/Monitor.swift | 12 ++++++ .../TimeseriesSessionCollector.swift | 8 ++++ .../TimeseriesSessionCollectorTests.swift | 38 +++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 8b5b8483e6..6081946b2d 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -219,6 +219,8 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider dateProvider: configuration.dateProvider ) + timeseriesCollector?.globalAttributesReader = monitor + if let refreshRateVital = dependencies.vitalsReaders?.refreshRate as? RenderLoopReader { dependencies.renderLoopObserver?.register(refreshRateVital) } diff --git a/DatadogRUM/Sources/RUMMonitor/Monitor.swift b/DatadogRUM/Sources/RUMMonitor/Monitor.swift index 1f71f73c84..568d37b87a 100644 --- a/DatadogRUM/Sources/RUMMonitor/Monitor.swift +++ b/DatadogRUM/Sources/RUMMonitor/Monitor.swift @@ -96,6 +96,14 @@ internal enum RUMInternalErrorSource: String, Decodable { /// A mobile-specific category of the error. It provides a high-level grouping for different types of errors. internal typealias RUMErrorCategory = RUMErrorEvent.Error.Category +/// Exposes the monitor's global custom attributes for readers that operate outside the `RUMCommand` pipeline +/// (e.g. the timer-driven `TimeseriesSessionCollector`), which otherwise have no access to `command.globalAttributes`. +internal protocol GlobalAttributesReader: AnyObject { + /// The current global attributes set through `addAttribute(forKey:value:)` / `addAttributes(_:)`. + /// Safe to read from any thread. + var globalAttributes: [AttributeKey: AttributeValue] { get } +} + internal class Monitor: RUMCommandSubscriber { /// RUM feature scope. let featureScope: FeatureScope @@ -191,6 +199,10 @@ internal class Monitor: RUMCommandSubscriber { } } +extension Monitor: GlobalAttributesReader { + var globalAttributes: [AttributeKey: AttributeValue] { attributes } +} + /// Declares `Monitor` conformance to public `RUMMonitorProtocol`. extension Monitor: RUMMonitorProtocol { // MARK: - attributes diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 167cd3cfd4..01cf1589d1 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -47,6 +47,10 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private let ciTest: RUMCITest? private let syntheticsTest: RUMSyntheticsTest? + /// Provides global custom attributes at flush time. Set by `RUMFeature` once `Monitor` is constructed, + /// since the collector is created before it. `Monitor.globalAttributes` is safe to read from any thread. + weak var globalAttributesReader: GlobalAttributesReader? + private var memoryBuffer: [MemorySample] = [] private var cpuBuffer: [CPUSample] = [] private var sessionID: String = "" @@ -252,6 +256,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let sessionType = self.sessionType let ciTest = self.ciTest let syntheticsTest = self.syntheticsTest + let globalAttributes = self.globalAttributesReader?.globalAttributes ?? [:] let start = batch[0].timestamp let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() @@ -276,6 +281,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { buildVersion: context.buildNumber, ciTest: ciTest, connectivity: .init(context: context), + context: .init(contextInfo: globalAttributes), date: (Double(start) / 1_000_000_000 + context.serverTimeOffset).dd.toInt64Milliseconds, ddtags: context.ddTags, device: context.normalizedDevice(), @@ -315,6 +321,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let sessionType = self.sessionType let ciTest = self.ciTest let syntheticsTest = self.syntheticsTest + let globalAttributes = self.globalAttributesReader?.globalAttributes ?? [:] let start = batch[0].timestamp let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() @@ -339,6 +346,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { buildVersion: context.buildNumber, ciTest: ciTest, connectivity: .init(context: context), + context: .init(contextInfo: globalAttributes), date: (Double(start) / 1_000_000_000 + context.serverTimeOffset).dd.toInt64Milliseconds, ddtags: context.ddTags, device: context.normalizedDevice(), diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 8e093a3e4e..8ae69ec410 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -637,6 +637,36 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(event.synthetics?.testId, "synthetics-test") } + func testItPopulatesContextFromGlobalAttributesReader() { + // Given + memoryReader.vitalData = 1_000_000 + let scope = FeatureScopeMock(context: .mockWith(additionalContext: [RUMCoreContext.mockAny()])) + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: scope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil } + ) + let attributesReader = GlobalAttributesReaderMock(globalAttributes: ["custom-key": "custom-value"]) + collector.globalAttributesReader = attributesReader + + // When + let expectation = self.expectation(description: "batch written") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + + collector.start(sessionID: "session-context", applicationID: "app-context", sessionType: .user) + _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) + waitForExpectations(timeout: 2) + collector.stop() + + // Then + let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + XCTAssertFalse(events.isEmpty) + XCTAssertEqual(events[0].context?.contextInfo["custom-key"] as? String, "custom-value") + } + // MARK: - Timeseries range func testTimestampsAreMonotonicallyIncreasing() { @@ -672,4 +702,12 @@ class TimeseriesSessionCollectorTests: XCTestCase { } } +private class GlobalAttributesReaderMock: GlobalAttributesReader { + let globalAttributes: [AttributeKey: AttributeValue] + + init(globalAttributes: [AttributeKey: AttributeValue]) { + self.globalAttributes = globalAttributes + } +} + #endif From c3e4ec7571870720fe53a552813e1d71a641b9b5 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 27 Jul 2026 15:03:19 +0200 Subject: [PATCH 082/102] Populate has_replay, session_sample_rate, and view name on timeseries events --- .../Sources/Models/RUM/RUMCoreContext.swift | 8 +- DatadogRUM/Sources/Feature/RUMFeature.swift | 7 +- DatadogRUM/Sources/RUMMonitor/Monitor.swift | 3 +- .../TimeseriesSessionCollector.swift | 51 ++++++++--- .../TimeseriesSessionCollectorTests.swift | 91 +++++++++++++++++++ .../Mocks/DatadogRUM/RUMFeatureMocks.swift | 9 +- 6 files changed, 149 insertions(+), 20 deletions(-) diff --git a/DatadogInternal/Sources/Models/RUM/RUMCoreContext.swift b/DatadogInternal/Sources/Models/RUM/RUMCoreContext.swift index 0a978c4177..1c02821621 100644 --- a/DatadogInternal/Sources/Models/RUM/RUMCoreContext.swift +++ b/DatadogInternal/Sources/Models/RUM/RUMCoreContext.swift @@ -27,6 +27,9 @@ public struct RUMCoreContext: AdditionalContext, Equatable { /// Current RUM view path public let viewPath: String? + /// Current RUM view user defined name + public let viewName: String? + /// Creates a RUM context. /// /// - Parameters: @@ -37,6 +40,7 @@ public struct RUMCoreContext: AdditionalContext, Equatable { /// - userActionID: The ID of current RUM action (standard UUID `String`, lowercased). /// - viewServerTimeOffset: Current view related server time offset /// - viewPath: Current RUM view path + /// - viewName: Current RUM view user defined name public init( applicationID: String, sessionID: String, @@ -44,7 +48,8 @@ public struct RUMCoreContext: AdditionalContext, Equatable { viewID: String? = nil, userActionID: String? = nil, viewServerTimeOffset: TimeInterval? = nil, - viewPath: String? = nil + viewPath: String? = nil, + viewName: String? = nil ) { self.applicationID = applicationID self.sessionID = sessionID @@ -53,6 +58,7 @@ public struct RUMCoreContext: AdditionalContext, Equatable { self.userActionID = userActionID self.viewServerTimeOffset = viewServerTimeOffset self.viewPath = viewPath + self.viewName = viewName } } diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 6081946b2d..f2c5fb6e8d 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -133,6 +133,8 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider } }() + let sessionSampleRate = configuration.debugSDK ? 100 : configuration.sessionSampleRate + let timeseriesCollector: TimeseriesSessionCollector? = configuration.enableTimeseries ? vitalsReaders.map { TimeseriesSessionCollector( memoryReader: $0.memory, @@ -140,14 +142,15 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider batchSize: configuration.timeseriesBatchSize, collectInBackground: configuration.trackBackgroundEvents, ciTest: ciTest, - syntheticsTest: syntheticsTest + syntheticsTest: syntheticsTest, + sessionSampleRate: Double(sessionSampleRate) ) } : nil let dependencies = RUMScopeDependencies( featureScope: featureScope, rumApplicationID: configuration.applicationID, - samplingRate: configuration.debugSDK ? 100 : configuration.sessionSampleRate, + samplingRate: sessionSampleRate, trackBackgroundEvents: configuration.trackBackgroundEvents, trackFrustrations: configuration.trackFrustrations, hasAppHangsEnabled: configuration.appHangThreshold != nil, diff --git a/DatadogRUM/Sources/RUMMonitor/Monitor.swift b/DatadogRUM/Sources/RUMMonitor/Monitor.swift index 568d37b87a..7705a691fd 100644 --- a/DatadogRUM/Sources/RUMMonitor/Monitor.swift +++ b/DatadogRUM/Sources/RUMMonitor/Monitor.swift @@ -170,7 +170,8 @@ internal class Monitor: RUMCommandSubscriber { viewID: context.activeViewID?.toRUMDataFormat, userActionID: context.activeUserActionID?.toRUMDataFormat, viewServerTimeOffset: activeSession.viewScopes.last?.serverTimeOffset, - viewPath: context.activeViewPath + viewPath: context.activeViewPath, + viewName: context.activeViewName ) } ) diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 01cf1589d1..6646361532 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -26,6 +26,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { /// The view active when this sample was collected, if any. let viewID: String? let viewPath: String? + let viewName: String? } /// A single CPU sample: usage as a percentage (0.0 to 100.0). @@ -35,6 +36,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { /// The view active when this sample was collected, if any. let viewID: String? let viewPath: String? + let viewName: String? } private let memoryReader: SamplingBasedVitalReader @@ -46,6 +48,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private let totalRAM: Double private let ciTest: RUMCITest? private let syntheticsTest: RUMSyntheticsTest? + private let sessionSampleRate: Double /// Provides global custom attributes at flush time. Set by `RUMFeature` once `Monitor` is constructed, /// since the collector is created before it. `Monitor.globalAttributes` is safe to read from any thread. @@ -63,6 +66,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { /// Cached rather than read at flush time so a batch isn't dropped just because its view ended right before flush. private var cachedViewID: String? private var cachedViewPath: String? + private var cachedViewName: String? /// All buffer mutations and timer events run on this queue. private let queue = DispatchQueue(label: "com.datadoghq.timeseries-collector", qos: .utility) @@ -76,7 +80,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { cpuUsageProvider: (() -> Double?)? = nil, totalRAM: Double = Double(ProcessInfo.processInfo.physicalMemory), ciTest: RUMCITest? = nil, - syntheticsTest: RUMSyntheticsTest? = nil + syntheticsTest: RUMSyntheticsTest? = nil, + sessionSampleRate: Double = 100 ) { self.memoryReader = memoryReader self.batchSize = max(2, batchSize) @@ -86,6 +91,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.totalRAM = totalRAM self.ciTest = ciTest self.syntheticsTest = syntheticsTest + self.sessionSampleRate = sessionSampleRate self.cpuUsageProvider = cpuUsageProvider ?? { TimeseriesSessionCollector.processCPU() } } @@ -148,6 +154,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.cpuBuffer = [] self.cachedViewID = nil self.cachedViewPath = nil + self.cachedViewName = nil self.isPaused = false self.timer?.cancel() @@ -197,15 +204,18 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.flushCPU() self.cachedViewID = nil self.cachedViewPath = nil + self.cachedViewName = nil } } /// Returns the most recently active view among the given samples, searching from the end of the batch /// backwards, or `nil` if no sample in the batch had a view. - private static func lastKnownView(in samples: [(viewID: String?, viewPath: String?)]) -> (id: String, path: String)? { + private static func lastKnownView( + in samples: [(viewID: String?, viewPath: String?, viewName: String?)] + ) -> (id: String, path: String, name: String?)? { for sample in samples.reversed() { if let id = sample.viewID { - return (id: id, path: sample.viewPath ?? "") + return (id: id, path: sample.viewPath ?? "", name: sample.viewName) } } return nil @@ -225,12 +235,20 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let now = Int64.ddWithNoOverflow(Date().timeIntervalSince1970 * 1_000_000_000) let viewID = cachedViewID let viewPath = cachedViewPath + let viewName = cachedViewName if let bytes = memoryReader.readVitalData() { let footprintKB = bytes / 1_024 let memoryPercent = totalRAM > 0 ? bytes / totalRAM * 100 : 0 memoryBuffer.append( - MemorySample(timestamp: now, footprintKB: footprintKB, percent: memoryPercent, viewID: viewID, viewPath: viewPath) + MemorySample( + timestamp: now, + footprintKB: footprintKB, + percent: memoryPercent, + viewID: viewID, + viewPath: viewPath, + viewName: viewName + ) ) if memoryBuffer.count >= batchSize { flushMemory() @@ -238,7 +256,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } if let cpuUsage = cpuUsageProvider() { - cpuBuffer.append(CPUSample(timestamp: now, usage: cpuUsage, viewID: viewID, viewPath: viewPath)) + cpuBuffer.append(CPUSample(timestamp: now, usage: cpuUsage, viewID: viewID, viewPath: viewPath, viewName: viewName)) if cpuBuffer.count >= batchSize { flushCPU() } @@ -257,6 +275,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let ciTest = self.ciTest let syntheticsTest = self.syntheticsTest let globalAttributes = self.globalAttributesReader?.globalAttributes ?? [:] + let sessionSampleRate = self.sessionSampleRate let start = batch[0].timestamp let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() @@ -264,7 +283,9 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { // The batch is attributed to the most recently active view among its samples, not the view active // at flush time — a view ending right before a scheduled flush shouldn't drop data that was // genuinely collected while it was active. Only dropped if no sample in the batch had a view. - guard let view = Self.lastKnownView(in: batch.map { (viewID: $0.viewID, viewPath: $0.viewPath) }) else { + guard let view = Self.lastKnownView( + in: batch.map { (viewID: $0.viewID, viewPath: $0.viewPath, viewName: $0.viewName) } + ) else { return } @@ -274,7 +295,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let adjustedStart = start + offsetNs let adjustedEnd = end + offsetNs let event = RUMTimeseriesMemoryEvent( - dd: .init(), + dd: .init(configuration: .init(sessionSampleRate: sessionSampleRate)), account: .init(context: context), application: .init(id: applicationID), buildId: context.buildId, @@ -287,7 +308,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { device: context.normalizedDevice(), os: context.os, service: context.service, - session: .init(id: sessionID, type: sessionType), + session: .init(hasReplay: context.hasReplay, id: sessionID, type: sessionType), source: .init(rawValue: context.source) ?? .ios, synthetics: syntheticsTest, timeseries: .init( @@ -304,7 +325,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { ), usr: .init(context: context), version: context.version, - view: .init(id: view.id, url: view.path) + view: .init(id: view.id, name: view.name, url: view.path) ) writer.write(value: event) } @@ -322,6 +343,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let ciTest = self.ciTest let syntheticsTest = self.syntheticsTest let globalAttributes = self.globalAttributesReader?.globalAttributes ?? [:] + let sessionSampleRate = self.sessionSampleRate let start = batch[0].timestamp let end = batch[batch.count - 1].timestamp let eventID = UUID().uuidString.lowercased() @@ -329,7 +351,9 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { // The batch is attributed to the most recently active view among its samples, not the view active // at flush time — a view ending right before a scheduled flush shouldn't drop data that was // genuinely collected while it was active. Only dropped if no sample in the batch had a view. - guard let view = Self.lastKnownView(in: batch.map { (viewID: $0.viewID, viewPath: $0.viewPath) }) else { + guard let view = Self.lastKnownView( + in: batch.map { (viewID: $0.viewID, viewPath: $0.viewPath, viewName: $0.viewName) } + ) else { return } @@ -339,7 +363,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let adjustedStart = start + offsetNs let adjustedEnd = end + offsetNs let event = RUMTimeseriesCpuEvent( - dd: .init(), + dd: .init(configuration: .init(sessionSampleRate: sessionSampleRate)), account: .init(context: context), application: .init(id: applicationID), buildId: context.buildId, @@ -352,7 +376,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { device: context.normalizedDevice(), os: context.os, service: context.service, - session: .init(id: sessionID, type: sessionType), + session: .init(hasReplay: context.hasReplay, id: sessionID, type: sessionType), source: .init(rawValue: context.source) ?? .ios, synthetics: syntheticsTest, timeseries: .init( @@ -366,7 +390,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { ), usr: .init(context: context), version: context.version, - view: .init(id: view.id, url: view.path) + view: .init(id: view.id, name: view.name, url: view.path) ) writer.write(value: event) } @@ -384,6 +408,7 @@ extension TimeseriesSessionCollector: FeatureMessageReceiver { queue.async { [weak self] in self?.cachedViewID = rum?.viewID self?.cachedViewPath = rum?.viewPath + self?.cachedViewName = rum?.viewName } return false } diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 8ae69ec410..9da7ceb1ca 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -221,6 +221,97 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(events[0].view.url, "/view/abc") } + func testWhenActiveViewHasName_itAttachesViewNameToEvent() { + // Given + memoryReader.vitalData = 1_000_000 + let rum: RUMCoreContext = .mockWith(viewID: "view-abc", viewPath: "/view/abc", viewName: "ViewController") + let scope = FeatureScopeMock(context: .mockWith(additionalContext: [rum])) + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: scope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + totalRAM: 4_000_000_000 + ) + + // When + let expectation = self.expectation(description: "batch written") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + + collector.start(sessionID: "session-view-name", applicationID: "app-view-name", sessionType: .user) + _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) + waitForExpectations(timeout: 2) + collector.stop() + + // Then + let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + XCTAssertFalse(events.isEmpty) + XCTAssertEqual(events[0].view.name, "ViewController") + } + + func testWhenSessionReplayHasReplay_itAttachesHasReplayToEvent() { + // Given + memoryReader.vitalData = 1_000_000 + let rum: RUMCoreContext = .mockAny() + let hasReplay = SessionReplayCoreContext.HasReplay(value: true) + let scope = FeatureScopeMock(context: .mockWith(additionalContext: [rum, hasReplay])) + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: scope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + totalRAM: 4_000_000_000 + ) + + // When + let expectation = self.expectation(description: "batch written") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + + collector.start(sessionID: "session-has-replay", applicationID: "app-has-replay", sessionType: .user) + _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) + waitForExpectations(timeout: 2) + collector.stop() + + // Then + let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + XCTAssertFalse(events.isEmpty) + XCTAssertEqual(events[0].session.hasReplay, true) + } + + func testItPopulatesSessionSampleRateFromConstructorInjectedValue() { + // Given + memoryReader.vitalData = 1_000_000 + let scope = FeatureScopeMock(context: .mockWith(additionalContext: [RUMCoreContext.mockAny()])) + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: scope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + totalRAM: 4_000_000_000, + sessionSampleRate: 42 + ) + + // When + let expectation = self.expectation(description: "batch written") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + + collector.start(sessionID: "session-sample-rate", applicationID: "app-sample-rate", sessionType: .user) + _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) + waitForExpectations(timeout: 2) + collector.stop() + + // Then + let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + XCTAssertFalse(events.isEmpty) + XCTAssertEqual(events[0].dd.configuration?.sessionSampleRate, 42) + } + func testWhenViewEndsRightBeforeFlush_itStillAttachesTheViewSamplesWereCollectedUnder() { // Given memoryReader.vitalData = 1_000_000 diff --git a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift index e0c49c6a99..7e298f267b 100644 --- a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift +++ b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift @@ -1844,7 +1844,8 @@ extension RUMCoreContext: AnyMockable, RandomMockable { viewID: String? = .mockAny(), userActionID: String? = nil, serverTimeOffset: TimeInterval = .mockAny(), - viewPath: String? = .mockAny() + viewPath: String? = .mockAny(), + viewName: String? = nil ) -> Self { .init( applicationID: applicationID, @@ -1853,7 +1854,8 @@ extension RUMCoreContext: AnyMockable, RandomMockable { viewID: viewID, userActionID: userActionID, viewServerTimeOffset: serverTimeOffset, - viewPath: viewPath + viewPath: viewPath, + viewName: viewName ) } @@ -1864,7 +1866,8 @@ extension RUMCoreContext: AnyMockable, RandomMockable { viewID: .mockRandom(), userActionID: .mockRandom(), serverTimeOffset: .mockRandom(), - viewPath: .mockRandom() + viewPath: .mockRandom(), + viewName: .mockRandom() ) } } From 874449110a000034573e93ccd33c7ad9d5efaa89 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 29 Jul 2026 10:55:05 +0200 Subject: [PATCH 083/102] Unify timeseries active-view and global-attributes reads behind RUMActiveContextReader --- DatadogRUM/Sources/Feature/RUMFeature.swift | 6 +- DatadogRUM/Sources/RUMMonitor/Monitor.swift | 26 ++++- .../TimeseriesSessionCollector.swift | 47 ++------- .../TimeseriesSessionCollectorTests.swift | 97 +++++++++++-------- 4 files changed, 90 insertions(+), 86 deletions(-) diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index f2c5fb6e8d..63514d207b 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -222,7 +222,7 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider dateProvider: configuration.dateProvider ) - timeseriesCollector?.globalAttributesReader = monitor + timeseriesCollector?.activeContextReader = monitor if let refreshRateVital = dependencies.vitalsReaders?.refreshRate as? RenderLoopReader { dependencies.renderLoopObserver?.register(refreshRateVital) @@ -330,10 +330,6 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider messageReceivers.append(watchdogTermination) } - if let timeseriesCollector = timeseriesCollector { - messageReceivers.append(timeseriesCollector) - } - self.messageReceiver = CombinedFeatureMessageReceiver(messageReceivers) // Forward instrumentation calls to monitor: diff --git a/DatadogRUM/Sources/RUMMonitor/Monitor.swift b/DatadogRUM/Sources/RUMMonitor/Monitor.swift index 7705a691fd..f1194d8b50 100644 --- a/DatadogRUM/Sources/RUMMonitor/Monitor.swift +++ b/DatadogRUM/Sources/RUMMonitor/Monitor.swift @@ -96,12 +96,15 @@ internal enum RUMInternalErrorSource: String, Decodable { /// A mobile-specific category of the error. It provides a high-level grouping for different types of errors. internal typealias RUMErrorCategory = RUMErrorEvent.Error.Category -/// Exposes the monitor's global custom attributes for readers that operate outside the `RUMCommand` pipeline -/// (e.g. the timer-driven `TimeseriesSessionCollector`), which otherwise have no access to `command.globalAttributes`. -internal protocol GlobalAttributesReader: AnyObject { +/// Exposes monitor state for readers that operate outside the `RUMCommand` pipeline +/// (e.g. the timer-driven `TimeseriesSessionCollector`), which otherwise have no access to +/// `command.globalAttributes` or the scope tree's active view. +internal protocol RUMActiveContextReader: AnyObject { /// The current global attributes set through `addAttribute(forKey:value:)` / `addAttributes(_:)`. /// Safe to read from any thread. var globalAttributes: [AttributeKey: AttributeValue] { get } + /// The currently active view, if any. Safe to read from any thread. + var activeView: (id: String?, path: String?, name: String?) { get } } internal class Monitor: RUMCommandSubscriber { @@ -116,6 +119,9 @@ internal class Monitor: RUMCommandSubscriber { @ReadWriteLock private var attributes: [AttributeKey: AttributeValue] = [:] + @ReadWriteLock + private var activeViewSnapshot: (id: String?, path: String?, name: String?) = (nil, nil, nil) + private let fatalErrorContext: FatalErrorContextNotifying private let rumUUIDGenerator: RUMUUIDGenerator private let telemetry: Telemetry @@ -148,6 +154,17 @@ internal class Monitor: RUMCommandSubscriber { if let debugging = self.debugging { debugging.debug(applicationScope: self.applicationScope) } + + if let activeSession = self.applicationScope.activeSession { + let viewContext = activeSession.viewScopes.last?.context ?? activeSession.context + self.activeViewSnapshot = ( + id: viewContext.activeViewID?.toRUMDataFormat, + path: viewContext.activeViewPath, + name: viewContext.activeViewName + ) + } else { + self.activeViewSnapshot = (nil, nil, nil) + } } // update the core context with rum context @@ -200,8 +217,9 @@ internal class Monitor: RUMCommandSubscriber { } } -extension Monitor: GlobalAttributesReader { +extension Monitor: RUMActiveContextReader { var globalAttributes: [AttributeKey: AttributeValue] { attributes } + var activeView: (id: String?, path: String?, name: String?) { activeViewSnapshot } } /// Declares `Monitor` conformance to public `RUMMonitorProtocol`. diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 6646361532..c7d14dabae 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -50,9 +50,10 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private let syntheticsTest: RUMSyntheticsTest? private let sessionSampleRate: Double - /// Provides global custom attributes at flush time. Set by `RUMFeature` once `Monitor` is constructed, - /// since the collector is created before it. `Monitor.globalAttributes` is safe to read from any thread. - weak var globalAttributesReader: GlobalAttributesReader? + /// Provides global custom attributes and the active view at sample time. Set by `RUMFeature` once `Monitor` + /// is constructed, since the collector is created before it. `Monitor`'s conformance is safe to read from + /// any thread. + weak var activeContextReader: RUMActiveContextReader? private var memoryBuffer: [MemorySample] = [] private var cpuBuffer: [CPUSample] = [] @@ -62,12 +63,6 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private var timer: DispatchSourceTimer? private var isPaused: Bool = false - /// The most recently known active view, updated from the message bus on every core context change. - /// Cached rather than read at flush time so a batch isn't dropped just because its view ended right before flush. - private var cachedViewID: String? - private var cachedViewPath: String? - private var cachedViewName: String? - /// All buffer mutations and timer events run on this queue. private let queue = DispatchQueue(label: "com.datadoghq.timeseries-collector", qos: .utility) @@ -152,9 +147,6 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.sessionType = sessionType self.memoryBuffer = [] self.cpuBuffer = [] - self.cachedViewID = nil - self.cachedViewPath = nil - self.cachedViewName = nil self.isPaused = false self.timer?.cancel() @@ -202,9 +194,6 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.isPaused = false self.flushMemory() self.flushCPU() - self.cachedViewID = nil - self.cachedViewPath = nil - self.cachedViewName = nil } } @@ -233,9 +222,10 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private func sample() { let now = Int64.ddWithNoOverflow(Date().timeIntervalSince1970 * 1_000_000_000) - let viewID = cachedViewID - let viewPath = cachedViewPath - let viewName = cachedViewName + let activeView = activeContextReader?.activeView + let viewID = activeView?.id + let viewPath = activeView?.path + let viewName = activeView?.name if let bytes = memoryReader.readVitalData() { let footprintKB = bytes / 1_024 @@ -274,7 +264,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let sessionType = self.sessionType let ciTest = self.ciTest let syntheticsTest = self.syntheticsTest - let globalAttributes = self.globalAttributesReader?.globalAttributes ?? [:] + let globalAttributes = self.activeContextReader?.globalAttributes ?? [:] let sessionSampleRate = self.sessionSampleRate let start = batch[0].timestamp let end = batch[batch.count - 1].timestamp @@ -342,7 +332,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let sessionType = self.sessionType let ciTest = self.ciTest let syntheticsTest = self.syntheticsTest - let globalAttributes = self.globalAttributesReader?.globalAttributes ?? [:] + let globalAttributes = self.activeContextReader?.globalAttributes ?? [:] let sessionSampleRate = self.sessionSampleRate let start = batch[0].timestamp let end = batch[batch.count - 1].timestamp @@ -396,20 +386,3 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } } } - -extension TimeseriesSessionCollector: FeatureMessageReceiver { - /// Receives the currently active view from the core context message and caches it for tagging future samples. - /// - Returns: Always `false`, because it doesn't block message propagation to other receivers. - func receive(message: FeatureMessage, from core: DatadogCoreProtocol) -> Bool { - guard case .context(let context) = message else { - return false - } - let rum = context.additionalContext(ofType: RUMCoreContext.self) - queue.async { [weak self] in - self?.cachedViewID = rum?.viewID - self?.cachedViewPath = rum?.viewPath - self?.cachedViewName = rum?.viewName - } - return false - } -} diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 9da7ceb1ca..09c3c80fe9 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -28,6 +28,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { cpuUsageProvider: { nil }, totalRAM: 4_000_000_000 ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader // When let expectation = self.expectation(description: "memory batch written") @@ -35,7 +37,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-abc", applicationID: "app-123", sessionType: .user) - _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -67,6 +68,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { samplingInterval: 0.05, cpuUsageProvider: { 42.5 } ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader // When let expectation = self.expectation(description: "cpu batch written") @@ -74,7 +77,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-abc", applicationID: "app-123", sessionType: .user) - _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -105,6 +107,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { cpuUsageProvider: { 75.0 }, totalRAM: 4_000_000_000 ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader // When let expectation = self.expectation(description: "both batches written") @@ -112,7 +116,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-both", applicationID: "app-both", sessionType: .user) - _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -140,6 +143,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { cpuUsageProvider: { nil }, totalRAM: 4_000_000_000 ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader // When let expectation = self.expectation(description: "batch written") @@ -147,7 +152,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-offset", applicationID: "app-offset", sessionType: .user) - _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -173,6 +177,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { cpuUsageProvider: { nil }, totalRAM: 4_000_000_000 ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader // When let expectation = self.expectation(description: "batch written") @@ -180,7 +186,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-rn", applicationID: "app-rn", sessionType: .user) - _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -193,8 +198,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { func testWhenActiveViewExists_itAttachesViewToEvent() { // Given memoryReader.vitalData = 1_000_000 - let rum: RUMCoreContext = .mockWith(viewID: "view-abc", viewPath: "/view/abc") - let scope = FeatureScopeMock(context: .mockWith(additionalContext: [rum])) + let scope = FeatureScopeMock(context: .mockWith(additionalContext: [])) let collector = TimeseriesSessionCollector( memoryReader: memoryReader, featureScope: scope, @@ -203,6 +207,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { cpuUsageProvider: { nil }, totalRAM: 4_000_000_000 ) + let contextReader = RUMActiveContextReaderMock(activeView: (id: "view-abc", path: "/view/abc", name: nil)) + collector.activeContextReader = contextReader // When let expectation = self.expectation(description: "batch written") @@ -210,7 +216,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-view", applicationID: "app-view", sessionType: .user) - _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -224,8 +229,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { func testWhenActiveViewHasName_itAttachesViewNameToEvent() { // Given memoryReader.vitalData = 1_000_000 - let rum: RUMCoreContext = .mockWith(viewID: "view-abc", viewPath: "/view/abc", viewName: "ViewController") - let scope = FeatureScopeMock(context: .mockWith(additionalContext: [rum])) + let scope = FeatureScopeMock(context: .mockWith(additionalContext: [])) let collector = TimeseriesSessionCollector( memoryReader: memoryReader, featureScope: scope, @@ -234,6 +238,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { cpuUsageProvider: { nil }, totalRAM: 4_000_000_000 ) + let contextReader = RUMActiveContextReaderMock(activeView: (id: "view-abc", path: "/view/abc", name: "ViewController")) + collector.activeContextReader = contextReader // When let expectation = self.expectation(description: "batch written") @@ -241,7 +247,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-view-name", applicationID: "app-view-name", sessionType: .user) - _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -254,9 +259,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { func testWhenSessionReplayHasReplay_itAttachesHasReplayToEvent() { // Given memoryReader.vitalData = 1_000_000 - let rum: RUMCoreContext = .mockAny() let hasReplay = SessionReplayCoreContext.HasReplay(value: true) - let scope = FeatureScopeMock(context: .mockWith(additionalContext: [rum, hasReplay])) + let scope = FeatureScopeMock(context: .mockWith(additionalContext: [hasReplay])) let collector = TimeseriesSessionCollector( memoryReader: memoryReader, featureScope: scope, @@ -265,6 +269,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { cpuUsageProvider: { nil }, totalRAM: 4_000_000_000 ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader // When let expectation = self.expectation(description: "batch written") @@ -272,7 +278,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-has-replay", applicationID: "app-has-replay", sessionType: .user) - _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -295,6 +300,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { totalRAM: 4_000_000_000, sessionSampleRate: 42 ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader // When let expectation = self.expectation(description: "batch written") @@ -302,7 +309,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-sample-rate", applicationID: "app-sample-rate", sessionType: .user) - _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -315,8 +321,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { func testWhenViewEndsRightBeforeFlush_itStillAttachesTheViewSamplesWereCollectedUnder() { // Given memoryReader.vitalData = 1_000_000 - let rum: RUMCoreContext = .mockWith(viewID: "view-ending", viewPath: "/view/ending") - let scope = FeatureScopeMock(context: .mockWith(additionalContext: [rum])) + let scope = FeatureScopeMock(context: .mockWith(additionalContext: [])) let collector = TimeseriesSessionCollector( memoryReader: memoryReader, featureScope: scope, @@ -325,18 +330,18 @@ class TimeseriesSessionCollectorTests: XCTestCase { cpuUsageProvider: { nil }, totalRAM: 4_000_000_000 ) + let contextReader = RUMActiveContextReaderMock(activeView: (id: "view-ending", path: "/view/ending", name: nil)) + collector.activeContextReader = contextReader // When — samples are collected while a view is active let expectation = self.expectation(description: "samples collected under an active view") expectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } collector.start(sessionID: "session-ending-view", applicationID: "app-ending-view", sessionType: .user) - _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) // The view ends right before the batch is flushed - scope.contextMock = .mockWith(additionalContext: []) - _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) + contextReader.activeView = (nil, nil, nil) let syncExpectation = self.expectation(description: "stop completed") collector.stop() @@ -363,7 +368,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { totalRAM: 4_000_000_000 ) - // When + // When — no `activeContextReader` set, so there is no active view to report let expectation = self.expectation(description: "samples collected") expectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } @@ -387,13 +392,14 @@ class TimeseriesSessionCollectorTests: XCTestCase { cpuUsageProvider: { nil }, totalRAM: 4_000_000_000 ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader // Accumulate some samples in session-1 let firstExpectation = self.expectation(description: "first session samples") firstExpectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { firstExpectation.fulfill() } collector.start(sessionID: "session-1", applicationID: "app-1", sessionType: .user) - _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) // Start session-2 without calling stop() first @@ -421,6 +427,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { samplingInterval: 0.05, cpuUsageProvider: { nil } ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader // When — let a few samples accumulate then stop let expectation = self.expectation(description: "samples collected") @@ -428,7 +436,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } collector.start(sessionID: "session-xyz", applicationID: "app-456", sessionType: .synthetics) - _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) let syncExpectation = self.expectation(description: "stop completed") @@ -456,13 +463,14 @@ class TimeseriesSessionCollectorTests: XCTestCase { samplingInterval: 0.05, cpuUsageProvider: { 10.0 } ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader let expectation = self.expectation(description: "samples collected") expectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } collector.start(sessionID: "session-xyz", applicationID: "app-456", sessionType: .ciTest) - _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) let syncExpectation = self.expectation(description: "stop completed") @@ -515,13 +523,14 @@ class TimeseriesSessionCollectorTests: XCTestCase { samplingInterval: 0.05, cpuUsageProvider: { nil } ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader // First session let firstExpectation = self.expectation(description: "first session samples") firstExpectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { firstExpectation.fulfill() } collector.start(sessionID: "session-1", applicationID: "app-1", sessionType: .user) - _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) // Second session — start() resets buffers and updates metadata @@ -529,7 +538,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { secondExpectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { secondExpectation.fulfill() } collector.start(sessionID: "session-2", applicationID: "app-1", sessionType: .user) - _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) // Flush second session @@ -556,13 +564,14 @@ class TimeseriesSessionCollectorTests: XCTestCase { samplingInterval: 0.05, cpuUsageProvider: { nil } ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader let samplingExpectation = self.expectation(description: "initial samples collected") samplingExpectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { samplingExpectation.fulfill() } collector.start(sessionID: "session-pause", applicationID: "app-1", sessionType: .user) - _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) let countBeforePause = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count @@ -593,9 +602,10 @@ class TimeseriesSessionCollectorTests: XCTestCase { samplingInterval: 0.05, cpuUsageProvider: { nil } ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader collector.start(sessionID: "session-resume", applicationID: "app-1", sessionType: .user) - _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) let pauseExpectation = self.expectation(description: "pause settled") collector.pause() @@ -628,13 +638,14 @@ class TimeseriesSessionCollectorTests: XCTestCase { collectInBackground: true, cpuUsageProvider: { nil } ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader let startExpectation = self.expectation(description: "initial samples") startExpectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { startExpectation.fulfill() } collector.start(sessionID: "session-bg", applicationID: "app-1", sessionType: .user) - _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) let countBeforePause = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count @@ -687,7 +698,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { buildId: "build-abc", userInfo: userInfo, accountInfo: accountInfo, - additionalContext: [RUMCoreContext.mockAny()] + additionalContext: [] ) ) let ciTest = RUMCITest(testExecutionId: "ci-exec-abc") @@ -702,6 +713,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { ciTest: ciTest, syntheticsTest: syntheticsTest ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader // When let expectation = self.expectation(description: "batch written") @@ -709,7 +722,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-common", applicationID: "app-common", sessionType: .user) - _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -728,10 +740,10 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(event.synthetics?.testId, "synthetics-test") } - func testItPopulatesContextFromGlobalAttributesReader() { + func testItPopulatesContextFromActiveContextReader() { // Given memoryReader.vitalData = 1_000_000 - let scope = FeatureScopeMock(context: .mockWith(additionalContext: [RUMCoreContext.mockAny()])) + let scope = FeatureScopeMock(context: .mockWith(additionalContext: [])) let collector = TimeseriesSessionCollector( memoryReader: memoryReader, featureScope: scope, @@ -739,8 +751,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { samplingInterval: 0.05, cpuUsageProvider: { nil } ) - let attributesReader = GlobalAttributesReaderMock(globalAttributes: ["custom-key": "custom-value"]) - collector.globalAttributesReader = attributesReader + let contextReader = RUMActiveContextReaderMock(globalAttributes: ["custom-key": "custom-value"]) + collector.activeContextReader = contextReader // When let expectation = self.expectation(description: "batch written") @@ -748,7 +760,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-context", applicationID: "app-context", sessionType: .user) - _ = collector.receive(message: .context(scope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -770,13 +781,14 @@ class TimeseriesSessionCollectorTests: XCTestCase { samplingInterval: 0.05, cpuUsageProvider: { nil } ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader let expectation = self.expectation(description: "first batch written") expectation.assertForOverFulfill = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } collector.start(sessionID: "session-abc", applicationID: "app-123", sessionType: .user) - _ = collector.receive(message: .context(featureScope.contextMock), from: NOPDatadogCore()) waitForExpectations(timeout: 2) collector.stop() @@ -793,11 +805,16 @@ class TimeseriesSessionCollectorTests: XCTestCase { } } -private class GlobalAttributesReaderMock: GlobalAttributesReader { - let globalAttributes: [AttributeKey: AttributeValue] +private class RUMActiveContextReaderMock: RUMActiveContextReader { + var globalAttributes: [AttributeKey: AttributeValue] + var activeView: (id: String?, path: String?, name: String?) - init(globalAttributes: [AttributeKey: AttributeValue]) { + init( + globalAttributes: [AttributeKey: AttributeValue] = [:], + activeView: (id: String?, path: String?, name: String?) = (.mockAny(), .mockAny(), nil) + ) { self.globalAttributes = globalAttributes + self.activeView = activeView } } From bf1c4e27ad13d3d1978075685e423c3366320ab4 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 29 Jul 2026 14:51:12 +0200 Subject: [PATCH 084/102] Remove leftover DatadogTimeseries POC planning and demo scripts --- ...26-04-21-timeseries-android-integration.md | 95 ----- plans/ipcivr-20260423-delta-compression.md | 57 --- pod/demo/01-demo-18-04.sh | 311 ---------------- pod/plans/1-plan-business-logic.md | 344 ------------------ pod/plans/2-plan-platform-integration.md | 182 --------- pod/plans/3-plan-sampling-filters.md | 256 ------------- pod/plans/4-plan-ios-sdk-integration.md | 239 ------------ pod/plans/git-rules-pod.md | 47 --- 8 files changed, 1531 deletions(-) delete mode 100644 plans/ipcivr-2026-04-21-timeseries-android-integration.md delete mode 100644 plans/ipcivr-20260423-delta-compression.md delete mode 100755 pod/demo/01-demo-18-04.sh delete mode 100644 pod/plans/1-plan-business-logic.md delete mode 100644 pod/plans/2-plan-platform-integration.md delete mode 100644 pod/plans/3-plan-sampling-filters.md delete mode 100644 pod/plans/4-plan-ios-sdk-integration.md delete mode 100644 pod/plans/git-rules-pod.md diff --git a/plans/ipcivr-2026-04-21-timeseries-android-integration.md b/plans/ipcivr-2026-04-21-timeseries-android-integration.md deleted file mode 100644 index f43102e7c3..0000000000 --- a/plans/ipcivr-2026-04-21-timeseries-android-integration.md +++ /dev/null @@ -1,95 +0,0 @@ -# IPCIVR Plan — Android Timeseries SDK Integration (2026-04-21) - -## Goal - -Wire memory + CPU timeseries collection into the Android RUM session lifecycle, mirroring iOS Plan 4. -Collects samples at 1s intervals, batches 30 samples, writes `RumTimeseriesMemoryEvent` / -`RumTimeseriesCpuEvent` to the RUM feature scope on `bplasovska/feature/timeseries`. - -## Decisions - -- Self-contained `TimeseriesSessionCollector` inside `dd-sdk-android-rum` (not reusing DatadogTimeseries module — that was a pipeline testbed) -- CPU % via `/proc/self/stat` delta between consecutive 1s ticks, injectable via `cpuUsageProvider: (() -> Double?)?` lambda for test isolation -- Memory via `MemoryVitalReader.readVitalData()` (already returns bytes as Double) -- Models generated from `bplasovska/timeseries` branch on rum-events-format -- `enableTimeseries: Boolean = false` opt-in flag in `RumConfiguration`; also requires `vitalsUpdateFrequency != null` -- Session hook: `collector.start()` in `renewSession()` / initial tracked state; `collector.stop()` in `stopSession()` -- Dedicated `ScheduledExecutorService` via `sdkCore.createScheduledExecutorService("rum-timeseries")`; `shutdownNow()` + `NoOpScheduledExecutorService()` on stop -- `synchronized` blocks for buffer thread safety (SDK convention) -- `EventType.DEFAULT` for event writing -- Android `RumSessionType` only has `USER` / `SYNTHETICS` (no `CI_TEST`) - -## Task List - -### Step 0 — Model generation -- Add timeseries schema mappings to `features/dd-sdk-android-rum/generate_rum_models.gradle.kts` -- Run: `./gradlew :features:dd-sdk-android-rum:generateRumModelsFromJson -Pdd.rum.schema.ref=bplasovska/timeseries` -- Models land in `build/generated/json2kotlin/` — NOT committed to source (build-time generation) -- Add `TIMESERIES_BUILD.md` in the module root documenting the required flag for anyone building this branch -- **Must run this step before writing any code that references the generated classes** - -### Step 1 — TimeseriesCollecting interface -- New file: `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/TimeseriesCollecting.kt` -- Methods: `fun start(sessionId: String, applicationId: String, sessionType: RumSessionType)` + `fun stop()` - -### Step 2 — TimeseriesSessionCollector -- New file: `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/TimeseriesSessionCollector.kt` -- Constructor: `memoryReader: VitalReader`, `writer: DataWriter`, `sdkCore: SdkCore`, `batchSize: Int = 30`, `samplingIntervalMs: Long = 1000`, `cpuUsageProvider: (() -> Double?)? = null` -- Default `cpuUsageProvider`: reads `/proc/self/stat` delta using `existsSafe()` / `readTextSafe()` helpers + `Os.sysconf(_SC_CLK_TCK)` for normalization -- **Pre-warm CPU in `start()`**: read `/proc/self/stat` once at end of `start()` to set `prevCpuTicks` — first 1s tick then has a valid delta (no wasted sample) -- `ScheduledExecutorService` via `sdkCore.createScheduledExecutorService("rum-timeseries")` — new executor created on `start()`, `shutdownNow()` + replaced with `NoOpScheduledExecutorService()` on `stop()` -- Memory buffer + CPU buffer, flush at `batchSize = 30` or on `stop()` -- Write via `DataWriter.write(event, null, EventType.DEFAULT)` -- **Thread safety**: `synchronized(this)` wraps the entire `sample()` body, `flushMemory()`, `flushCPU()`, and the flush calls inside `stop()` — prevents race between in-flight sample and stop flush - -### Step 3 — RumSessionTypeExt -- Add `fun RumSessionType.toTimeseriesMemory(): RumTimeseriesMemoryEvent.Session.Type` in `RumSessionTypeExt.kt` -- Add `fun RumSessionType.toTimeseriesCpu(): RumTimeseriesCpuEvent.Session.Type` in `RumSessionTypeExt.kt` - -### Step 4 — Serializer registration -- Add two `is` branches in `RumEventSerializer.serialize()` (the existing `when` expression): - - `is RumTimeseriesMemoryEvent -> model.toJson().toString()` - - `is RumTimeseriesCpuEvent -> model.toJson().toString()` -- No separate registration infrastructure needed — generated models already have `toJson()` from the GSON-based code generator - -### Step 5 — RumConfiguration flag -- Add `enableTimeseries: Boolean = false` to `RumConfiguration` (or `Rum.Configuration`) - -### Step 6 — RumFeature factory -- Create collector only when `enableTimeseries = true` and `vitalsUpdateFrequency != null` -- Create dedicated executor `"rum-timeseries"` -- Pass collector to `RumScopeDependencies` - -### Step 7 — RumSessionScope hookup -- **ALL session starts go through `renewSession()`** — confirmed from code: even the first session (isNewSession=true) calls `renewSession()` with `USER_APP_LAUNCH` -- At the **top** of `renewSession()`: if `sessionState == TRACKED`, call `collector.stop()` (stops previous session before renewing) -- At the **bottom** of `renewSession()`: if `keepSession == true`, call `collector.start(sessionId, applicationId, sessionType)` -- In `stopSession()`: call `collector.stop()` -- Guard all calls with null check; `stop()` must be idempotent (safe to call twice) - -### Step 8 — Unit tests -- New file: `features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/timeseries/TimeseriesSessionCollectorTest.kt` -- Use Mockito `@Mock lateinit var mockMemoryReader: VitalReader` and `@Mock lateinit var mockWriter: DataWriter` -- Mirror 7 iOS test cases: batch flush memory, batch flush CPU, partial flush on stop (memory), partial flush on stop (CPU), nil readers write no events, session restart uses new metadata, timestamps monotonically increasing -- Injectable `cpuUsageProvider` lambda for fixed CPU values - -### Step 9 — Forgery factory -- New file: `features/dd-sdk-android-rum/src/testFixtures/kotlin/com/datadog/android/rum/utils/forge/TimeseriesEventForgeryFactory.kt` - -## Key file paths - -| Purpose | Path | -|---------|------| -| Collector | `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/TimeseriesSessionCollector.kt` | -| Interface | `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/TimeseriesCollecting.kt` | -| Session scope | `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt` | -| RumFeature | `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt` | -| SessionTypeExt | `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumSessionTypeExt.kt` | -| Model generation config | `features/dd-sdk-android-rum/generate_rum_models.gradle.kts` | -| VitalReader helpers | `dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/FileExt.kt` | -| NoOpExecutor | `features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/thread/NoOpScheduledExecutorService.kt` | - -## Verification strategy - -- `./gradlew :features:dd-sdk-android-rum:test` — all unit tests pass -- Manual RocketLauncher (Android) run with `enableTimeseries = true` to confirm events reach backend diff --git a/plans/ipcivr-20260423-delta-compression.md b/plans/ipcivr-20260423-delta-compression.md deleted file mode 100644 index d5c27a3aab..0000000000 --- a/plans/ipcivr-20260423-delta-compression.md +++ /dev/null @@ -1,57 +0,0 @@ -# IPCIVR Plan — Delta Compression for Timeseries -**Date:** 2026-04-23 - -## Idea Summary -- Add `enableDeltaCompression: Bool = false` to `TimeseriesSessionCollector` on both iOS and Android -- When `true`, replace the normal `data: [{timestamp, data_point}]` array with a columnar delta object `{precision:4, ts:[...], field:[...]}` for both memory and CPU flushes -- Precision hardcoded to 4 — floats multiplied by `10^4`, stored as integer deltas -- Single-sample batches are dropped (no degenerate delta objects sent) -- Each flush logs `[Timeseries] delta flush: signal=X normal=YB delta=ZB ratio=Wx` for staging comparison -- Flag defaults `false` — demo path completely unaffected; staged override set locally only (not committed) - -## Decisions -- Flag location: `TimeseriesSessionCollector` constructor (not RUM config) -- Scope: both memory and CPU signals -- Precision: hardcoded `4` -- iOS serialization: encode normal event → patch `[String:Any]` dict → write via `AnyEncodable` wrapper (avoids envelope duplication) -- Android serialization: call `event.toJson()` → patch `timeseries.data` field in resulting `JsonObject` (reuses generated serialization) -- Encoder types: all scaled values as `Int64` (Swift) / `Long` (Kotlin) — enforced by type signature to prevent overflow for large `memory_max` values -- Size comparison log: `#if DEBUG` guarded on iOS, debug build check on Android — zero overhead in release builds -- Encoder location: iOS → `DatadogRUM/Sources/Timeseries/`, Android → same package as collector -- Tests: encoder unit tests + collector flush output tests (both modes) - -## Tasks - -### iOS -1. `DeltaEncoder.swift` — pure static `encodeMemory(_:precision:)` and `encodeCPU(_:precision:)` returning `[String: Any]?` (nil for ≤1 sample) -2. `DeltaTimeseriesEvent.swift` — `DeltaTimeseriesMemoryEvent` and `DeltaTimeseriesCpuEvent` Encodable structs with full RUM envelope + delta data field -3. Add `enableDeltaCompression: Bool = false` to `TimeseriesSessionCollector.init` -4. `flushMemory()` — branch on flag: call encoder, drop if nil, write `DeltaTimeseriesMemoryEvent`, log size -5. `flushCPU()` — same pattern -6. `DeltaEncoderTests.swift` — 3-sample batch assertions: ts[0] absolute, ts[1..2] deltas, fields scaled + delta'd -7. `TimeseriesSessionCollectorTests.swift` — delta mode cases: JSON shape assert, single-sample drop - -### Android -8. `DeltaEncoder.kt` — `encodeMemory(buffer, precision): JsonObject?` and `encodeCpu(buffer, precision): JsonObject?`, null for ≤1 sample -9. Add `enableDeltaCompression: Boolean = false` to `TimeseriesSessionCollector` constructor -10. `flushMemoryBatch()` — branch on flag: encoder, skip if null, manual `JsonObject`, write, log -11. `flushCpuBatch()` — same -12. `DeltaEncoderTest.kt` — same assertions, Kotlin style -13. `TimeseriesSessionCollectorTest.kt` — delta mode cases - -### Wrap-up -14. iOS linter + tests (`DatadogRUM iOS`) -15. Android tests (`TimeseriesSessionCollectorTest`) -16. Export pantry notes (`/nono:export --timeseries`) - -## Verification Strategy -1. **Unit tests** — `DeltaEncoderTests` with known 3-sample batches, exact Int64 delta assertions. Collector flush tests with `enableDeltaCompression=true` assert delta JSON shape and single-sample drop. Runs automatically via `make test-ios SCHEME="DatadogRUM iOS"` and Android test suite. -2. **Instrumented size logs** — Enable flag locally, run sample app for ~1 min, confirm `[Timeseries] delta flush:` lines appear in console with `ratio > 1x`. -3. **Staging event inspection** — Capture raw intake payloads in staging, confirm `timeseries.data` is the columnar delta object (not an array). - -## Status -- [ ] Phase 3: Criticism -- [ ] Phase 4: Verification strategy -- [ ] Phase 5: Implementation -- [ ] Phase 6: Verification -- [ ] Phase 7: Report diff --git a/pod/demo/01-demo-18-04.sh b/pod/demo/01-demo-18-04.sh deleted file mode 100755 index 9e44cc33f8..0000000000 --- a/pod/demo/01-demo-18-04.sh +++ /dev/null @@ -1,311 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ============================================================================ -# Performance Timeseries — Plan 1 + Sampling Demo -# AI-first POD | Week 1 | April 18, 2026 -# ============================================================================ -# -# Configure these paths for your machine: -# -IOS_SDK_PATH="$HOME/go/src/github.com/DataDog/dd-sdk-ios" -ANDROID_SDK_PATH="$HOME/dd/sdks/dd-sdk-android" -JAVA_HOME_PATH="/Applications/Android Studio.app/Contents/jbr/Contents/Home" -# -# ============================================================================ - -BOLD="\033[1m" -DIM="\033[2m" -GREEN="\033[32m" -CYAN="\033[36m" -YELLOW="\033[33m" -RESET="\033[0m" - -separator() { - echo "" - echo -e "${DIM}────────────────────────────────────────────────────────────${RESET}" - echo "" -} - -heading() { - echo -e "${BOLD}${CYAN}$1${RESET}" -} - -subheading() { - echo -e "${BOLD}$1${RESET}" -} - -narrate() { - echo -e "${DIM}$1${RESET}" -} - -pause() { - echo "" - read -r -p " [press enter to continue]" - echo "" -} - -IOS_PACKAGE="$IOS_SDK_PATH/DatadogTimeseries" - -# ============================================================================ -clear -echo "" -heading " PERFORMANCE TIMESERIES — Week 1" -echo "" -narrate " AI-first POD | Sprint 1 | RUM-13949" -narrate " Barbora Plasovska | April 18, 2026" -separator - -heading "This Week" -echo "" -echo " 1. Built and verified the business logic pipeline — a standalone," -echo " platform-agnostic package that takes timestamped performance samples" -echo " and produces complete RUM timeseries JSON events." -echo " Tested on both iOS (Swift) and Android (Kotlin)." -echo "" -echo " 2. Researched sampling strategies and algorithms — how much data can" -echo " we cut without losing meaningful signal." -pause - -# ============================================================================ -separator -heading "1/2 Business Logic Pipeline" -echo "" -echo " Built a standalone, platform-agnostic pipeline that implements" -echo " the core business logic of the timeseries feature." -echo "" -echo " What the pipeline does:" -echo " Takes timestamped performance samples (memory, CPU — sampled every 1s)" -echo " and transforms them into complete RUM timeseries JSON events." -echo "" -echo " Samples --> Batcher --> Event Builder --> JSON Encoder --> RUM JSON" -echo "" -echo " Zero SDK dependencies — pure logic, tested on both iOS (Swift)" -echo " and Android (Kotlin) against the same expected fixtures." -echo " This is the foundation everything else builds on." -pause - -separator -narrate " iOS — dd-sdk-ios/DatadogTimeseries/" -echo "" - -if [ ! -d "$IOS_PACKAGE" ]; then - echo -e " ${YELLOW}Skipped — $IOS_PACKAGE not found${RESET}" -else - cd "$IOS_PACKAGE" - TEST_OUTPUT=$(swift test 2>&1) - PASSED=$(echo "$TEST_OUTPUT" | grep -E "Executed [0-9]+ tests" | tail -1 || true) - if [ -n "$PASSED" ]; then - echo -e " ${GREEN}✓ $PASSED${RESET}" - else - echo -e " ${GREEN}✓ Tests passed${RESET}" - fi -fi - -echo "" -narrate " Android — dd-sdk-android/DatadogTimeseries/" -echo "" - -ANDROID_PACKAGE="$ANDROID_SDK_PATH/DatadogTimeseries" -if [ ! -d "$ANDROID_PACKAGE" ]; then - echo -e " ${YELLOW}Skipped — $ANDROID_PACKAGE not found${RESET}" -else - cd "$ANDROID_PACKAGE" - GRADLE_OUTPUT=$(JAVA_HOME="$JAVA_HOME_PATH" ./gradlew cleanTest test 2>&1) - PASSED_COUNT=$(echo "$GRADLE_OUTPUT" | grep -c " PASSED" || true) - FAILED_COUNT=$(echo "$GRADLE_OUTPUT" | grep -c " FAILED" || true) - if [ "$PASSED_COUNT" -gt 0 ]; then - echo -e " ${GREEN}✓ Executed $PASSED_COUNT tests, with $FAILED_COUNT failures${RESET}" - else - SUMMARY=$(echo "$GRADLE_OUTPUT" | grep -E "[0-9]+ tests completed" | tail -1 || true) - if [ -n "$SUMMARY" ]; then - echo -e " ${GREEN}✓ $SUMMARY${RESET}" - else - echo -e " ${GREEN}✓ Tests passed${RESET}" - fi - fi -fi -pause - -separator -echo " Both platforms verify against the SAME expected JSON fixtures." -echo " Same business logic → identical RUM events regardless of platform." -echo "" - -FIXTURE_IOS="$IOS_SDK_PATH/DatadogTimeseries/Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json" -FIXTURE_ANDROID="$ANDROID_SDK_PATH/DatadogTimeseries/src/test/resources/fixtures/expected_memory_batch1.json" - -if [ -f "$FIXTURE_IOS" ] && [ -f "$FIXTURE_ANDROID" ]; then - if diff -q "$FIXTURE_IOS" "$FIXTURE_ANDROID" > /dev/null 2>&1; then - echo -e " ${GREEN}iOS and Android fixtures are identical${RESET}" - else - echo -e " ${YELLOW}Fixtures differ (check manually)${RESET}" - fi - - echo "" - subheading " Sample event (memory_usage, batch 1):" - echo "" - if command -v python3 > /dev/null 2>&1; then - python3 -m json.tool "$FIXTURE_IOS" 2>/dev/null | head -30 | sed 's/^/ /' - LINES=$(python3 -m json.tool "$FIXTURE_IOS" 2>/dev/null | wc -l) - if [ "$LINES" -gt 30 ]; then - narrate " ... ($(( LINES - 30 )) more lines)" - fi - else - cat "$FIXTURE_IOS" | sed 's/^/ /' - fi -else - narrate " (fixture files not found — skipping comparison)" -fi -pause - -# ============================================================================ -separator -heading "2/2 Sampling Strategies" -echo "" -echo " As of right now, we collect one sample per second." -echo "" -echo " For a 30-minute session:" -echo " memory_usage → 1,800 data points" -echo " cpu_usage → 1,800 data points" -echo " total → 3,600 data points per session" -echo "" -echo " Most of that data is redundant." -echo " Memory barely moves when the app is idle." -echo " CPU is noisy — individual spikes matter, not every tick." -echo "" -echo " Can we send less data without losing meaningful signal?" -pause - -separator -subheading " Three Strategies" -echo "" -echo " 1. PassThrough (current baseline)" -echo " Every sample is forwarded. 60 samples in → 60 data points out." -echo " No intelligence — maximum data, maximum cost." -echo "" -echo " 2. Deadband" -echo " Only emit a sample when the value has changed by more than a threshold." -echo " Memory sits at 31MB for 10 seconds → send nothing." -echo " Memory jumps to 33MB → emit." -echo " Good for: memory (slow-changing, allocation-driven)" -echo "" -echo " 3. Window Aggregate" -echo " Collapse a time window into one representative value (max, avg, min)." -echo " 10 CPU samples over 5 seconds → emit the peak." -echo " Good for: CPU (noisy, burst-driven)" -pause - -separator -narrate " Running all 3 against a 60-second fixture (60 samples per metric)..." -echo "" - -if [ ! -d "$IOS_PACKAGE" ]; then - echo -e " ${YELLOW}Skipped — $IOS_PACKAGE not found${RESET}" - pause -else - cd "$IOS_PACKAGE" - - FIXTURE="$IOS_PACKAGE/Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv" - OUTPUT_DIR="$IOS_PACKAGE/output" - rm -rf "$OUTPUT_DIR" && mkdir -p "$OUTPUT_DIR" - - RUNNER_TMP="$(mktemp /tmp/ts-runner-output.XXXXXX.json)" - trap 'rm -f "$RUNNER_TMP"' EXIT - - swift run DatadogTimeseriesRunner -- \ - --fixture-path "$FIXTURE" \ - --output-dir "$OUTPUT_DIR" \ - --threshold 1000000 \ - --heartbeat 30 \ - --window 5 \ - --aggregate max > "$RUNNER_TMP" 2>/dev/null - - echo -e " ${GREEN}✓ Pipeline complete${RESET}" - echo "" - - python3 - "$RUNNER_TMP" <<'PYEOF' -import json, sys - -with open(sys.argv[1]) as f: - data = json.load(f) - -stats = data["stats"] -filters = ["passthrough", "deadband", "window"] -labels = {"passthrough": "PassThrough", "deadband": "Deadband (1MB)", "window": "Window (max, 5s)"} - -pt_mem = stats["passthrough"]["memory"]["dataPointCount"] -pt_cpu = stats["passthrough"]["cpu"]["dataPointCount"] -pt_total = pt_mem + pt_cpu - -col_w = [20, 13, 13, 13, 13, 13] -header = ( - f" {'Filter':<{col_w[0]}}" - f"{'Events(mem)':>{col_w[1]}}" - f"{'Points(mem)':>{col_w[2]}}" - f"{'Events(cpu)':>{col_w[3]}}" - f"{'Points(cpu)':>{col_w[4]}}" - f"{'Reduction':>{col_w[5]}}" -) -sep = " " + "-" * sum(col_w) - -print(header) -print(sep) -for f in filters: - em = stats[f]["memory"]["eventCount"] - dm = stats[f]["memory"]["dataPointCount"] - ec = stats[f]["cpu"]["eventCount"] - dc = stats[f]["cpu"]["dataPointCount"] - total = dm + dc - pct = round((1.0 - total / pt_total) * 100, 1) if pt_total > 0 else 0.0 - reduction = "--" if f == "passthrough" else f"{pct}%" - print( - f" {labels[f]:<{col_w[0]}}" - f"{em:>{col_w[1]}}" - f"{dm:>{col_w[2]}}" - f"{ec:>{col_w[3]}}" - f"{dc:>{col_w[4]}}" - f"{reduction:>{col_w[5]}}" - ) -PYEOF - - echo "" - narrate " Filtered output is backend-ready — same schema, fewer data points." - echo "" - - python3 - "$RUNNER_TMP" <<'PYEOF' -import json, sys - -with open(sys.argv[1]) as f: - data = json.load(f) - -raw = data.get("firstEvents", {}).get("deadband_memory", "") -if raw: - parsed = json.loads(raw) - ts_data = parsed.get("timeseries", {}).get("data", []) - values_mb = [f"{round(p['data_point_value'] / 1e6, 1)}MB" for p in ts_data] - print(f" Deadband memory → {values_mb}") - print(f" Only the allocation jumps. Flat stretches between them: dropped.") -PYEOF - -fi -pause - -# ============================================================================ -separator -heading "Next Steps" -echo "" -echo " Integrate the pipeline into the SDK:" -echo " - Replace CSVDataProvider with real VitalMemoryReader / VitalCPUReader" -echo " - Wire into RUM session lifecycle and the upload pipeline" -echo " - Test end-to-end: SDK → backend → query" -echo "" -echo " Two open decisions to resolve along the way:" -echo " Schema — which data_point format does the backend adopt?" -echo " (A: typed fields / B: single scalar / C: compound)" -echo " Sampling — per-metric strategy or one for all?" -echo " e.g. Deadband for memory, Window for CPU" -separator -echo "" -narrate " End of demo." -echo "" diff --git a/pod/plans/1-plan-business-logic.md b/pod/plans/1-plan-business-logic.md deleted file mode 100644 index 24f76d1d65..0000000000 --- a/pod/plans/1-plan-business-logic.md +++ /dev/null @@ -1,344 +0,0 @@ -# PLAN.md — DatadogTimeseries Standalone Package - -**Date:** 2026-04-13 -**Epic:** RUM-13949 -**Pod:** AI-first Performance Timeseries -**Author:** Barbora Plasovska - ---- - -## Idea Summary - -Standalone Swift package (`DatadogTimeseries`) with zero SDK dependencies that implements the pure timeseries transform logic: takes timestamped performance samples (memory, CPU) and produces complete RUM timeseries JSON events. Runs with `swift build` / `swift test` only. Includes a verification pipeline (CSV fake data in, expected JSON out, exact match comparison). Lives on a feature branch in dd-sdk-ios. - -### Why standalone? - -This package is designed for fast agent-driven iteration: -- `swift build` compiles in seconds — no Xcode workspace, no simulators, no Carthage, no CocoaPods -- `swift test` runs all tests headlessly — the agent can loop (edit → test → fix) autonomously in YOLO mode -- Zero SDK dependencies means zero setup — clone, `cd DatadogTimeseries/`, `swift test`, done - -### Two-plan approach - -This is **Plan 1 of 2**: -- **Plan 1 (this plan):** Build and verify the standalone package — pure logic, CSV in, JSON out, verification pipeline -- **Plan 2 (separate IPCIVR session, Week 2+):** Integrate into DatadogRUM — replace CSVDataProvider with real VitalMemoryReader/VitalCPUReader, wire into RUM session lifecycle, connect to the upload pipeline - -Plan 2 starts once Plan 1 is solid and verified. The integration is glue code on top of a battle-tested transform library. - ---- - -## Decisions Log - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Consumers | Both RUM Investigation Agent + Mobile Vitals | Both are first-class from day 1 | -| Event scope | Full RUM envelope | Package produces complete JSON events (with application, session, _dd) | -| Timer/scheduling | Platform glue (not in package) | Package is pure stateless transform | -| Delta compression | Deferred to Week 2+ | Simpler first iteration | -| RUM context injection | Config struct | Simple TimeseriesConfig passed at init | -| CSV format | timestamp,metric,value | Three-column, one CSV for all metrics | -| JSON encoding | Codable + .sortedKeys | Type-safe AND deterministic output for exact match | -| Batching | Package enforces | Batcher accumulates samples, flushes at batch size | -| DataProvider | Pull-based sync | `func read() -> Sample?` — matches VitalMemoryReader pattern | -| Fixture generation | Backend-driven | Hand-write from staging schema, validate with William | -| Timestamps | Event date in ms, start/end/data_point in ns | Matches staging schema | -| Error handling | Skip failed samples | Log warning, leave gap, continue | -| UUID comparison | Mask before diff | Replace UUIDs with placeholder for verification | -| Output format | Individual JSON events | Batching into NDJSON is platform glue | -| Location | Feature branch in dd-sdk-ios | Directory at repo root: `DatadogTimeseries/` | - ---- - -## Architecture - -``` -DatadogTimeseries/ -├── Package.swift # Zero external dependencies -├── Sources/ -│ └── DatadogTimeseries/ -│ ├── Models/ -│ │ ├── TimeseriesEvent.swift # Codable RUM timeseries event (full envelope) -│ │ ├── TimeseriesName.swift # Enum: memory_usage, cpu_usage -│ │ └── Sample.swift # (timestamp: Int64, value: Double) value type -│ ├── DataProvider/ -│ │ ├── DataProvider.swift # Protocol: func read() -> Sample? -│ │ └── CSVDataProvider.swift # Reads CSV fixtures for testing -│ ├── Core/ -│ │ ├── TimeseriesConfig.swift # RUM context: app id, session id, source, etc. -│ │ ├── TimeseriesBatcher.swift # Accumulates samples, flushes at batch size -│ │ └── TimeseriesEventBuilder.swift # Samples → RUM JSON event -│ ├── Encoding/ -│ │ └── TimeseriesEncoder.swift # JSONEncoder wrapper with sorted keys -│ └── TimeseriesPipeline.swift # Convenience: wires provider → batcher → builder → encoder -├── Tests/ -│ └── DatadogTimeseriesTests/ -│ ├── Fixtures/ -│ │ ├── input_memory_cpu.csv # Fake input: timestamp,metric,value -│ │ ├── expected_memory_batch1.json # Expected output for memory batch 1 -│ │ ├── expected_memory_batch2.json # Expected output for memory batch 2 -│ │ ├── expected_cpu_batch1.json # Expected output for CPU batch 1 -│ │ └── expected_cpu_batch2.json # Expected output for CPU batch 2 -│ ├── CSVDataProviderTests.swift -│ ├── TimeseriesBatcherTests.swift -│ ├── TimeseriesEventBuilderTests.swift -│ ├── TimeseriesEncoderTests.swift -│ └── EndToEndVerificationTests.swift # CSV in → JSON out → exact match -└── Scripts/ # Reserved for future tooling -``` - ---- - -## Data Flow - -``` -CSV file TimeseriesConfig - │ │ - ▼ │ -CSVDataProvider │ - │ │ - ▼ │ - Sample(timestamp, value) │ - │ │ - ▼ │ -TimeseriesBatcher │ - │ (accumulates N samples) │ - │ (flushes when full) │ - ▼ ▼ -TimeseriesEventBuilder ◄─────────┘ - │ - ▼ -TimeseriesEvent (Codable struct) - │ - ▼ -TimeseriesEncoder (.sortedKeys) - │ - ▼ -JSON string (deterministic) - │ - ▼ -Compare vs expected fixture (exact match, UUIDs masked) -``` - ---- - -## Task Breakdown - -### Task 1: Package scaffolding -Create the Swift package structure with `Package.swift`, directory layout, empty source files. -- No external dependencies -- Targets: `DatadogTimeseries` (library) + `DatadogTimeseriesTests` (test) -- Swift 5.9+ (match dd-sdk-ios) - -### Task 2: Models -Define the core data types: - -**Sample.swift:** -```swift -struct Sample { - let timestamp: Int64 // nanoseconds - let value: Double -} -``` - -**TimeseriesName.swift:** -```swift -enum TimeseriesName: String, Codable { - case memoryUsage = "memory_usage" - case cpuUsage = "cpu_usage" -} -``` - -**TimeseriesEvent.swift** (Codable, full RUM envelope): -```swift -struct TimeseriesEvent: Codable { - let dd: DD // { format_version: 2 } - let application: Application // { id: String } - let date: Int64 // milliseconds - let session: Session // { id: String, type: "user" } - let source: String // "ios" - let type: String // "timeseries" - let service: String? - let version: String? - let timeseries: Timeseries - - struct DD: Codable { - let formatVersion: Int // 2 - } - struct Application: Codable { - let id: String - } - struct Session: Codable { - let id: String - let type: String // "user" - } - struct Timeseries: Codable { - let id: String // UUID - let name: TimeseriesName - let start: Int64 // nanoseconds - let end: Int64 // nanoseconds - let data: [DataPoint] - } - struct DataPoint: Codable { - let timestamp: Int64 // nanoseconds - let dataPointValue: Double - } -} -``` - -Use explicit `CodingKeys` on every struct to map to snake_case (`format_version`, `data_point_value`, `_dd`). No `.convertToSnakeCase` encoder strategy — CodingKeys gives full control over edge cases like `_dd` and avoids double-conversion bugs. - -### Task 3: TimeseriesConfig -```swift -struct TimeseriesConfig { - let applicationId: String - let sessionId: String - let sessionType: String // "user" - let source: String // "ios" - let service: String? - let version: String? -} -``` - -### Task 4: DataProvider protocol + CSVDataProvider - -**DataProvider.swift:** -```swift -protocol DataProvider { - func read() -> Sample? -} -``` - -**CSVDataProvider.swift:** -- Reads a CSV file with format: `timestamp,metric_name,value` -- Filters by a given `TimeseriesName` -- Returns samples one by one via `read()` (pull-based) -- Returns `nil` when exhausted - -### Task 5: TimeseriesBatcher -- Initialized with `batchSize: Int` (default 30) — metric-agnostic, it just batches samples -- `add(_ sample: Sample)` — appends to internal buffer -- `shouldFlush() -> Bool` — true when buffer.count >= batchSize -- `flush() -> [Sample]` — returns accumulated samples, clears buffer -- `flushRemaining() -> [Sample]?` — returns whatever is left (for session end), nil if empty - -### Task 6: TimeseriesEventBuilder -- Initialized with `TimeseriesConfig` -- `build(samples: [Sample], name: TimeseriesName, eventId: String) -> TimeseriesEvent` -- Computes `start` = first sample timestamp, `end` = last sample timestamp -- Computes `date` = `start` converted from ns to ms (integer division by 1_000_000) -- Maps samples to `DataPoint` array - -### Task 7: TimeseriesEncoder -- Wraps `JSONEncoder` with: - - `.sortedKeys` output formatting - - No `.convertToSnakeCase` — all snake_case mapping handled by explicit CodingKeys on the model structs -- `func encode(_ event: TimeseriesEvent) -> Data` -- Returns deterministic JSON bytes - -### Task 8: CSV test fixtures -Create `input_memory_cpu.csv` with realistic fake data: -- ~20 rows (10 `memory_usage` + 10 `cpu_usage`, simulating 10 seconds at 1Hz) -- Memory values in ~30-40 MB range (bytes), CPU values in 0-100 range (percent) -- Timestamps in nanoseconds, 1-second intervals starting from a fixed epoch -- Tests use `batchSize=5` so this produces 2 batches per metric (4 expected JSON files) -- Production default of 30 is a tuning concern for Plan 2, not a verification concern here - -### Task 9: Expected JSON fixtures (backend-driven) -The expected JSON fixtures should represent what the backend actually accepts. Two-step approach: -1. **Hand-write initial fixtures** based on the staging schema contract (the JSON format already documented in the kickoff context + what William's backend validates against) -2. **Validate with William** — share the fixture files with William/backend team to confirm they match the intake contract. If the backend rejects the format, the fixtures are wrong regardless of what our code produces. - -This avoids the "testing our code with our code" problem — the expected output is defined by the backend contract, not by our own generator. - -Files: -- `Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch1.json` -- `Tests/DatadogTimeseriesTests/Fixtures/expected_memory_batch2.json` -- `Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch1.json` -- `Tests/DatadogTimeseriesTests/Fixtures/expected_cpu_batch2.json` -- All with masked UUIDs (`00000000-0000-0000-0000-000000000000`) and sorted keys - -### Task 10: Unit tests -- **CSVDataProviderTests**: reads CSV, filters by metric, returns correct samples, returns nil at end -- **TimeseriesBatcherTests**: accumulates correctly, flushes at batch size, flushRemaining works, empty flush returns nil -- **TimeseriesEventBuilderTests**: correct envelope fields, correct start/end, correct data points, correct timestamps -- **TimeseriesEncoderTests**: sorted keys, snake_case, valid JSON - -### Task 11: End-to-end verification test -`EndToEndVerificationTests.swift`: -1. Read `input_memory_cpu.csv` via `CSVDataProvider` -2. Feed samples through `TimeseriesBatcher` + `TimeseriesEventBuilder` + `TimeseriesEncoder` -3. Mask UUIDs in actual output (regex replace UUID pattern with `"00000000-0000-0000-0000-000000000000"`) -4. Load expected JSON fixture (already has masked UUIDs) -5. Compare byte-for-byte -6. Pass/fail - -### Task 12: TimeseriesPipeline (orchestrator) -- Convenience type that wires the full flow: `DataProvider` → `TimeseriesBatcher` → `TimeseriesEventBuilder` → `TimeseriesEncoder` -- `init(provider: DataProvider, config: TimeseriesConfig, metricName: TimeseriesName, batchSize: Int)` -- `func processAll() -> [Data]` — reads all samples from provider, batches, builds events, encodes, returns JSON data array -- This is what the E2E test calls — keeps wiring logic out of the test itself -- In Plan 2 (platform integration), the real orchestrator is the session scope + timer, not this pipeline - -### Task 13: Skip-sample error handling -- `DataProvider.read()` returns `Sample?` — nil means skip -- `TimeseriesBatcher.add()` only accepts non-nil samples -- Test: CSV with a gap (missing row) → output event has fewer data points, timestamps reflect the gap - ---- - -## Verification Strategy - -The agent must run these checks **in order** after every change: - -### 1. Build check -```bash -cd DatadogTimeseries && swift build -``` -Must compile with zero errors and zero warnings. Fastest feedback — catches type errors, missing imports, syntax issues. - -### 2. Unit tests -```bash -cd DatadogTimeseries && swift test -``` -Runs all tests in `DatadogTimeseriesTests`. Each component has dedicated tests (Tasks 10). Pass/fail is unambiguous. - -### 3. End-to-end exact match -Part of `swift test` (Task 11) — the `EndToEndVerificationTests`: -- CSV in → pipeline → JSON out → mask UUIDs → compare byte-for-byte against expected fixtures -- UUID masking regex: `[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` → `00000000-0000-0000-0000-000000000000` -- Fixtures are hand-written from backend contract (validated with William) - -### 4. JSON schema validation -A test that validates output JSON structure against expected RUM timeseries schema: -- Required fields present: `_dd`, `application`, `date`, `session`, `source`, `type`, `timeseries` -- Correct types: `date` is Int64, `timeseries.data` is array, `data_point_value` is Double -- Correct constants: `type` == `"timeseries"`, `_dd.format_version` == 2, `session.type` == `"user"` -- This catches structural errors even before fixtures are finalized - -### Agent loop -After every code change, the agent runs: -```bash -cd DatadogTimeseries && swift build && swift test -``` -All green = proceed. Any red = fix before moving on. - ---- - -## Week 1 Milestone (Friday Apr 18 Demo) - -- [ ] Package compiles with `swift build` -- [ ] All unit tests pass with `swift test` -- [ ] End-to-end verification passes (CSV in → JSON out → exact match) -- [ ] Can show: "same CSV input, same expected JSON — ready for Kotlin/Go to verify against" - ---- - -## Future (Week 2+) - -- Delta compression (DeltaEncoder) -- Integration into DatadogRUM (replace CSVDataProvider with VitalMemoryReader/VitalCPUReader) -- Wire TimeseriesEventBuilder output into RUM Writer pipeline -- NDJSON batch format for upload -- Kotlin rewrite + verification against same fixtures -- Configurable batch size tuning based on backend feedback diff --git a/pod/plans/2-plan-platform-integration.md b/pod/plans/2-plan-platform-integration.md deleted file mode 100644 index 0941df3729..0000000000 --- a/pod/plans/2-plan-platform-integration.md +++ /dev/null @@ -1,182 +0,0 @@ -# PLAN.md — Platform Integration Wiring (Plan 2) - -**Date:** 2026-04-14 -**Epic:** RUM-13949 -**Pod:** AI-first Performance Timeseries -**Author:** Barbora Plasovska - ---- - -## Purpose - -This plan tells an agent how to wire the Plan 1 business logic into any SDK platform (iOS, Android, React Native, etc.). It is **platform-agnostic** — described in prose with hints. The agent is told "implement Plan 2 for [platform]" and figures out the platform-specific code. - -### Relationship to Plan 1 - -- **Plan 1** = standalone business logic (batcher → builder → encoder). Produces RUM timeseries JSON events from timestamped samples. Verified with CSV input → JSON output exact match. -- **Plan 2 (this plan)** = wire Plan 1's logic into a real SDK. Read real metrics, run on a timer, plug into the session lifecycle, send output to the upload pipeline. - -Plan 1 is a **reference implementation**, not a library to import. The agent must: -- Read `plans/1-plan-business-logic.md` for architecture and design decisions -- Read `DatadogTimeseries/` source code for exact logic (batcher, builder, encoder, timestamp handling) -- **Rewrite** the logic into the target SDK's code style, module structure, and conventions -- Event models must come from the SDK's own model generation system, not copied from Plan 1 - -After Plan 2 is implemented for a platform: -- Remove the `DatadogTimeseries/` standalone package from the repo — it was a validation tool, not a permanent artifact -- Move the test fixtures (CSV input, expected JSON) into the platform SDK's test directory - ---- - -## Decisions Log (carried from Plan 1 + IPCIVR) - -### Design decisions from Plan 1 (must be followed) - -| Decision | Detail | -|----------|--------| -| Batcher is metric-agnostic | One batcher per metric, it just accumulates samples. Metric name is assigned by the builder, not the batcher. | -| Event builder owns metric name | Builder receives `metricName` when building an event. Batcher doesn't know what metric it's batching. | -| `date` field = first sample timestamp in ms | `start` timestamp (nanoseconds) divided by 1,000,000. | -| Timestamps | Event-level `date` in milliseconds. Everything inside `timeseries` (start, end, data point timestamps) in nanoseconds. | -| `_dd.format_version` = 2 | Constant. | -| `session.type` = "user" | Constant for now. | -| `type` = "timeseries" | Constant. | -| Explicit CodingKeys / field mapping | All JSON keys are snake_case. No automatic conversion — explicit mapping to avoid edge cases like `_dd`. | -| Two metrics for MVP | `memory_usage` and `cpu_usage`. Closed enum with documented extension path. | -| Skip failed samples | If a metric read fails, skip it (leave a gap). Don't crash, don't retry. | - -### Plan 2 decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Reuse mode | Rewrite into SDK | Plan 1 is reference. Agent rewrites using SDK patterns and conventions. | -| Plan structure | 3 phases | (1) Metrics flowing, (2) Session lifecycle, (3) Upload pipeline. Each independently verifiable. | -| Detail level | Requirements + hints | State what to achieve, add hints like "look at VitalReaders" to point the agent in the right direction. | -| Verification | Integration tests with mocks | Mock boundaries, trigger manually, verify JSON output. Follow SDK's existing test patterns. | -| Background handling | Future concern | Not addressed in Plan 2. MVP: collect only while app is active. | -| E2E staging validation | Outside scope | Plan 2 stops at "JSON handed to upload pipeline". | -| Config flag | TODO, default opt-in | Add a config flag, default disabled. Decision pending, easy to flip. | -| Fixtures after removal | Move to platform tests | Copy CSV/JSON fixtures from Plan 1 into the SDK's test directory. | - ---- - -## TODO Placeholders (blocked decisions) - -These are not yet decided. Implement with the stated defaults; they are designed to be easy to change. - -| Item | Default | What might change | Impact of change | -|------|---------|-------------------|-----------------| -| Schema | Current staging schema (numeric-only `data_point_value`) | Polymorphic value schema (A/B/C) — waiting on William/backend results | Event model struct changes. Regenerate from `rum-events-format`. | -| Batch size | 30 (30 seconds at 1Hz) | Backend may recommend different size | One constant to change. | -| Collection interval | 1 second | Could change based on performance feedback | One constant to change. | -| Config flag default | `false` (opt-in) | Team may decide always-on | One boolean default to flip. | - ---- - -## Phase 1 — Get metrics flowing - -### What to do - -1. Find the SDK's existing metric/vital readers for memory and CPU -2. Create a collector component that: - - Runs a 1-second periodic timer on a background thread - - Each tick: reads memory and CPU from the existing readers - - Creates a `Sample(timestamp_nanoseconds, value)` for each metric - - Feeds each sample into a batcher (one batcher per metric, metric-agnostic) - - When a batcher is ready to flush (buffer >= batch size), builds a timeseries event via the builder and encodes it to JSON -3. If a metric read fails, skip it (leave a gap in the data). Do not crash or retry. - -### Hints - -- Look for existing periodic collection patterns in the SDK (e.g. vital readers, performance monitors). Follow the same threading, timer, and lifecycle patterns. -- If no existing metric collection pattern exists, create a minimal background timer that reads metrics and feeds the pipeline. Keep it simple. -- The timer should not block the main thread. Buffer access must be thread-safe. - -### Done when - -- A test exists that: mocks the metric reader to return known values, triggers collection manually (no real timer wait), and verifies that correctly-shaped JSON events are produced. -- The JSON output matches the expected structure from Plan 1 (same fields, same timestamp precision, same constants). - ---- - -## Phase 2 — Add session lifecycle - -### What to do - -4. Wire the collector to the RUM session lifecycle: - - **Start** collecting when a new RUM session begins - - **Stop** collecting when the session ends (timeout, max duration, or explicit stop) - - **Flush remaining** on stop: flush any samples left in both batchers, even if below batch size. Don't lose the tail. -5. On session renewal (old session ends, new one starts): stop old collector, create new one. No gap, no overlap. - -### Hints - -- Look for the session scope/manager in the SDK. Look at how other session-scoped components are created and destroyed. Follow the same pattern. -- Look at how the SDK handles session timeout (e.g. 15 min inactivity) and max session duration (e.g. 4 hours). The collector should respond to the same signals. - -### Done when - -- A test exists that: simulates session start → collection → session stop, and verifies that (a) events are produced during the session, (b) remaining samples are flushed on stop, (c) no events are produced after stop. - ---- - -## Phase 3 — Connect to upload pipeline - -### What to do - -6. Hand the encoded JSON events to the SDK's existing event writer/upload pipeline -7. The events should flow through the same path as other RUM events (storage → upload → backend) -8. Add a configuration flag to the SDK's RUM configuration (default: disabled / opt-in). When disabled, the collector is not created. - -### Hints - -- Look at how other RUM event types (views, actions, errors) are written to the pipeline. Follow the same writer pattern. -- For the config flag, look for existing boolean feature flags in the RUM configuration (e.g. `trackFrustrations`, `trackBackgroundEvents`). Follow the same pattern. - -### Done when - -- A test exists that: enables the config flag, starts a session, triggers collection, and verifies that events reach the writer/upload layer (mocked at the writer boundary). -- The config flag defaults to disabled. When disabled, no collector is created, no timer runs, no overhead. -- All existing SDK tests still pass (no regressions). - ---- - -## Verification Strategy - -### What to test - -1. **Phase 1**: Given known metric values → collector produces correctly-shaped JSON events -2. **Phase 2**: Session start triggers collection, session stop flushes and stops, no events after stop -3. **Phase 3**: Events reach the upload pipeline, config flag gates the feature, no regressions - -### How to test - -- Mock the metric readers to return deterministic values -- Trigger the timer/collection manually (inject a manual trigger instead of waiting for real seconds) -- Mock the writer to capture output and verify it -- Look at how existing collectors/features are tested in the SDK. Follow the same mocking approach and test utilities. -- Copy Plan 1's test fixtures (CSV input, expected JSON) into the platform's test directory for reference - ---- - -## How to use this plan - -``` -Agent prompt: - -"Implement Plan 2 for [iOS / Android / React Native]. - -1. Read plans/1-plan-business-logic.md for design decisions -2. Read DatadogTimeseries/ source code for exact business logic -3. Read this plan (plans/2-plan-platform-integration.md) for integration steps -4. Explore the target SDK to find existing patterns for: - - Metric/vital readers (memory, CPU) - - Periodic collection (timers, background threads) - - Session lifecycle (scope/manager, start/stop signals) - - Event writing (how RUM events reach the upload pipeline) - - RUM configuration flags - - Test patterns (mocking, test utilities) -5. Implement the 3 phases in order, following TDD -6. After implementation: remove DatadogTimeseries/ standalone package, - move test fixtures to the platform's test directory" -``` diff --git a/pod/plans/3-plan-sampling-filters.md b/pod/plans/3-plan-sampling-filters.md deleted file mode 100644 index 6d72145bd6..0000000000 --- a/pod/plans/3-plan-sampling-filters.md +++ /dev/null @@ -1,256 +0,0 @@ -# Plan 3 — Sampling Filters for DatadogTimeseries Standalone Package - -**Date:** 2026-04-15 -**Epic:** RUM-13949 -**Phase:** Post-MVP research — compare sampling strategies before Plan 2 integration - ---- - -## Goal - -Add pluggable `SampleFilter` protocol to the standalone `DatadogTimeseries` Swift package with 2 concrete implementations (PassThrough, Deadband, WindowAggregate). Wire it into `TimeseriesPipeline`, generate a realistic 60-sample fixture, and provide a runner script that compares all strategies side by side. - -This is a **research tool**, not a production feature. The filters and fixture generated here inform which strategy carries into Plan 2 (SDK integration). The JSON output from each filter feeds into the real backend pipeline. - ---- - -## Architecture - -The filter slots between `DataProvider` and `TimeseriesBatcher` inside `TimeseriesPipeline.processAll()`: - -``` -DataProvider → [SampleFilter] → TimeseriesBatcher → EventBuilder → Encoder -``` - -Protocol shape (class-only for clean mutable state): - -```swift -protocol SampleFilter: AnyObject { - func process(_ sample: Sample) -> [Sample] // 0 = suppress, 1+ = forward - func flush() -> [Sample] // emit buffered state at end of stream -} -``` - -Default filter is `PassThroughFilter()` — existing tests are unaffected. - ---- - -## Decisions Log - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| SampleFilter type | Class-only protocol (AnyObject) | Filters are stateful. Reference semantics avoid inout/wrapper complexity. | -| TransitionFilter | **Dropped from this plan** | Not meaningful for continuous metrics (memory, CPU). Build later for thermal/battery state. | -| Fixture size | 60 samples (1 min at 1 Hz) | Small enough to inspect, large enough to show meaningful filter differences. | -| Fixture shape | Realistic: slow memory growth + CPU spikes | Matches real-world patterns. 3 allocation jumps, 3 CPU bursts. | -| Fixture generator | Python/shell script | Simpler than standalone Swift script (can't import the package). | -| Filter scope | Any filter on any metric | One --filter arg applies to both memory and CPU. Flexible for research. | -| Deadband threshold | Configurable via --threshold | Lets you experiment without code changes. | -| Deadband heartbeat | Configurable via --heartbeat | Important so backend can distinguish "stable value" from "collection stopped". | -| Window duration | Configurable via --window (seconds) | Try 5s vs 10s vs 30s without code changes. | -| Aggregate function | Configurable via --aggregate max\|avg\|min\|last | All 4 are useful; max for spike detection, avg for baseline. | -| Table aggregate | Uses --aggregate arg for window row | Comparison table reflects the active configuration. | -| JSON output | All 3 filters written every run | Script runs all 3 internally; no reason to discard any result. | -| Output cleanup | Clear output/ at start of each run | Prevents accidentally curling a stale file from a previous run. | -| Pipeline default | PassThroughFilter() | Existing E2E fixture tests are unchanged. New filter tests are additive. | -| Script location | DatadogTimeseries/scripts/ | Self-contained next to the code. Goes away naturally with the standalone package. | - ---- - -## Tasks - -### Task 1 — `SampleFilter` protocol -**File:** `Sources/DatadogTimeseries/Filters/SampleFilter.swift` - -Protocol definition. Class-only (`AnyObject`). Two methods: `process` and `flush`. Include inline doc explaining when each is called. - ---- - -### Task 2 — `PassThroughFilter` -**File:** `Sources/DatadogTimeseries/Filters/PassThroughFilter.swift` - -Returns `[sample]` always, `flush()` returns `[]`. Makes the current implicit pipeline behaviour explicit. Zero logic. - ---- - -### Task 3 — `DeadbandFilter` -**File:** `Sources/DatadogTimeseries/Filters/DeadbandFilter.swift` - -```swift -final class DeadbandFilter: SampleFilter { - init(threshold: Double, heartbeatInterval: Int64? = nil) -} -``` - -State: `lastEmittedValue: Double?`, `lastEmittedTimestamp: Int64?` - -Logic: -- Always emit the first sample -- Emit if `abs(current.value - lastEmitted.value) >= threshold` -- Emit if `heartbeatInterval != nil && (current.timestamp - lastEmitted.timestamp) >= heartbeatInterval` -- `flush()` returns `[]` (no internal buffer) - ---- - -### Task 4 — `WindowAggregateFilter` -**File:** `Sources/DatadogTimeseries/Filters/WindowAggregateFilter.swift` - -```swift -enum AggregateFunction { case avg, min, max, last } - -final class WindowAggregateFilter: SampleFilter { - init(windowDuration: Int64, function: AggregateFunction = .max) -} -``` - -State: `windowStart: Int64?`, `buffer: [Sample]` - -Logic: -- Accumulate samples in buffer -- When `sample.timestamp - windowStart >= windowDuration`: flush window, emit one aggregate sample, start new window -- `flush()`: emit aggregate of remaining buffer (partial window at end of stream) -- Emitted sample timestamp = `windowStart` -- Aggregate: avg = mean, min = minimum, max = maximum, last = last sample's value - ---- - -### Task 5 — Update `TimeseriesPipeline` -**File:** `Sources/DatadogTimeseries/TimeseriesPipeline.swift` - -```swift -init( - provider: DataProvider, - config: TimeseriesConfig, - metricName: TimeseriesName, - batchSize: Int = 30, - filter: SampleFilter = PassThroughFilter() -) -``` - -Update `processAll()`: -``` -while let raw = provider.read() { - let filtered = filter.process(raw) - for sample in filtered { batcher.add + maybe flush } -} -// After provider exhausted: -for sample in filter.flush() { batcher.add + maybe flush } -// Then existing batcher.flushRemaining() tail -``` - ---- - -### Task 6 — Realistic 60-sample fixture generator -**File:** `DatadogTimeseries/scripts/generate-fixture.py` - -Generates `Tests/DatadogTimeseriesTests/Fixtures/input_realistic_60s.csv`. - -Memory shape (60 rows): -- Base: 31_000_000 bytes (~31MB) -- Slow drift: +1_000–3_000 bytes/s (noise) -- 3 allocation jumps: +1_500_000 bytes at ~t=12s, +2_000_000 at ~t=30s, +1_000_000 at ~t=50s -- One deallocation: -500_000 at ~t=40s - -CPU shape (60 rows): -- Baseline: 5–15% (random noise in range) -- 3 bursts: 50–80% for 3–5 seconds at ~t=15s, ~t=35s, ~t=55s - -CSV format: same as existing fixture (`timestamp,metric,value`), timestamps at 1s intervals starting from `1700000001000000000`. - ---- - -### Task 7 — Unit tests per filter -**Files:** -- `Tests/DatadogTimeseriesTests/Filters/PassThroughFilterTests.swift` -- `Tests/DatadogTimeseriesTests/Filters/DeadbandFilterTests.swift` -- `Tests/DatadogTimeseriesTests/Filters/WindowAggregateFilterTests.swift` - -**PassThroughFilterTests:** passes all samples, flush returns empty. - -**DeadbandFilterTests:** -- Always emits first sample -- Suppresses sample below threshold -- Emits at exact threshold -- Emits on negative delta -- References last *emitted* value, not last *seen* -- Heartbeat fires after silence interval -- No heartbeat without interval configured -- Flush returns nothing - -**WindowAggregateFilterTests:** -- Does not emit until window closes -- Emits when window closes (timestamp = window start) -- flush() emits partial window -- flush() on empty buffer returns nothing -- Multiple windows each emit once -- Each aggregate function (avg, min, max, last) produces correct value -- Realistic CPU scenario: 10 samples, 5s window, max → 2 aggregate events with correct max values - ---- - -### Task 8 — `FilterComparisonTests` -**File:** `Tests/DatadogTimeseriesTests/Filters/FilterComparisonTests.swift` - -Uses `input_realistic_60s.csv` (60 samples). - -Tests: -- PassThrough on memory/CPU: 60 data points each -- Deadband (threshold=1_000_000) on memory: fewer than 60 data points, includes first sample -- Deadband (threshold=1_000_000) on CPU: tests that spikes cross threshold -- Window (5s, max) on CPU: exactly 12 data points (60s / 5s) -- Window (5s, max) on CPU: max values correctly capture burst peaks -- All filters produce valid JSON (type="timeseries", required fields present) -- Pipeline default (no filter arg) = PassThrough behaviour (regression guard) - ---- - -### Task 9 — Runner script -**File:** `DatadogTimeseries/scripts/run-pipeline.sh` - -```bash -./scripts/run-pipeline.sh [--filter passthrough|deadband|window] - [--threshold ] # deadband threshold (default: 1000000) - [--heartbeat ] # deadband heartbeat (default: 30) - [--window ] # window duration (default: 5) - [--aggregate max|avg|min|last] # window function (default: max) -``` - -**Behaviour:** - -1. Clear `output/` directory -2. Run `swift test` — exit on failure -3. Run all 3 filters against `input_realistic_60s.csv` for both memory and CPU using the provided params (deadband uses --threshold/--heartbeat, window uses --window/--aggregate) -4. Print comparison table: - -``` -┌─────────────────┬───────────────────────────────┬───────────────────────────────┐ -│ Filter │ Memory │ CPU │ -│ │ events │ data pts │ reduction │ events │ data pts │ reduction │ -├─────────────────┼────────┼──────────┼────────────┼────────┼──────────┼────────────┤ -│ passthrough │ 6 │ 60 │ -- │ 6 │ 60 │ -- │ -│ deadband │ 2 │ 18 │ 70% │ 4 │ 32 │ 47% │ -│ window (max,5s) │ 2 │ 12 │ 80% │ 2 │ 12 │ 80% │ -└─────────────────┴────────┴──────────┴────────────┴────────┴──────────┴────────────┘ -``` - -5. Write all 3 filters' output to `output/`: - - `output/passthrough_memory.ndjson`, `output/passthrough_cpu.ndjson` - - `output/deadband_memory.ndjson`, `output/deadband_cpu.ndjson` - - `output/window_memory.ndjson`, `output/window_cpu.ndjson` -6. Pretty-print first event from the `--filter` selection (default: all 3) - -**Script is self-contained** — uses `swift` CLI to run a small Swift driver program that imports `DatadogTimeseries` and runs the pipeline, piping output back to the shell script for display. - ---- - -## Verification Strategy - -See Phase 4 output (to be determined). - ---- - -## What this is NOT - -- Not a production feature — the standalone package gets removed after Plan 2 -- Not a backend integration — the JSON files are ready to curl, but curling is manual -- `TransitionFilter` is intentionally out of scope — build when enum-like metrics are added diff --git a/pod/plans/4-plan-ios-sdk-integration.md b/pod/plans/4-plan-ios-sdk-integration.md deleted file mode 100644 index 219c69c169..0000000000 --- a/pod/plans/4-plan-ios-sdk-integration.md +++ /dev/null @@ -1,239 +0,0 @@ -# Plan 4 — iOS SDK Integration - -**Branch:** `feature/timeseries` -**Scope:** Wire the timeseries pipeline into `DatadogRUM` so real memory and CPU samples flow through the SDK's upload infrastructure. - ---- - -## Context - -The standalone `DatadogTimeseries` package (Plan 1 + 3) is complete and committed. It has: -- `Sample`, `TimeseriesEvent`, `TimeseriesConfig`, `TimeseriesName` -- `TimeseriesPipeline` with `processAll()` (batch/CSV mode) -- `SampleFilter` protocol + PassThrough / Deadband / Window implementations -- Current schema: `{ "data_point_value": 42.0 }` (Schema B) - -`DatadogRUM` already has (on `feature/timeseries` / develop): -- `VitalMemoryReader` — reads `phys_footprint` via `task_info()` -- `VitalCPUReader` — reads CPU ticks via `host_statistics()` -- `VitalInfoSampler` — timer-driven, aggregates vitals stats for RUM view events -- `RUMScopeDependencies.vitalsReaders: VitalsReaders?` -- `RUMSessionScope` — session lifecycle hook point -- `RUMFeature` — initialises `VitalsReaders` from `vitalsUpdateFrequency` config - -Marie's prototype (`feature/timeseries-prototype`) implements `MemoryTimeseriesCollector` as reference — single metric, Schema B, batch size 5, tied to `RUMSessionScope`. We follow the same pattern for both metrics with Schema C. - ---- - -## Decisions - -- **Schema C** — DataPoint is `{ "timestamp": ..., "data_point": { "memory_max": ..., "memory_percent": ... } }`. Nested `data_point` object with named metric fields. Static `CodingKeys` — compatible with code generation. -- **Two generated event types** — `RUMTimeseriesMemoryEvent` and `RUMTimeseriesCPUEvent` defined separately in rum-events-format, each with their own `DataPoint` struct. -- **memory_percent computed at sampling time** — `VitalMemoryReader` returns bytes; collector divides by `ProcessInfo.processInfo.physicalMemory * 100` to get percent. -- **No cross-package import** — `DatadogRUM` does not import `DatadogTimeseries`. Standalone package stays for demo/runner use only. -- **PassThrough filter only** — no sampling for this integration. Deadband/Window deferred. -- **Batch size: 30** — ~30 seconds of data per event. -- **Sampling interval: 1s** — dedicated `DispatchSourceTimer` (not reusing `VitalInfoSampler`, which runs at user-configured frequency). -- **Collector lifetime: single reusable instance** — created in `RUMFeature.init`, `start()` resets all state (buffers, timer) cleanly per session. -- **Session context injected at `start()`** — `RUMSessionScope` calls `collector.start(sessionID:applicationID:)` so the collector always has fresh context for the new session. -- **Collection scope: session** — start on session start, stop on session end, flush remaining buffer. -- **Opt-in via RUM config flag** — `RUM.Configuration.enableTimeseries: Bool = false`. -- **Upload: existing RUM Writer** — no new storage scope or upload worker. - ---- - -## Schema C DataPoint Encoding - -**Before (Schema B):** -```json -{ "timestamp": 1714000000000000000, "data_point_value": 38052032.0 } -``` - -**After (Schema C):** -```json -{ - "timestamp": 1776690660041000000, - "data_point": { - "memory_max": 115456128.5, - "memory_percent": 76.8 - } -} -``` - -The value is wrapped in a nested `data_point` object with named metric fields. This uses **static `CodingKeys`** — fully compatible with code generation. - -**Memory data point fields:** -- `memory_max` — raw bytes from `VitalMemoryReader.readVitalData()` (`phys_footprint`) -- `memory_percent` — `memory_max / ProcessInfo.processInfo.physicalMemory * 100` - -**CPU data point fields (to confirm exact names with backend):** -- `cpu_usage` — CPU percentage from `VitalCPUReader.readVitalData()` - -**Generated struct shape (from rum-events-format):** -```swift -// RUMDataModels.swift (generated) -public struct RUMTimeseriesMemoryEvent: RUMDataModel { - // ... envelope fields ... - public struct DataPoint: Codable { - public let timestamp: Int64 - public let dataPoint: MemoryDataPoint - public struct MemoryDataPoint: Codable { - public let memoryMax: Double - public let memoryPercent: Double - // CodingKeys: memory_max, memory_percent - } - } -} -``` - ---- - -## Architecture - -``` -RUM.Configuration.enableTimeseries = true - │ - ▼ -RUMFeature.init - → creates TimeseriesSessionCollector(memoryReader:, cpuReader:, writer:, config:) - → injects into RUMScopeDependencies - │ - ▼ -RUMSessionScope.init - → dependencies.timeseriesCollector?.start() - │ - ▼ -Timer @ 1s: - → memoryReader.readVitalData() → Sample → memoryBuffer.append - → cpuReader.readVitalData() → Sample → cpuBuffer.append - → if buffer.count >= batchSize: flush(metric, buffer) → Writer.write(event) - │ -RUMSessionScope ends - → dependencies.timeseriesCollector?.stop() (flushes remaining) -``` - ---- - -## How the Event Model Gets Into the SDK - -RUM event types in this SDK are **not hand-written**. The flow is: - -1. Schema defined as JSON Schema in the [`rum-events-format`](https://github.com/DataDog/rum-events-format) repo -2. `make rum-models-generate` runs a codegen tool → appends the generated Swift struct to `DatadogInternal/Sources/Models/RUM/RUMDataModels.swift` -3. The generated struct conforms to `RUMDataModel` (which is `Codable`) and uses explicit `CodingKeys` -4. `TimeseriesSessionCollector` uses this generated type when writing events - -Since we work off a feature branch on `rum-events-format` (`bplasovska/timeseries`) without needing a merged PR, the generated type is available on the iOS branch from the start of Phase 2. - ---- - -## Tasks - -### Phase 0 — rum-events-format schema - -0. **Create branch `bplasovska/timeseries` on `rum-events-format`** and define two JSON Schemas there (no PR needed) - - **`RUMTimeseriesMemoryEvent`**: envelope + `timeseries.data: [{ timestamp, data_point: { memory_max: Double, memory_percent: Double } }]` - - **`RUMTimeseriesCPUEvent`**: envelope + `timeseries.data: [{ timestamp, data_point: { cpu_usage: Double } }]` (confirm exact CPU field names with backend) - - Both share the same envelope shape: `{ _dd, application, session, source, type, service, version, date, timeseries: { id, name, start, end, data } }` - - Run `make rum-models-generate GIT_REF=bplasovska/timeseries` on the iOS branch → generated structs appear in `RUMDataModels.swift` - -### Phase 1 — Schema C in standalone package - -1. **Update `TimeseriesEvent.DataPoint`** in `DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift` - - Replace `dataPointValue: Double` (CodingKey `data_point_value`) with Schema C nested shape - - `DataPoint` becomes `{ timestamp: Int64, dataPoint: [String: Double] }` — encodes as `{ "timestamp": ..., "data_point": { "memory_max": ..., "memory_percent": ... } }` - - `dataPoint` is a `[String: Double]` dictionary (flexible for runner/demo use; the SDK uses generated typed structs) - -2. **Update `TimeseriesEventBuilder`** to populate `dataPoint` dictionary with the correct metric keys per `TimeseriesName` - -3. **Update fixture files** (`expected_memory_batch1.json`, `expected_cpu_batch1.json`) to Schema C format - -4. **Update `DatadogTimeseriesRunner`** — output still valid after schema change - -5. **Run standalone tests** — all must pass after schema migration - -### Phase 2 — Timeseries infrastructure in DatadogRUM - -6. **Create `DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift`** - - `TimeseriesSessionCollector` — manages two metric streams (memory + CPU) - - `init(memoryReader:cpuReader:batchSize:featureScope:)` - - `start(sessionID:applicationID:)` — resets buffers, starts dedicated 1s `DispatchSourceTimer` on a background serial queue - - `stop()` — cancels timer, flushes remaining buffers for both metrics - - Timer handler: read both vitals; for memory compute `memory_percent = bytes / ProcessInfo.processInfo.physicalMemory * 100`; append to respective buffer; flush if buffer.count >= batchSize - - `flush(metric:buffer:)` — builds `RUMTimeseriesMemoryEvent` or `RUMTimeseriesCPUEvent` from `RUMDataModels.swift`, writes via `featureScope.eventWriteContext { _, writer in writer.write(value: event) }` - - Thread-safe: all buffer access on dedicated serial queue - -### Phase 3 — Wire into RUM - -7. **Update `RUM.Configuration`** (`DatadogRUM/Sources/RUMConfiguration.swift`) - - Add `public var enableTimeseries: Bool = false` - -8. **Update `RUMScopeDependencies`** (`DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift`) - - Add `timeseriesCollector: TimeseriesSessionCollector?` - -9. **Update `RUMFeature.init`** (`DatadogRUM/Sources/Feature/RUMFeature.swift`) - - If `configuration.enableTimeseries && vitalsReaders != nil`: - - Create `TimeseriesSessionCollector` with memory + CPU readers and the feature scope - - Inject into `RUMScopeDependencies` - -10. **Update `RUMSessionScope`** (`DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift`) - - In `init`: call `dependencies.timeseriesCollector?.start(sessionID: sessionUUID.toRUMDataFormat, applicationID: dependencies.rumApplicationID)` - - On session end (at the existing expiry/stop call site, following Marie's prototype pattern): call `dependencies.timeseriesCollector?.stop()` - -### Phase 4 — Tests - -11. **`DatadogTimeseriesTests`** (standalone package): - - Update encoding tests to assert Schema C field names - - Verify fixture JSON matches Schema C format - -12. **`DatadogRUMTests`** (SDK): - - `TimeseriesSessionCollectorTests` — batching, flush on stop, thread safety, correct generated type used - - `RUMSessionScopeTests` — collector started/stopped with session (mock collector) - ---- - -## Verification Strategy - -After each phase: -1. **Unit tests** — `swift test --package-path DatadogTimeseries` (standalone) and `make test-ios SCHEME="DatadogRUM iOS"` (SDK) -2. **Runner script** — after Schema C changes, run `DatadogTimeseriesRunner` against the fixture CSV and assert the output JSON matches the Schema C `data_point` nested shape -3. **Linter** — `./tools/lint/run-linter.sh` after each new/modified file - ---- - -## What is NOT in scope - -- Deadband / Window filters (Plan 3 deferred) -- Android integration (parallel track) -- Merging the `bplasovska/timeseries` branch into rum-events-format main (no PR needed for this phase) -- Per-session size limiting / data cap enforcement (experiments running in parallel) -- Custom metrics beyond memory_usage and cpu_usage - ---- - -## Files to create - -| File | Purpose | -|------|---------| -| `DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift` | Session-level collector (memory + CPU) | -| `DatadogRUM/Tests/DatadogRUMTests/Timeseries/TimeseriesSessionCollectorTests.swift` | Collector tests | - -## Files to modify - -| File | Change | -|------|--------| -| `DatadogTimeseries/Sources/DatadogTimeseries/Models/TimeseriesEvent.swift` | Schema C DataPoint | -| `DatadogInternal/Sources/Models/RUM/RUMDataModels.swift` | Generated — run `make rum-models-generate GIT_REF=bplasovska/timeseries` | -| `DatadogRUM/Sources/RUMConfiguration.swift` | Add `enableTimeseries` flag | -| `DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift` | Add `timeseriesCollector` | -| `DatadogRUM/Sources/Feature/RUMFeature.swift` | Create + inject collector | -| `DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift` | Start/stop collector | -| Fixture JSON files | Schema C format | - ---- - -## Open Questions - -- Does `TimeseriesSessionCollector` need access to `DatadogContext` for `source`, `service`, `version`? (Yes — pass at `start(with:)` or `init`) -- Should `enableTimeseries` only activate if `vitalsUpdateFrequency != nil`? (Yes — guard at collector creation) -- ~~What is the `type` field value for timeseries events in the RUM schema?~~ → **`"timeseries"`** (confirmed from `TimeseriesEventBuilder.build()` in both the standalone package and Marie's prototype) diff --git a/pod/plans/git-rules-pod.md b/pod/plans/git-rules-pod.md deleted file mode 100644 index 1b4da55c30..0000000000 --- a/pod/plans/git-rules-pod.md +++ /dev/null @@ -1,47 +0,0 @@ -# Git Workflow — Timeseries Pod Override - -> These rules apply **only** during the AI Pod (Apr 13 – ~May 9, 2026) on the `feature/timeseries` branch. -> They override the global git-workflow rules for the duration of the pod. -> Once the pod ends and code moves toward `develop`, revert to the standard rules. - ---- - -## Branching - -- All work happens on `feature/timeseries` (already created). -- No new branches needed unless explicitly discussed with the team. -- Do NOT create PRs to `develop` during the pod. - ---- - -## Commits - -- **Single-line commit message**, starting with a **verb** in the imperative form. -- **No JIRA prefix** — there are no individual tickets during the pod. -- **No co-author lines** — Barbora is the sole commit author. -- Commit frequently — small, logical units of work. The agent should commit after each completed task or meaningful step. -- No description lines unless explicitly requested. - -Examples: -``` -add Package.swift scaffolding for DatadogTimeseries -implement TimeseriesBatcher with configurable batch size -fix CodingKeys for _dd field in TimeseriesEvent -add end-to-end verification test with UUID masking -``` - ---- - -## Pull Requests - -- **No PRs during the pod.** All commits go directly to `feature/timeseries`. -- Code review happens post-pod when merging to `develop`. - ---- - -## Agent autonomy - -- The agent commits directly without asking for approval. -- The agent does NOT need to run `git status` or `git diff` before committing — just stage the relevant files and commit. -- After a successful `swift build && swift test` (or equivalent verification), the agent should commit immediately. -- Keep commits atomic: one logical change per commit. From 27d2a03ebfc01b64646f00b692b7627aef9e56d1 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 29 Jul 2026 14:53:24 +0200 Subject: [PATCH 085/102] Add experimental availability to Timeseries --- DatadogRUM/Sources/RUMConfiguration.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index 35ce15b5fc..6f765e0432 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -334,6 +334,7 @@ extension RUM { /// timeseries events scoped to the RUM session. Requires `vitalsUpdateFrequency` to be set. /// /// Default: `false`. + @available(*, message: "This API is experimental and may change in future releases") public var enableTimeseries: Bool /// The default number of samples collected before a timeseries batch is flushed. From 362b620bf1d3d5fe866e497edd70f17cbcf870ce Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 3 Aug 2026 17:07:13 +0200 Subject: [PATCH 086/102] Mark enableTimeseries with @_spi(Experimental) instead of @available --- DatadogRUM/Sources/RUMConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index 6f765e0432..28c45fb03f 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -334,7 +334,7 @@ extension RUM { /// timeseries events scoped to the RUM session. Requires `vitalsUpdateFrequency` to be set. /// /// Default: `false`. - @available(*, message: "This API is experimental and may change in future releases") + @_spi(Experimental) public var enableTimeseries: Bool /// The default number of samples collected before a timeseries batch is flushed. From 547762f2738ca0e8b2f28f6786ea86a35b1e934e Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Mon, 3 Aug 2026 17:58:02 +0200 Subject: [PATCH 087/102] Apply CR suggestions --- DatadogRUM/Sources/Feature/RUMFeature.swift | 1 - .../Timeseries/TimeseriesSessionCollector.swift | 14 +++++++------- .../TimeseriesSessionCollectorTests.swift | 13 ++++++------- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 63514d207b..da3ffd0135 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -140,7 +140,6 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider memoryReader: $0.memory, featureScope: featureScope, batchSize: configuration.timeseriesBatchSize, - collectInBackground: configuration.trackBackgroundEvents, ciTest: ciTest, syntheticsTest: syntheticsTest, sessionSampleRate: Double(sessionSampleRate) diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index c7d14dabae..45198db743 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -43,7 +43,6 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private let cpuUsageProvider: () -> Double? private let batchSize: Int private let samplingInterval: TimeInterval - private let collectInBackground: Bool private let featureScope: FeatureScope private let totalRAM: Double private let ciTest: RUMCITest? @@ -71,7 +70,6 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { featureScope: FeatureScope, batchSize: Int = 120, samplingInterval: TimeInterval = 1, - collectInBackground: Bool = false, cpuUsageProvider: (() -> Double?)? = nil, totalRAM: Double = Double(ProcessInfo.processInfo.physicalMemory), ciTest: RUMCITest? = nil, @@ -81,7 +79,6 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.memoryReader = memoryReader self.batchSize = max(2, batchSize) self.samplingInterval = samplingInterval - self.collectInBackground = collectInBackground self.featureScope = featureScope self.totalRAM = totalRAM self.ciTest = ciTest @@ -90,6 +87,10 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.cpuUsageProvider = cpuUsageProvider ?? { TimeseriesSessionCollector.processCPU() } } + deinit { + timer?.cancel() + } + /// Per-process CPU as a percentage (0–100+), summed across all app threads. /// Separated into a static so it can be called from the init closure without capturing self. private static func processCPU() -> Double? { @@ -121,7 +122,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { var info = thread_basic_info() var infoCount = mach_msg_type_number_t(THREAD_INFO_MAX) let kr = withUnsafeMutablePointer(to: &info) { - $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(infoCount)) { thread_info(threadsList[Int(i)], thread_flavor_t(THREAD_BASIC_INFO), $0, &infoCount) } } @@ -130,7 +131,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } total += Double(info.cpu_usage) / Double(TH_USAGE_SCALE) * 100.0 } - return total + return min(total, 100.0) #endif } @@ -155,13 +156,12 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } /// Suspends sampling and flushes buffered data. Session state is preserved for `resume()`. Idempotent. - /// No-op when `collectInBackground` is `true`. func pause() { queue.async { [weak self] in guard let self = self else { return } - if self.collectInBackground || self.isPaused || self.timer == nil { + if self.isPaused || self.timer == nil { return } self.timer?.cancel() diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 09c3c80fe9..dcddacedfe 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -627,7 +627,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertGreaterThan(countAfterResume, countAfterPause, "Expected new events after resume") } - func testWhenCollectInBackgroundEnabled_pauseIsNoOp() { + func testWhenBackgrounded_pauseAlwaysStopsSampling() { // Given memoryReader.vitalData = 1_000_000 let collector = TimeseriesSessionCollector( @@ -635,7 +635,6 @@ class TimeseriesSessionCollectorTests: XCTestCase { featureScope: featureScope, batchSize: 2, samplingInterval: 0.05, - collectInBackground: true, cpuUsageProvider: { nil } ) let contextReader = RUMActiveContextReaderMock() @@ -651,17 +650,17 @@ class TimeseriesSessionCollectorTests: XCTestCase { let countBeforePause = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count XCTAssertGreaterThan(countBeforePause, 0) - // When — pause should be a no-op - let afterPauseExpectation = self.expectation(description: "sampling continues after pause") + // When — pause on backgrounding + let afterPauseExpectation = self.expectation(description: "sampling stopped after pause") afterPauseExpectation.assertForOverFulfill = false collector.pause() DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { afterPauseExpectation.fulfill() } waitForExpectations(timeout: 2) - collector.stop() - // Then — events keep accumulating + // Then — no new events accumulate while paused let countAfterPause = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count - XCTAssertGreaterThan(countAfterPause, countBeforePause, "Sampling should continue when collectInBackground = true") + XCTAssertEqual(countAfterPause, countBeforePause, "Sampling should stop while backgrounded, regardless of trackBackgroundEvents") + collector.stop() } func testWhenPauseCalledBeforeStart_itIsNoOp() { From dfcb918fa993b6d3a0736b045c6eac5fe77d1928 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 5 Aug 2026 11:13:02 +0200 Subject: [PATCH 088/102] Regenerate RUM models from rum-events-format optional-view schema --- .../Sources/Models/RUM/RUMDataModels.swift | 2 +- .../Sources/DataModels/RUMDataModels+objc.swift | 2 +- .../Timeseries/TimeseriesSessionCollectorTests.swift | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift index 702c837f71..a793dbf421 100644 --- a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift +++ b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift @@ -15586,4 +15586,4 @@ extension TelemetryUsageEvent.Telemetry { } } -// Generated from https://github.com/DataDog/rum-events-format/tree/00d5005015e04067bc591e618640de06ecbf7d23 +// Generated from https://github.com/DataDog/rum-events-format/tree/ece51fc7977b612330049af36095ab2310a001af diff --git a/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift b/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift index 3621a998b7..9ebd3cb66c 100644 --- a/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift +++ b/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift @@ -17478,4 +17478,4 @@ public class objc_TelemetryErrorEventView: NSObject { // swiftlint:enable force_unwrapping -// Generated from https://github.com/DataDog/rum-events-format/tree/00d5005015e04067bc591e618640de06ecbf7d23 +// Generated from https://github.com/DataDog/rum-events-format/tree/ece51fc7977b612330049af36095ab2310a001af diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index dcddacedfe..732ea24a96 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -222,8 +222,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { // Then let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) XCTAssertFalse(events.isEmpty) - XCTAssertEqual(events[0].view.id, "view-abc") - XCTAssertEqual(events[0].view.url, "/view/abc") + XCTAssertEqual(events[0].view?.id, "view-abc") + XCTAssertEqual(events[0].view?.url, "/view/abc") } func testWhenActiveViewHasName_itAttachesViewNameToEvent() { @@ -253,7 +253,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { // Then let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) XCTAssertFalse(events.isEmpty) - XCTAssertEqual(events[0].view.name, "ViewController") + XCTAssertEqual(events[0].view?.name, "ViewController") } func testWhenSessionReplayHasReplay_itAttachesHasReplayToEvent() { @@ -351,11 +351,11 @@ class TimeseriesSessionCollectorTests: XCTestCase { // Then — the batch is still written, attributed to the view it was actually collected under let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) XCTAssertFalse(events.isEmpty, "Expected the batch collected under an active view to be flushed, not dropped") - XCTAssertEqual(events[0].view.id, "view-ending") - XCTAssertEqual(events[0].view.url, "/view/ending") + XCTAssertEqual(events[0].view?.id, "view-ending") + XCTAssertEqual(events[0].view?.url, "/view/ending") } - func testWhenNoActiveView_itDropsEvent() { + func testWhenNoActiveView_itWritesEventWithoutView() { // Given memoryReader.vitalData = 1_000_000 let scope = FeatureScopeMock(context: .mockWith(additionalContext: [])) From 327045759dfd564bb0dd90c863bf7c52598950a8 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 5 Aug 2026 11:13:17 +0200 Subject: [PATCH 089/102] Write timeseries batches with no view instead of dropping them --- .../TimeseriesSessionCollector.swift | 22 +++++++++---------- .../TimeseriesSessionCollectorTests.swift | 6 +++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 45198db743..5078458f1e 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -272,12 +272,11 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { // The batch is attributed to the most recently active view among its samples, not the view active // at flush time — a view ending right before a scheduled flush shouldn't drop data that was - // genuinely collected while it was active. Only dropped if no sample in the batch had a view. - guard let view = Self.lastKnownView( + // genuinely collected while it was active. `view` is left `nil` if no sample in the batch had one + // (e.g. samples collected before the first view starts), rather than dropping the batch. + let view = Self.lastKnownView( in: batch.map { (viewID: $0.viewID, viewPath: $0.viewPath, viewName: $0.viewName) } - ) else { - return - } + ) featureScope.eventWriteContext { context, writer in let offsetNs = context.serverTimeOffset.dd.toInt64Nanoseconds @@ -315,7 +314,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { ), usr: .init(context: context), version: context.version, - view: .init(id: view.id, name: view.name, url: view.path) + view: view.map { .init(id: $0.id, name: $0.name, url: $0.path) } ) writer.write(value: event) } @@ -340,12 +339,11 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { // The batch is attributed to the most recently active view among its samples, not the view active // at flush time — a view ending right before a scheduled flush shouldn't drop data that was - // genuinely collected while it was active. Only dropped if no sample in the batch had a view. - guard let view = Self.lastKnownView( + // genuinely collected while it was active. `view` is left `nil` if no sample in the batch had one + // (e.g. samples collected before the first view starts), rather than dropping the batch. + let view = Self.lastKnownView( in: batch.map { (viewID: $0.viewID, viewPath: $0.viewPath, viewName: $0.viewName) } - ) else { - return - } + ) featureScope.eventWriteContext { context, writer in let offsetNs = context.serverTimeOffset.dd.toInt64Nanoseconds @@ -380,7 +378,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { ), usr: .init(context: context), version: context.version, - view: .init(id: view.id, name: view.name, url: view.path) + view: view.map { .init(id: $0.id, name: $0.name, url: $0.path) } ) writer.write(value: event) } diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 732ea24a96..1352412148 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -377,8 +377,10 @@ class TimeseriesSessionCollectorTests: XCTestCase { waitForExpectations(timeout: 2) collector.stop() - // Then — no view context means no `view.id`/`view.url` to report, so the batch is dropped - XCTAssertTrue(scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty) + // Then — no view context means the batch is still written, just with `view: nil`, not dropped + let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + XCTAssertFalse(events.isEmpty, "Expected the batch collected without an active view to be flushed with view: nil, not dropped") + XCTAssertNil(events[0].view) } func testWhenStartIsCalledWithoutStop_itFlushesPartialBufferBeforeNewSession() { From 215bab30e3f5194c9464ba790d7845ab2ba9b528 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 5 Aug 2026 12:21:53 +0200 Subject: [PATCH 090/102] Remove enableTimeseries initializer parameter to enforce SPI-only opt-in --- DatadogRUM/Sources/RUMConfiguration.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index 28c45fb03f..a9684f17af 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -608,7 +608,6 @@ extension RUM.Configuration { trackSlowFrames: Bool = true, telemetrySampleRate: SampleRate = 20, collectAccessibility: Bool = false, - enableTimeseries: Bool = false, timeseriesBatchSize: Int = RUM.Configuration.defaultTimeseriesBatchSize, featureFlags: FeatureFlags = .defaults ) { @@ -639,7 +638,7 @@ extension RUM.Configuration { self.trackSlowFrames = trackSlowFrames self.telemetrySampleRate = telemetrySampleRate self.collectAccessibility = collectAccessibility - self.enableTimeseries = enableTimeseries + self.enableTimeseries = false self.timeseriesBatchSize = timeseriesBatchSize self.featureFlags = featureFlags } @@ -667,7 +666,6 @@ extension RUM.Configuration { trackSlowFrames: Bool = true, telemetrySampleRate: SampleRate = 20, collectAccessibility: Bool = false, - enableTimeseries: Bool = false, timeseriesBatchSize: Int = RUM.Configuration.defaultTimeseriesBatchSize, featureFlags: FeatureFlags = .defaults ) { @@ -693,7 +691,7 @@ extension RUM.Configuration { self.trackSlowFrames = trackSlowFrames self.telemetrySampleRate = telemetrySampleRate self.collectAccessibility = collectAccessibility - self.enableTimeseries = enableTimeseries + self.enableTimeseries = false self.timeseriesBatchSize = timeseriesBatchSize self.featureFlags = featureFlags } From 7f8a3595ff604d055f43f103ba5ee50fd1797142 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 5 Aug 2026 13:12:32 +0200 Subject: [PATCH 091/102] update api surface reference after removing enableTimeseries init parameter --- api-surface-swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-surface-swift b/api-surface-swift index f05aa41454..174b873191 100644 --- a/api-surface-swift +++ b/api-surface-swift @@ -446,7 +446,7 @@ public enum RUM case matchHeaders([String]) public init(firstPartyHostsTracing: RUM.Configuration.URLSessionTracking.FirstPartyHostsTracing? = nil,resourceAttributesProvider: RUM.ResourceAttributesProvider? = nil,trackResourceHeaders: TrackResourceHeaders = .disabled) [?] extension RUM.Configuration - public init(applicationID: String,sessionSampleRate: SampleRate = .maxSampleRate,uiKitViewsPredicate: UIKitRUMViewsPredicate? = nil,uiKitActionsPredicate: UIKitRUMActionsPredicate? = nil,swiftUIViewsPredicate: SwiftUIRUMViewsPredicate? = nil,swiftUIActionsPredicate: SwiftUIRUMActionsPredicate? = nil,urlSessionTracking: URLSessionTracking? = nil,trackFrustrations: Bool = true,trackBackgroundEvents: Bool = false,longTaskThreshold: TimeInterval? = 0.1,appHangThreshold: TimeInterval? = nil,trackWatchdogTerminations: Bool = false,vitalsUpdateFrequency: VitalsFrequency? = .average,networkSettledResourcePredicate: NetworkSettledResourcePredicate = TimeBasedTNSResourcePredicate(),nextViewActionPredicate: NextViewActionPredicate? = TimeBasedINVActionPredicate(),viewEventMapper: RUM.ViewEventMapper? = nil,resourceEventMapper: RUM.ResourceEventMapper? = nil,actionEventMapper: RUM.ActionEventMapper? = nil,errorEventMapper: RUM.ErrorEventMapper? = nil,longTaskEventMapper: RUM.LongTaskEventMapper? = nil,onSessionStart: RUM.SessionListener? = nil,customEndpoint: URL? = nil,trackAnonymousUser: Bool = true,trackMemoryWarnings: Bool = true,trackSlowFrames: Bool = true,telemetrySampleRate: SampleRate = 20,collectAccessibility: Bool = false,enableTimeseries: Bool = false,timeseriesBatchSize: Int = RUM.Configuration.defaultTimeseriesBatchSize,featureFlags: FeatureFlags = .defaults) + public init(applicationID: String,sessionSampleRate: SampleRate = .maxSampleRate,uiKitViewsPredicate: UIKitRUMViewsPredicate? = nil,uiKitActionsPredicate: UIKitRUMActionsPredicate? = nil,swiftUIViewsPredicate: SwiftUIRUMViewsPredicate? = nil,swiftUIActionsPredicate: SwiftUIRUMActionsPredicate? = nil,urlSessionTracking: URLSessionTracking? = nil,trackFrustrations: Bool = true,trackBackgroundEvents: Bool = false,longTaskThreshold: TimeInterval? = 0.1,appHangThreshold: TimeInterval? = nil,trackWatchdogTerminations: Bool = false,vitalsUpdateFrequency: VitalsFrequency? = .average,networkSettledResourcePredicate: NetworkSettledResourcePredicate = TimeBasedTNSResourcePredicate(),nextViewActionPredicate: NextViewActionPredicate? = TimeBasedINVActionPredicate(),viewEventMapper: RUM.ViewEventMapper? = nil,resourceEventMapper: RUM.ResourceEventMapper? = nil,actionEventMapper: RUM.ActionEventMapper? = nil,errorEventMapper: RUM.ErrorEventMapper? = nil,longTaskEventMapper: RUM.LongTaskEventMapper? = nil,onSessionStart: RUM.SessionListener? = nil,customEndpoint: URL? = nil,trackAnonymousUser: Bool = true,trackMemoryWarnings: Bool = true,trackSlowFrames: Bool = true,telemetrySampleRate: SampleRate = 20,collectAccessibility: Bool = false,timeseriesBatchSize: Int = RUM.Configuration.defaultTimeseriesBatchSize,featureFlags: FeatureFlags = .defaults) [?] extension InternalExtension where ExtendedType == RUM.Configuration public var configurationTelemetrySampleRate: Float [?] extension RUM.Configuration From 3855dabdb4c5547187265238bdad58d67ed9d974 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 5 Aug 2026 11:59:21 +0200 Subject: [PATCH 092/102] RUM-17879 Tie TimeseriesSessionCollector lifecycle to RUM session lifetime --- .../RUMMonitor/Scopes/RUMSessionScope.swift | 12 +- .../TimeseriesSessionCollector.swift | 76 ++++-- .../Scopes/RUMSessionScopeTests.swift | 28 +- .../TimeseriesSessionCollectorTests.swift | 246 ++++++++++++++++-- 4 files changed, 310 insertions(+), 52 deletions(-) diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift index 5233b4f3b0..e7c22d2c0e 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift @@ -102,7 +102,7 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { private var hadApplicationLaunchViewWhenEnteringBackground: Bool? = nil /// The reason why this session has ended or `nil` if it is still active. private(set) var endReason: EndReason? { - didSet { if endReason != nil { dependencies.timeseriesCollector?.stop() } } + didSet { if endReason != nil { dependencies.timeseriesCollector?.stop(sessionID: sessionUUID.rawValue.uuidString.lowercased()) } } } /// Counter to track the index of views in this session. Starts at 0 for the first view. @@ -175,10 +175,11 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { dependencies.timeseriesCollector?.start( sessionID: sessionUUID.rawValue.uuidString.lowercased(), applicationID: dependencies.rumApplicationID, - sessionType: dependencies.sessionType + sessionType: dependencies.sessionType, + startTime: startTime ) if !context.applicationStateHistory.currentState.isRunningInForeground { - dependencies.timeseriesCollector?.pause() + dependencies.timeseriesCollector?.pause(sessionID: sessionUUID.rawValue.uuidString.lowercased()) } } } @@ -256,6 +257,7 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { if command.isUserInteraction { lastInteractionTime = command.time + dependencies.timeseriesCollector?.noteActivity(sessionID: sessionUUID.rawValue.uuidString.lowercased(), at: command.time) } if !sampler.isSampled { @@ -286,13 +288,13 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { case let appLifecycleCommand as RUMHandleAppLifecycleEventCommand where appLifecycleCommand.event == .didEnterBackground: hadApplicationLaunchViewWhenEnteringBackground = activeView?.viewPath == RUMOffViewEventsHandlingRule.Constants.applicationLaunchViewURL appLaunchManager.process(command, context: context, writer: writer) - dependencies.timeseriesCollector?.pause() + dependencies.timeseriesCollector?.pause(sessionID: sessionUUID.rawValue.uuidString.lowercased()) case let appLifecycleCommand as RUMHandleAppLifecycleEventCommand where appLifecycleCommand.event == .willEnterForeground: if hadApplicationLaunchViewWhenEnteringBackground == true { startApplicationLaunchView(on: appLifecycleCommand, context: context, writer: writer) } hadApplicationLaunchViewWhenEnteringBackground = nil - dependencies.timeseriesCollector?.resume() + dependencies.timeseriesCollector?.resume(sessionID: sessionUUID.rawValue.uuidString.lowercased()) case let operationStepVitalCommand as RUMOperationStepVitalCommand: // Forward command to the feature operation manager diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 5078458f1e..c58ac2f7a8 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -9,10 +9,13 @@ import DatadogInternal /// Defines the interface for collecting timeseries data during a RUM session. internal protocol TimeseriesCollecting: AnyObject { - func start(sessionID: String, applicationID: String, sessionType: RUMSessionType) - func pause() - func resume() - func stop() + func start(sessionID: String, applicationID: String, sessionType: RUMSessionType, startTime: Date) + func pause(sessionID: String) + func resume(sessionID: String) + func stop(sessionID: String) + /// Reports that a RUM interaction was just processed, so the collector can self-enforce + /// the session inactivity timeout even if no further commands ever arrive to call `stop(sessionID:)`. + func noteActivity(sessionID: String, at time: Date) } /// Collects memory and CPU samples at configurable intervals (default: 1 s) during a RUM session and flushes them @@ -61,6 +64,13 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private var sessionType: RUMSessionType = .user private var timer: DispatchSourceTimer? private var isPaused: Bool = false + /// The start time of the current session, used to self-enforce `RUMSessionScope.Constants.sessionMaxDuration` + /// even if the RUM command pipeline never notifies this collector of the session's expiry. + private var sessionStartTime: Date = .distantPast + /// The time of the last RUM interaction reported via `noteActivity(sessionID:at:)`, used to self-enforce + /// `RUMSessionScope.Constants.sessionTimeoutDuration` even when the app goes idle with no RUM commands. + private var lastActivityTime: Date = .distantPast + private let now: () -> Date /// All buffer mutations and timer events run on this queue. private let queue = DispatchQueue(label: "com.datadoghq.timeseries-collector", qos: .utility) @@ -74,7 +84,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { totalRAM: Double = Double(ProcessInfo.processInfo.physicalMemory), ciTest: RUMCITest? = nil, syntheticsTest: RUMSyntheticsTest? = nil, - sessionSampleRate: Double = 100 + sessionSampleRate: Double = 100, + now: @escaping () -> Date = Date.init ) { self.memoryReader = memoryReader self.batchSize = max(2, batchSize) @@ -85,6 +96,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.syntheticsTest = syntheticsTest self.sessionSampleRate = sessionSampleRate self.cpuUsageProvider = cpuUsageProvider ?? { TimeseriesSessionCollector.processCPU() } + self.now = now } deinit { @@ -136,7 +148,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } /// Resets state, flips the compression coin, and starts the sampling timer for the new session. - func start(sessionID: String, applicationID: String, sessionType: RUMSessionType) { + func start(sessionID: String, applicationID: String, sessionType: RUMSessionType, startTime: Date = Date()) { queue.async { [weak self] in guard let self = self else { return @@ -146,6 +158,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.sessionID = sessionID self.applicationID = applicationID self.sessionType = sessionType + self.sessionStartTime = startTime + self.lastActivityTime = startTime self.memoryBuffer = [] self.cpuBuffer = [] self.isPaused = false @@ -156,9 +170,11 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } /// Suspends sampling and flushes buffered data. Session state is preserved for `resume()`. Idempotent. - func pause() { + /// No-ops if `sessionID` no longer matches the currently active session (e.g. a call left over from a + /// session that has since ended and been replaced — see `RUMSessionScope.Constants` for expiry rules). + func pause(sessionID: String) { queue.async { [weak self] in - guard let self = self else { + guard let self = self, self.sessionID == sessionID else { return } if self.isPaused || self.timer == nil { @@ -173,9 +189,10 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } /// Resumes sampling after `pause()`. Idempotent — only takes effect if currently paused. - func resume() { + /// No-ops if `sessionID` no longer matches the currently active session. + func resume(sessionID: String) { queue.async { [weak self] in - guard let self = self, self.isPaused else { + guard let self = self, self.sessionID == sessionID, self.isPaused else { return } self.isPaused = false @@ -184,9 +201,10 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } /// Stops sampling and flushes any remaining buffered data points. - func stop() { + /// No-ops if `sessionID` no longer matches the currently active session. + func stop(sessionID: String) { queue.async { [weak self] in - guard let self = self else { + guard let self = self, self.sessionID == sessionID else { return } self.timer?.cancel() @@ -197,6 +215,18 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } } + /// Records that a RUM interaction happened, resetting the inactivity clock this collector uses to + /// self-enforce `RUMSessionScope.Constants.sessionTimeoutDuration` (see `sample()`). + /// No-ops if `sessionID` no longer matches the currently active session. + func noteActivity(sessionID: String, at time: Date) { + queue.async { [weak self] in + guard let self = self, self.sessionID == sessionID else { + return + } + self.lastActivityTime = time + } + } + /// Returns the most recently active view among the given samples, searching from the end of the batch /// backwards, or `nil` if no sample in the batch had a view. private static func lastKnownView( @@ -221,7 +251,23 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { // MARK: - Private private func sample() { - let now = Int64.ddWithNoOverflow(Date().timeIntervalSince1970 * 1_000_000_000) + let currentDate = now() + + // Self-enforce the same session lifetime rules `RUMSessionScope` uses, in case this session + // has expired without any RUM command arriving to call `stop(sessionID:)` (e.g. the app went + // idle with no user interaction). This is a safety net only — it does not affect RUM's own + // session state, it just stops this collector from uploading data past session expiry. + let sessionExceededMaxDuration = currentDate.timeIntervalSince(sessionStartTime) >= RUMSessionScope.Constants.sessionMaxDuration + let sessionExceededInactivityTimeout = currentDate.timeIntervalSince(lastActivityTime) >= RUMSessionScope.Constants.sessionTimeoutDuration + if sessionExceededMaxDuration || sessionExceededInactivityTimeout { + timer?.cancel() + timer = nil + flushMemory() + flushCPU() + return + } + + let timestamp = Int64.ddWithNoOverflow(currentDate.timeIntervalSince1970 * 1_000_000_000) let activeView = activeContextReader?.activeView let viewID = activeView?.id let viewPath = activeView?.path @@ -232,7 +278,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let memoryPercent = totalRAM > 0 ? bytes / totalRAM * 100 : 0 memoryBuffer.append( MemorySample( - timestamp: now, + timestamp: timestamp, footprintKB: footprintKB, percent: memoryPercent, viewID: viewID, @@ -246,7 +292,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } if let cpuUsage = cpuUsageProvider() { - cpuBuffer.append(CPUSample(timestamp: now, usage: cpuUsage, viewID: viewID, viewPath: viewPath, viewName: viewName)) + cpuBuffer.append(CPUSample(timestamp: timestamp, usage: cpuUsage, viewID: viewID, viewPath: viewPath, viewName: viewName)) if cpuBuffer.count >= batchSize { flushCPU() } diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift index c889772470..137bdbef89 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift @@ -870,21 +870,41 @@ private class TimeseriesCollectorSpy: TimeseriesCollecting { var pauseCallCount = 0 var resumeCallCount = 0 var stopCallCount = 0 + var noteActivityCallCount = 0 var lastStartedSessionID: String? var lastStartedApplicationID: String? var lastStartedSessionType: RUMSessionType? + var lastStartedStartTime: Date? + var lastStoppedSessionID: String? + var lastPausedSessionID: String? + var lastResumedSessionID: String? + var lastNoteActivitySessionID: String? - func start(sessionID: String, applicationID: String, sessionType: RUMSessionType) { + func start(sessionID: String, applicationID: String, sessionType: RUMSessionType, startTime: Date) { startCallCount += 1 lastStartedSessionID = sessionID lastStartedApplicationID = applicationID lastStartedSessionType = sessionType + lastStartedStartTime = startTime } - func pause() { pauseCallCount += 1 } - func resume() { resumeCallCount += 1 } + func pause(sessionID: String) { + pauseCallCount += 1 + lastPausedSessionID = sessionID + } + + func resume(sessionID: String) { + resumeCallCount += 1 + lastResumedSessionID = sessionID + } - func stop() { + func stop(sessionID: String) { stopCallCount += 1 + lastStoppedSessionID = sessionID + } + + func noteActivity(sessionID: String, at time: Date) { + noteActivityCallCount += 1 + lastNoteActivitySessionID = sessionID } } diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 1352412148..88e47cf4af 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -38,7 +38,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-abc", applicationID: "app-123", sessionType: .user) waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-abc") // Then let events = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) @@ -78,7 +78,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-abc", applicationID: "app-123", sessionType: .user) waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-abc") // Then let events = featureScope.eventsWritten(ofType: RUMTimeseriesCpuEvent.self) @@ -117,7 +117,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-both", applicationID: "app-both", sessionType: .user) waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-both") // Then XCTAssertFalse(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, "Expected memory events") @@ -153,7 +153,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-offset", applicationID: "app-offset", sessionType: .user) waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-offset") // Then — date and data point timestamps are server-adjusted let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) @@ -187,7 +187,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-rn", applicationID: "app-rn", sessionType: .user) waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-rn") // Then let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) @@ -217,7 +217,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-view", applicationID: "app-view", sessionType: .user) waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-view") // Then let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) @@ -248,7 +248,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-view-name", applicationID: "app-view-name", sessionType: .user) waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-view-name") // Then let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) @@ -279,7 +279,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-has-replay", applicationID: "app-has-replay", sessionType: .user) waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-has-replay") // Then let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) @@ -310,7 +310,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-sample-rate", applicationID: "app-sample-rate", sessionType: .user) waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-sample-rate") // Then let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) @@ -344,7 +344,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { contextReader.activeView = (nil, nil, nil) let syncExpectation = self.expectation(description: "stop completed") - collector.stop() + collector.stop(sessionID: "session-ending-view") DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.2) { syncExpectation.fulfill() } waitForExpectations(timeout: 2) @@ -375,7 +375,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-no-view", applicationID: "app-no-view", sessionType: .user) waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-no-view") // Then — no view context means the batch is still written, just with `view: nil`, not dropped let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) @@ -414,7 +414,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { let events = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) XCTAssertFalse(events.isEmpty, "Expected session-1 partial buffer to be flushed on re-start") XCTAssertEqual(events[0].session.id, "session-1") - collector.stop() + collector.stop(sessionID: "session-2") } // MARK: - Flush on stop @@ -441,7 +441,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { waitForExpectations(timeout: 2) let syncExpectation = self.expectation(description: "stop completed") - collector.stop() + collector.stop(sessionID: "session-xyz") DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.2) { syncExpectation.fulfill() } waitForExpectations(timeout: 2) @@ -476,7 +476,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { waitForExpectations(timeout: 2) let syncExpectation = self.expectation(description: "stop completed") - collector.stop() + collector.stop(sessionID: "session-xyz") DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.2) { syncExpectation.fulfill() } waitForExpectations(timeout: 2) @@ -506,7 +506,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-abc", applicationID: "app-123", sessionType: .user) waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-abc") // Then XCTAssertTrue(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty) @@ -544,7 +544,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { // Flush second session let stopExpectation = self.expectation(description: "stop completed") - collector.stop() + collector.stop(sessionID: "session-2") DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.2) { stopExpectation.fulfill() } waitForExpectations(timeout: 2) @@ -581,7 +581,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { // When — pause and wait for more potential samples let pauseExpectation = self.expectation(description: "pause settled") - collector.pause() + collector.pause(sessionID: "session-pause") DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3) { pauseExpectation.fulfill() } waitForExpectations(timeout: 2) @@ -591,7 +591,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { let countAfterSettle = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count XCTAssertEqual(countAfterPause, countAfterSettle, "No further events should be written while paused") - collector.stop() + collector.stop(sessionID: "session-pause") } func testWhenResumedAfterPause_itContinuesSampling() { @@ -610,7 +610,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-resume", applicationID: "app-1", sessionType: .user) let pauseExpectation = self.expectation(description: "pause settled") - collector.pause() + collector.pause(sessionID: "session-resume") DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.1) { pauseExpectation.fulfill() } waitForExpectations(timeout: 2) @@ -619,10 +619,10 @@ class TimeseriesSessionCollectorTests: XCTestCase { // When — resume and let samples accumulate let resumeExpectation = self.expectation(description: "resumed samples collected") resumeExpectation.assertForOverFulfill = false - collector.resume() + collector.resume(sessionID: "session-resume") DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { resumeExpectation.fulfill() } waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-resume") // Then — new events were written after resume let countAfterResume = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count @@ -655,14 +655,14 @@ class TimeseriesSessionCollectorTests: XCTestCase { // When — pause on backgrounding let afterPauseExpectation = self.expectation(description: "sampling stopped after pause") afterPauseExpectation.assertForOverFulfill = false - collector.pause() + collector.pause(sessionID: "session-bg") DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { afterPauseExpectation.fulfill() } waitForExpectations(timeout: 2) // Then — no new events accumulate while paused let countAfterPause = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count XCTAssertEqual(countAfterPause, countBeforePause, "Sampling should stop while backgrounded, regardless of trackBackgroundEvents") - collector.stop() + collector.stop(sessionID: "session-bg") } func testWhenPauseCalledBeforeStart_itIsNoOp() { @@ -676,8 +676,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { ) // When / Then — should not crash - collector.pause() - collector.resume() + collector.pause(sessionID: "") + collector.resume(sessionID: "") let settleExpectation = self.expectation(description: "settle") DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.1) { settleExpectation.fulfill() } @@ -724,7 +724,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-common", applicationID: "app-common", sessionType: .user) waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-common") // Then let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) @@ -762,7 +762,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-context", applicationID: "app-context", sessionType: .user) waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-context") // Then let events = scope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) @@ -791,7 +791,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.start(sessionID: "session-abc", applicationID: "app-123", sessionType: .user) waitForExpectations(timeout: 2) - collector.stop() + collector.stop(sessionID: "session-abc") // Then let events = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) @@ -804,6 +804,184 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertEqual(event.timeseries.start, timestamps.first) XCTAssertEqual(event.timeseries.end, timestamps.last) } + + // MARK: - Session lifetime self-enforcement + + func testWhenSessionExceedsMaxDuration_itSelfStopsAndFlushes() { + // Given + memoryReader.vitalData = 1_000_000 + let clock = MutableClock() + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 100, // won't auto-flush — only self-expiry triggers the flush + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + totalRAM: 4_000_000_000, + now: clock.now + ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader + + let startTime = clock.date + collector.start(sessionID: "session-expired", applicationID: "app-1", sessionType: .user, startTime: startTime) + + let beforeExpiryExpectation = self.expectation(description: "samples collected before expiry") + beforeExpiryExpectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { beforeExpiryExpectation.fulfill() } + waitForExpectations(timeout: 2) + XCTAssertTrue(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, "Batch should not be flushed while the session is still within its max duration") + + // When — advance the (injected) clock past the session's max duration, without ever calling stop() + clock.date = startTime.addingTimeInterval(RUMSessionScope.Constants.sessionMaxDuration) + let selfStopExpectation = self.expectation(description: "self-stop settled") + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3) { selfStopExpectation.fulfill() } + waitForExpectations(timeout: 2) + + // Then — the buffered samples were flushed once the session exceeded max duration + let events = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + XCTAssertFalse(events.isEmpty, "Expected the collector to self-flush once the session exceeded max duration") + let countAfterSelfStop = events.count + + // And — no further samples accumulate afterwards (the timer was cancelled) + let settleExpectation = self.expectation(description: "no further samples after self-stop") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { settleExpectation.fulfill() } + waitForExpectations(timeout: 2) + XCTAssertEqual(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count, countAfterSelfStop, "No further batches should be written after self-stop") + } + + func testWhenNoActivityWithinTimeoutWindow_itSelfStopsAndFlushes() { + // Given + memoryReader.vitalData = 1_000_000 + let clock = MutableClock() + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 100, // won't auto-flush — only self-expiry triggers the flush + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + totalRAM: 4_000_000_000, + now: clock.now + ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader + + let startTime = clock.date + collector.start(sessionID: "session-idle", applicationID: "app-1", sessionType: .user, startTime: startTime) + + let beforeTimeoutExpectation = self.expectation(description: "samples collected before timeout") + beforeTimeoutExpectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { beforeTimeoutExpectation.fulfill() } + waitForExpectations(timeout: 2) + XCTAssertTrue(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty) + + // When — advance the clock past the inactivity timeout without ever calling noteActivity(sessionID:at:) + clock.date = startTime.addingTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration) + let selfStopExpectation = self.expectation(description: "self-stop settled") + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3) { selfStopExpectation.fulfill() } + waitForExpectations(timeout: 2) + + // Then + XCTAssertFalse(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, "Expected the collector to self-flush once idle past the inactivity timeout") + } + + func testNoteActivity_preventsSelfStopWithinTimeoutWindow() { + // Given + memoryReader.vitalData = 1_000_000 + let clock = MutableClock() + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + totalRAM: 4_000_000_000, + now: clock.now + ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader + + let startTime = clock.date + collector.start(sessionID: "session-active", applicationID: "app-1", sessionType: .user, startTime: startTime) + + // When — activity is reported right before what would have been the inactivity timeout, resetting the clock + clock.date = startTime.addingTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration - 1) + collector.noteActivity(sessionID: "session-active", at: clock.date) + clock.date = clock.date.addingTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration - 1) + + let expectation = self.expectation(description: "still sampling") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } + waitForExpectations(timeout: 2) + + // Then — sampling continued since activity reset the inactivity clock before it lapsed + XCTAssertFalse(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, "Expected sampling to continue since activity reset the inactivity clock") + collector.stop(sessionID: "session-active") + } + + // MARK: - Session-ID guard against stale calls + + func testStaleStopCall_afterNewSessionStarted_doesNotStopNewSession() { + // Given + memoryReader.vitalData = 1_000_000 + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + totalRAM: 4_000_000_000 + ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader + + collector.start(sessionID: "session-old", applicationID: "app-1", sessionType: .user) + collector.start(sessionID: "session-new", applicationID: "app-1", sessionType: .user) + + // When — a stale stop() call for the already-replaced session arrives + collector.stop(sessionID: "session-old") + + // Then — sampling for the new session continues uninterrupted + let expectation = self.expectation(description: "new session still sampling") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } + waitForExpectations(timeout: 2) + + let events = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self) + XCTAssertFalse(events.isEmpty, "Expected the new session to keep sampling despite a stale stop() call for the old session") + XCTAssertEqual(events.last?.session.id, "session-new") + collector.stop(sessionID: "session-new") + } + + func testStalePauseCall_afterNewSessionStarted_doesNotPauseNewSession() { + // Given + memoryReader.vitalData = 1_000_000 + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 2, + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + totalRAM: 4_000_000_000 + ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader + + collector.start(sessionID: "session-old", applicationID: "app-1", sessionType: .user) + collector.start(sessionID: "session-new", applicationID: "app-1", sessionType: .user) + + // When — a stale pause() call for the already-replaced session arrives + collector.pause(sessionID: "session-old") + + // Then — the new session keeps sampling, unaffected + let expectation = self.expectation(description: "new session still sampling despite stale pause") + expectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } + waitForExpectations(timeout: 2) + + XCTAssertFalse(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, "Expected sampling to continue since the stale pause() targeted a different session") + collector.stop(sessionID: "session-new") + } } private class RUMActiveContextReaderMock: RUMActiveContextReader { @@ -819,4 +997,16 @@ private class RUMActiveContextReaderMock: RUMActiveContextReader { } } +/// A `Date` provider whose current time can be advanced manually, used to deterministically test the +/// collector's self-enforced session expiry without waiting on real wall-clock durations. +private class MutableClock { + var date: Date + + init(date: Date = Date()) { + self.date = date + } + + func now() -> Date { date } +} + #endif From 117ed8ad8a50070a4b272789d985f691372ac415 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Wed, 5 Aug 2026 14:38:58 +0200 Subject: [PATCH 093/102] RUM-17879 Remove stale enableTimeseries doc comment --- DatadogRUM/Sources/RUMConfiguration.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index a9684f17af..3239c81fcc 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -571,7 +571,6 @@ extension RUM.Configuration { /// - trackSlowFrames: Enables the collection of slow frames (view hitches). Default: `true`. /// - telemetrySampleRate: The sampling rate for SDK internal telemetry utilized by Datadog. Must be a value between `0` and `100`. Default: `20`. /// - collectAccessibility: Determines whether accessibility data should be collected and included in RUM view events. Default: `false`. - /// - enableTimeseries: Enables collection of memory and CPU timeseries events. Default: `false`. /// - timeseriesBatchSize: The number of samples collected before a timeseries batch is flushed. Default: `120`. /// - featureFlags: Experimental feature flags. /// From 51fafcaed8fed3c0597134997e23dd326b0278b1 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 6 Aug 2026 10:13:15 +0200 Subject: [PATCH 094/102] RUM-17879 Address remaining PR review feedback on timeseries collection --- DatadogRUM/Sources/Feature/RUMFeature.swift | 24 ++++++++------- .../Sources/RUMEvent/RUMEventSanitizer.swift | 4 +++ DatadogRUM/Sources/RUMMonitor/Monitor.swift | 7 +++-- .../TimeseriesSessionCollector.swift | 29 +++++++++++++++++-- .../Scopes/RUMSessionScopeTests.swift | 6 ++++ 5 files changed, 55 insertions(+), 15 deletions(-) diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index da3ffd0135..10e53f3494 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -23,6 +23,10 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider let anonymousIdentifierManager: AnonymousIdentifierManaging + /// Collects memory/CPU timeseries samples during the RUM session, if enabled. Retained here so it can be + /// flushed alongside other instrumentation in `flush()`. + private let timeseriesCollector: TimeseriesCollecting? + /// Used by WebViewTracking to obtain the RUM session sampler synchronously. @ReadWriteLock private(set) var rumSessionSampler: DeterministicSampler? @@ -135,16 +139,14 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider let sessionSampleRate = configuration.debugSDK ? 100 : configuration.sessionSampleRate - let timeseriesCollector: TimeseriesSessionCollector? = configuration.enableTimeseries ? vitalsReaders.map { - TimeseriesSessionCollector( - memoryReader: $0.memory, - featureScope: featureScope, - batchSize: configuration.timeseriesBatchSize, - ciTest: ciTest, - syntheticsTest: syntheticsTest, - sessionSampleRate: Double(sessionSampleRate) - ) - } : nil + let timeseriesCollector: TimeseriesSessionCollector? = configuration.enableTimeseries ? TimeseriesSessionCollector( + memoryReader: VitalMemoryReader(), + featureScope: featureScope, + batchSize: configuration.timeseriesBatchSize, + ciTest: ciTest, + syntheticsTest: syntheticsTest, + sessionSampleRate: Double(sessionSampleRate) + ) : nil let dependencies = RUMScopeDependencies( featureScope: featureScope, @@ -222,6 +224,7 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider ) timeseriesCollector?.activeContextReader = monitor + self.timeseriesCollector = timeseriesCollector if let refreshRateVital = dependencies.vitalsReaders?.refreshRate as? RenderLoopReader { dependencies.renderLoopObserver?.register(refreshRateVital) @@ -421,6 +424,7 @@ extension RUMFeature: Flushable { /// **blocks the caller thread** func flush() { instrumentation.appHangs?.flush() + timeseriesCollector?.flush() } } diff --git a/DatadogRUM/Sources/RUMEvent/RUMEventSanitizer.swift b/DatadogRUM/Sources/RUMEvent/RUMEventSanitizer.swift index 60d24b8730..d33b0d9c2c 100644 --- a/DatadogRUM/Sources/RUMEvent/RUMEventSanitizer.swift +++ b/DatadogRUM/Sources/RUMEvent/RUMEventSanitizer.swift @@ -91,3 +91,7 @@ extension RUMResourceEvent: RUMSanitizableEvent {} extension RUMErrorEvent: RUMSanitizableEvent {} extension RUMLongTaskEvent: RUMSanitizableEvent {} + +extension RUMTimeseriesMemoryEvent: RUMSanitizableEvent {} + +extension RUMTimeseriesCpuEvent: RUMSanitizableEvent {} diff --git a/DatadogRUM/Sources/RUMMonitor/Monitor.swift b/DatadogRUM/Sources/RUMMonitor/Monitor.swift index f1194d8b50..0c4cdeb9be 100644 --- a/DatadogRUM/Sources/RUMMonitor/Monitor.swift +++ b/DatadogRUM/Sources/RUMMonitor/Monitor.swift @@ -156,7 +156,7 @@ internal class Monitor: RUMCommandSubscriber { } if let activeSession = self.applicationScope.activeSession { - let viewContext = activeSession.viewScopes.last?.context ?? activeSession.context + let viewContext = activeSession.viewScopes.last(where: { $0.isActiveView })?.context ?? activeSession.context self.activeViewSnapshot = ( id: viewContext.activeViewID?.toRUMDataFormat, path: viewContext.activeViewPath, @@ -178,7 +178,8 @@ internal class Monitor: RUMCommandSubscriber { return nil } - let context = activeSession.viewScopes.last?.context ?? activeSession.context + let activeViewScope = activeSession.viewScopes.last(where: { $0.isActiveView }) + let context = activeViewScope?.context ?? activeSession.context return RUMCoreContext( applicationID: context.rumApplicationID, @@ -186,7 +187,7 @@ internal class Monitor: RUMCommandSubscriber { sessionSampler: activeSession.sampler, viewID: context.activeViewID?.toRUMDataFormat, userActionID: context.activeUserActionID?.toRUMDataFormat, - viewServerTimeOffset: activeSession.viewScopes.last?.serverTimeOffset, + viewServerTimeOffset: activeViewScope?.serverTimeOffset, viewPath: context.activeViewPath, viewName: context.activeViewName ) diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index c58ac2f7a8..58ffc256d3 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -16,6 +16,8 @@ internal protocol TimeseriesCollecting: AnyObject { /// Reports that a RUM interaction was just processed, so the collector can self-enforce /// the session inactivity timeout even if no further commands ever arrive to call `stop(sessionID:)`. func noteActivity(sessionID: String, at time: Date) + /// Synchronously flushes any buffered samples. **Blocks the caller thread.** + func flush() } /// Collects memory and CPU samples at configurable intervals (default: 1 s) during a RUM session and flushes them @@ -51,6 +53,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private let ciTest: RUMCITest? private let syntheticsTest: RUMSyntheticsTest? private let sessionSampleRate: Double + private let sanitizer = RUMEventSanitizer() /// Provides global custom attributes and the active view at sample time. Set by `RUMFeature` once `Monitor` /// is constructed, since the collector is created before it. `Monitor`'s conformance is safe to read from @@ -64,6 +67,10 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private var sessionType: RUMSessionType = .user private var timer: DispatchSourceTimer? private var isPaused: Bool = false + /// The view ID of the samples currently buffered, so a view change can trigger a flush before + /// mixing samples from two different views into the same batch. `nil` means either no view was + /// active yet, or no sample has been buffered since the last flush. + private var currentBatchViewID: String? /// The start time of the current session, used to self-enforce `RUMSessionScope.Constants.sessionMaxDuration` /// even if the RUM command pipeline never notifies this collector of the session's expiry. private var sessionStartTime: Date = .distantPast @@ -163,6 +170,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.memoryBuffer = [] self.cpuBuffer = [] self.isPaused = false + self.currentBatchViewID = nil self.timer?.cancel() self.timer = self.makeTimer() @@ -215,6 +223,14 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } } + /// Synchronously flushes any buffered samples without stopping or pausing sampling. **Blocks the caller thread.** + func flush() { + queue.sync { + flushMemory() + flushCPU() + } + } + /// Records that a RUM interaction happened, resetting the inactivity clock this collector uses to /// self-enforce `RUMSessionScope.Constants.sessionTimeoutDuration` (see `sample()`). /// No-ops if `sessionID` no longer matches the currently active session. @@ -273,6 +289,15 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let viewPath = activeView?.path let viewName = activeView?.name + // Flush before mixing samples from two different views into the same batch, so each batch + // (and the RUM view it's attributed to) reflects a single view rather than whichever view + // happened to be active at the last sample or at flush time. + if viewID != currentBatchViewID { + flushMemory() + flushCPU() + } + currentBatchViewID = viewID + if let bytes = memoryReader.readVitalData() { let footprintKB = bytes / 1_024 let memoryPercent = totalRAM > 0 ? bytes / totalRAM * 100 : 0 @@ -362,7 +387,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { version: context.version, view: view.map { .init(id: $0.id, name: $0.name, url: $0.path) } ) - writer.write(value: event) + writer.write(value: self.sanitizer.sanitize(event: event)) } } @@ -426,7 +451,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { version: context.version, view: view.map { .init(id: $0.id, name: $0.name, url: $0.path) } ) - writer.write(value: event) + writer.write(value: self.sanitizer.sanitize(event: event)) } } } diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift index 137bdbef89..b2f2dc6c7a 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift @@ -907,4 +907,10 @@ private class TimeseriesCollectorSpy: TimeseriesCollecting { noteActivityCallCount += 1 lastNoteActivitySessionID = sessionID } + + var flushCallCount = 0 + + func flush() { + flushCallCount += 1 + } } From d31133b965ee0f4feffb827dcc95bdea72ded45d Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Thu, 6 Aug 2026 13:25:59 +0200 Subject: [PATCH 095/102] RUM-17879 Fix noteActivity race with self-stop on inactivity timeout --- .../TimeseriesSessionCollector.swift | 19 +++++++ .../TimeseriesSessionCollectorTests.swift | 52 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 58ffc256d3..19c4ca23a7 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -67,6 +67,11 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private var sessionType: RUMSessionType = .user private var timer: DispatchSourceTimer? private var isPaused: Bool = false + /// Set when `sample()` self-stops the timer because it locally observed the session as expired. + /// `noteActivity(sessionID:at:)` uses this to distinguish "self-stopped, may need to resume if that + /// expiry check raced with a genuine activity update" from an explicit `stop(sessionID:)` call, which + /// must never be resumed by a later (possibly stale) `noteActivity` call for the same session. + private var selfStoppedDueToExpiry = false /// The view ID of the samples currently buffered, so a view change can trigger a flush before /// mixing samples from two different views into the same batch. `nil` means either no view was /// active yet, or no sample has been buffered since the last flush. @@ -171,6 +176,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.cpuBuffer = [] self.isPaused = false self.currentBatchViewID = nil + self.selfStoppedDueToExpiry = false self.timer?.cancel() self.timer = self.makeTimer() @@ -218,6 +224,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.timer?.cancel() self.timer = nil self.isPaused = false + self.selfStoppedDueToExpiry = false self.flushMemory() self.flushCPU() } @@ -240,6 +247,15 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { return } self.lastActivityTime = time + + // This activity update can race with `sample()`'s self-stop check: both run on `queue`, so if a + // timer tick was already enqueued when this activity happened, it can self-stop on stale + // `lastActivityTime` just before this block runs. Since the session is still genuinely active, + // resume sampling now rather than leaving this collector silently stopped until the next session. + if self.selfStoppedDueToExpiry && !self.isPaused { + self.selfStoppedDueToExpiry = false + self.timer = self.makeTimer() + } } } @@ -276,6 +292,9 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { let sessionExceededMaxDuration = currentDate.timeIntervalSince(sessionStartTime) >= RUMSessionScope.Constants.sessionMaxDuration let sessionExceededInactivityTimeout = currentDate.timeIntervalSince(lastActivityTime) >= RUMSessionScope.Constants.sessionTimeoutDuration if sessionExceededMaxDuration || sessionExceededInactivityTimeout { + // Only the inactivity check can race with `noteActivity(sessionID:at:)` (see its comment) — max + // duration is a hard cap unrelated to activity timing, so it should never be resumed. + selfStoppedDueToExpiry = sessionExceededInactivityTimeout && !sessionExceededMaxDuration timer?.cancel() timer = nil flushMemory() diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 88e47cf4af..c89145d182 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -919,6 +919,58 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.stop(sessionID: "session-active") } + func testNoteActivity_resumesSamplingAfterRacingWithSelfStop() { + // Given + memoryReader.vitalData = 1_000_000 + let clock = MutableClock() + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 100, // won't auto-flush — only self-expiry/explicit flush triggers the flush + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + totalRAM: 4_000_000_000, + now: clock.now + ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader + + let startTime = clock.date + collector.start(sessionID: "session-race", applicationID: "app-1", sessionType: .user, startTime: startTime) + + let beforeTimeoutExpectation = self.expectation(description: "samples collected before timeout") + beforeTimeoutExpectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { beforeTimeoutExpectation.fulfill() } + waitForExpectations(timeout: 2) + + // When — the session goes idle past the inactivity timeout, so `sample()` self-stops the timer... + clock.date = startTime.addingTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration) + let selfStopExpectation = self.expectation(description: "self-stop settled") + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3) { selfStopExpectation.fulfill() } + waitForExpectations(timeout: 2) + let countAfterSelfStop = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count + XCTAssertGreaterThan(countAfterSelfStop, 0, "Expected the collector to have self-stopped due to inactivity") + + // ...but a RUM interaction actually happened around the same time: the command pipeline still calls + // `noteActivity(sessionID:at:)` even though this collector already self-stopped on stale state — this + // is the race between `sample()`'s self-stop check and `noteActivity` enqueued on the same serial queue. + collector.noteActivity(sessionID: "session-race", at: clock.date) + + // Then — sampling resumes rather than staying silently stopped for the rest of the (still active) session + let resumeExpectation = self.expectation(description: "sampling resumed after race") + resumeExpectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { resumeExpectation.fulfill() } + waitForExpectations(timeout: 2) + collector.flush() + XCTAssertGreaterThan( + featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count, + countAfterSelfStop, + "Expected sampling to resume and produce new batches after noteActivity raced with the self-stop" + ) + + collector.stop(sessionID: "session-race") + } + // MARK: - Session-ID guard against stale calls func testStaleStopCall_afterNewSessionStarted_doesNotStopNewSession() { From 1d48f23f7f1c76764fdbdee067ff34dfb629a1b4 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Fri, 7 Aug 2026 09:40:17 +0200 Subject: [PATCH 096/102] RUM-17879 Address remaining Codex review findings on timeseries lifecycle --- DatadogRUM/Sources/Feature/RUMFeature.swift | 3 +- DatadogRUM/Sources/RUMConfiguration.swift | 2 +- .../TimeseriesSessionCollector.swift | 4 ++ .../TimeseriesSessionCollectorTests.swift | 53 +++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 10e53f3494..b273539874 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -145,7 +145,8 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider batchSize: configuration.timeseriesBatchSize, ciTest: ciTest, syntheticsTest: syntheticsTest, - sessionSampleRate: Double(sessionSampleRate) + sessionSampleRate: Double(sessionSampleRate), + now: { configuration.dateProvider.now } ) : nil let dependencies = RUMScopeDependencies( diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index 3239c81fcc..ee63c4ddcb 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -331,7 +331,7 @@ extension RUM { /// Enables collection of memory and CPU timeseries events. /// /// When enabled, memory footprint and CPU usage are sampled every second and uploaded as - /// timeseries events scoped to the RUM session. Requires `vitalsUpdateFrequency` to be set. + /// timeseries events scoped to the RUM session. /// /// Default: `false`. @_spi(Experimental) diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 19c4ca23a7..50e226e9fc 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -191,6 +191,10 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { guard let self = self, self.sessionID == sessionID else { return } + // Clear this before the idempotency check below: even if the timer already self-stopped + // (so this call is a no-op as far as the timer goes), backgrounding must still prevent a + // later `noteActivity` from treating the self-stop as recoverable and resuming the timer. + self.selfStoppedDueToExpiry = false if self.isPaused || self.timer == nil { return } diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index c89145d182..dbad633f12 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -971,6 +971,59 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.stop(sessionID: "session-race") } + func testPause_afterSelfStopDueToExpiry_preventsLaterNoteActivityFromResuming() { + // Given + memoryReader.vitalData = 1_000_000 + let clock = MutableClock() + let collector = TimeseriesSessionCollector( + memoryReader: memoryReader, + featureScope: featureScope, + batchSize: 100, // won't auto-flush — only self-expiry/explicit flush triggers the flush + samplingInterval: 0.05, + cpuUsageProvider: { nil }, + totalRAM: 4_000_000_000, + now: clock.now + ) + let contextReader = RUMActiveContextReaderMock() + collector.activeContextReader = contextReader + + let startTime = clock.date + collector.start(sessionID: "session-backgrounded", applicationID: "app-1", sessionType: .user, startTime: startTime) + + let beforeTimeoutExpectation = self.expectation(description: "samples collected before timeout") + beforeTimeoutExpectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { beforeTimeoutExpectation.fulfill() } + waitForExpectations(timeout: 2) + + // When — the session goes idle past the inactivity timeout, so `sample()` self-stops the timer + // (nil-ing it out) just before the app is backgrounded... + clock.date = startTime.addingTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration) + let selfStopExpectation = self.expectation(description: "self-stop settled") + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3) { selfStopExpectation.fulfill() } + waitForExpectations(timeout: 2) + let countAfterSelfStop = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count + XCTAssertGreaterThan(countAfterSelfStop, 0, "Expected the collector to have self-stopped due to inactivity") + + // ...and `didEnterBackground` is processed right after — with the timer already nil from the + // self-stop, this must still record the paused state, not silently no-op. + collector.pause(sessionID: "session-backgrounded") + + // ...but a stale/racing RUM interaction for the same session still reaches `noteActivity` afterwards. + collector.noteActivity(sessionID: "session-backgrounded", at: clock.date) + + // Then — sampling must stay stopped since the app is backgrounded, not resume behind the scenes + let settleExpectation = self.expectation(description: "settle after pause + noteActivity") + settleExpectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { settleExpectation.fulfill() } + waitForExpectations(timeout: 2) + collector.flush() + XCTAssertEqual( + featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count, + countAfterSelfStop, + "Expected sampling to remain stopped since the app was backgrounded, despite the racing noteActivity call" + ) + } + // MARK: - Session-ID guard against stale calls func testStaleStopCall_afterNewSessionStarted_doesNotStopNewSession() { From 2c758d99cc28236a440824fea2231e484b436033 Mon Sep 17 00:00:00 2001 From: Barbora Plasovska Date: Fri, 7 Aug 2026 13:49:18 +0200 Subject: [PATCH 097/102] RUM-17879 Record paused state after inactivity self-stop so foreground resume works --- .../Timeseries/TimeseriesSessionCollector.swift | 11 ++++++----- .../TimeseriesSessionCollectorTests.swift | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 50e226e9fc..87366c73d2 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -191,16 +191,17 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { guard let self = self, self.sessionID == sessionID else { return } - // Clear this before the idempotency check below: even if the timer already self-stopped - // (so this call is a no-op as far as the timer goes), backgrounding must still prevent a - // later `noteActivity` from treating the self-stop as recoverable and resuming the timer. - self.selfStoppedDueToExpiry = false - if self.isPaused || self.timer == nil { + if self.isPaused { return } self.timer?.cancel() self.timer = nil + // Record the paused state even if the timer had already self-stopped (e.g. an inactivity + // self-stop racing with this call): `noteActivity` must not treat that self-stop as + // recoverable while backgrounded, and `resume(sessionID:)` on foregrounding must still be + // able to restart sampling for this still-active session. self.isPaused = true + self.selfStoppedDueToExpiry = false self.flushMemory() self.flushCPU() } diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index dbad633f12..08e8f33607 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -1022,6 +1022,22 @@ class TimeseriesSessionCollectorTests: XCTestCase { countAfterSelfStop, "Expected sampling to remain stopped since the app was backgrounded, despite the racing noteActivity call" ) + + // And — a later `willEnterForeground` resume must still be able to restart sampling for this + // still-active session, even though the timer was already nil going into `pause(sessionID:)`. + collector.resume(sessionID: "session-backgrounded") + let resumeExpectation = self.expectation(description: "sampling resumed on foreground") + resumeExpectation.assertForOverFulfill = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { resumeExpectation.fulfill() } + waitForExpectations(timeout: 2) + collector.flush() + XCTAssertGreaterThan( + featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count, + countAfterSelfStop, + "Expected sampling to resume once the app returns to the foreground" + ) + + collector.stop(sessionID: "session-backgrounded") } // MARK: - Session-ID guard against stale calls From ada6fefd9c1fa22693ebe7b6be6a8c70ab935a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barbora=20Pla=C5=A1ovsk=C3=A1?= Date: Mon, 10 Aug 2026 10:22:45 +0200 Subject: [PATCH 098/102] RUM-17879 Type timeseries collector against its protocol and use toRUMDataFormat --- DatadogRUM/Sources/Feature/RUMFeature.swift | 2 +- .../Sources/RUMMonitor/Scopes/RUMSessionScope.swift | 12 ++++++------ .../Timeseries/TimeseriesSessionCollector.swift | 7 ++++--- .../RUMMonitor/Scopes/RUMSessionScopeTests.swift | 1 + 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index b273539874..b53bee893f 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -139,7 +139,7 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider let sessionSampleRate = configuration.debugSDK ? 100 : configuration.sessionSampleRate - let timeseriesCollector: TimeseriesSessionCollector? = configuration.enableTimeseries ? TimeseriesSessionCollector( + let timeseriesCollector: TimeseriesCollecting? = configuration.enableTimeseries ? TimeseriesSessionCollector( memoryReader: VitalMemoryReader(), featureScope: featureScope, batchSize: configuration.timeseriesBatchSize, diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift index e7c22d2c0e..d6c5f9e4b9 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift @@ -102,7 +102,7 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { private var hadApplicationLaunchViewWhenEnteringBackground: Bool? = nil /// The reason why this session has ended or `nil` if it is still active. private(set) var endReason: EndReason? { - didSet { if endReason != nil { dependencies.timeseriesCollector?.stop(sessionID: sessionUUID.rawValue.uuidString.lowercased()) } } + didSet { if endReason != nil { dependencies.timeseriesCollector?.stop(sessionID: sessionUUID.toRUMDataFormat) } } } /// Counter to track the index of views in this session. Starts at 0 for the first view. @@ -173,13 +173,13 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { if sampler.isSampled { dependencies.timeseriesCollector?.start( - sessionID: sessionUUID.rawValue.uuidString.lowercased(), + sessionID: sessionUUID.toRUMDataFormat, applicationID: dependencies.rumApplicationID, sessionType: dependencies.sessionType, startTime: startTime ) if !context.applicationStateHistory.currentState.isRunningInForeground { - dependencies.timeseriesCollector?.pause(sessionID: sessionUUID.rawValue.uuidString.lowercased()) + dependencies.timeseriesCollector?.pause(sessionID: sessionUUID.toRUMDataFormat) } } } @@ -257,7 +257,7 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { if command.isUserInteraction { lastInteractionTime = command.time - dependencies.timeseriesCollector?.noteActivity(sessionID: sessionUUID.rawValue.uuidString.lowercased(), at: command.time) + dependencies.timeseriesCollector?.noteActivity(sessionID: sessionUUID.toRUMDataFormat, at: command.time) } if !sampler.isSampled { @@ -288,13 +288,13 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { case let appLifecycleCommand as RUMHandleAppLifecycleEventCommand where appLifecycleCommand.event == .didEnterBackground: hadApplicationLaunchViewWhenEnteringBackground = activeView?.viewPath == RUMOffViewEventsHandlingRule.Constants.applicationLaunchViewURL appLaunchManager.process(command, context: context, writer: writer) - dependencies.timeseriesCollector?.pause(sessionID: sessionUUID.rawValue.uuidString.lowercased()) + dependencies.timeseriesCollector?.pause(sessionID: sessionUUID.toRUMDataFormat) case let appLifecycleCommand as RUMHandleAppLifecycleEventCommand where appLifecycleCommand.event == .willEnterForeground: if hadApplicationLaunchViewWhenEnteringBackground == true { startApplicationLaunchView(on: appLifecycleCommand, context: context, writer: writer) } hadApplicationLaunchViewWhenEnteringBackground = nil - dependencies.timeseriesCollector?.resume(sessionID: sessionUUID.rawValue.uuidString.lowercased()) + dependencies.timeseriesCollector?.resume(sessionID: sessionUUID.toRUMDataFormat) case let operationStepVitalCommand as RUMOperationStepVitalCommand: // Forward command to the feature operation manager diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 87366c73d2..41fdbe3bdf 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -9,6 +9,9 @@ import DatadogInternal /// Defines the interface for collecting timeseries data during a RUM session. internal protocol TimeseriesCollecting: AnyObject { + /// Provides global custom attributes and the active view at sample time. Set by `RUMFeature` once `Monitor` + /// is constructed, since the collector is created before it. + var activeContextReader: RUMActiveContextReader? { get set } func start(sessionID: String, applicationID: String, sessionType: RUMSessionType, startTime: Date) func pause(sessionID: String) func resume(sessionID: String) @@ -55,9 +58,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private let sessionSampleRate: Double private let sanitizer = RUMEventSanitizer() - /// Provides global custom attributes and the active view at sample time. Set by `RUMFeature` once `Monitor` - /// is constructed, since the collector is created before it. `Monitor`'s conformance is safe to read from - /// any thread. + /// `Monitor`'s conformance is safe to read from any thread. weak var activeContextReader: RUMActiveContextReader? private var memoryBuffer: [MemorySample] = [] diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift index b2f2dc6c7a..50424edbf0 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift @@ -866,6 +866,7 @@ class RUMSessionScopeTests: XCTestCase { // MARK: - Test Helpers private class TimeseriesCollectorSpy: TimeseriesCollecting { + weak var activeContextReader: RUMActiveContextReader? var startCallCount = 0 var pauseCallCount = 0 var resumeCallCount = 0 From 86220b4d3694f2552704e10cdda58d7f1c42423f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barbora=20Pla=C5=A1ovsk=C3=A1?= Date: Mon, 10 Aug 2026 10:57:41 +0200 Subject: [PATCH 099/102] RUM-17879 Replace pushed session activity state with a pulled RUMSessionActivityReader --- DatadogRUM/Sources/Feature/RUMFeature.swift | 1 + DatadogRUM/Sources/RUMMonitor/Monitor.swift | 22 +++ .../RUMMonitor/Scopes/RUMSessionScope.swift | 30 +++- .../TimeseriesSessionCollector.swift | 70 ++------ .../Scopes/RUMSessionScopeTests.swift | 12 +- .../TimeseriesSessionCollectorTests.swift | 158 ++++-------------- 6 files changed, 92 insertions(+), 201 deletions(-) diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index b53bee893f..0574cc64d5 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -225,6 +225,7 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider ) timeseriesCollector?.activeContextReader = monitor + timeseriesCollector?.sessionActivityReader = monitor self.timeseriesCollector = timeseriesCollector if let refreshRateVital = dependencies.vitalsReaders?.refreshRate as? RenderLoopReader { diff --git a/DatadogRUM/Sources/RUMMonitor/Monitor.swift b/DatadogRUM/Sources/RUMMonitor/Monitor.swift index 0c4cdeb9be..1dd33c5acc 100644 --- a/DatadogRUM/Sources/RUMMonitor/Monitor.swift +++ b/DatadogRUM/Sources/RUMMonitor/Monitor.swift @@ -107,6 +107,15 @@ internal protocol RUMActiveContextReader: AnyObject { var activeView: (id: String?, path: String?, name: String?) { get } } +/// Exposes the active session's lifetime state for readers that operate outside the `RUMCommand` pipeline +/// (e.g. the timer-driven `TimeseriesSessionCollector`), so they can evaluate `RUMSessionScope`'s own +/// expiry rules against a live, single source of truth instead of maintaining a shadow copy of that state. +internal protocol RUMSessionActivityReader: AnyObject { + /// The active session's ID, start time, and time of last RUM interaction, or `nil` values if there is + /// no active session. Safe to read from any thread. + var sessionActivity: (sessionID: String?, sessionStartTime: Date?, lastInteractionTime: Date?) { get } +} + internal class Monitor: RUMCommandSubscriber { /// RUM feature scope. let featureScope: FeatureScope @@ -122,6 +131,9 @@ internal class Monitor: RUMCommandSubscriber { @ReadWriteLock private var activeViewSnapshot: (id: String?, path: String?, name: String?) = (nil, nil, nil) + @ReadWriteLock + private var sessionActivitySnapshot: (sessionID: String?, sessionStartTime: Date?, lastInteractionTime: Date?) = (nil, nil, nil) + private let fatalErrorContext: FatalErrorContextNotifying private let rumUUIDGenerator: RUMUUIDGenerator private let telemetry: Telemetry @@ -162,8 +174,14 @@ internal class Monitor: RUMCommandSubscriber { path: viewContext.activeViewPath, name: viewContext.activeViewName ) + self.sessionActivitySnapshot = ( + sessionID: activeSession.sessionUUID.toRUMDataFormat, + sessionStartTime: activeSession.sessionStartTime, + lastInteractionTime: activeSession.lastInteractionTime + ) } else { self.activeViewSnapshot = (nil, nil, nil) + self.sessionActivitySnapshot = (nil, nil, nil) } } @@ -223,6 +241,10 @@ extension Monitor: RUMActiveContextReader { var activeView: (id: String?, path: String?, name: String?) { activeViewSnapshot } } +extension Monitor: RUMSessionActivityReader { + var sessionActivity: (sessionID: String?, sessionStartTime: Date?, lastInteractionTime: Date?) { sessionActivitySnapshot } +} + /// Declares `Monitor` conformance to public `RUMMonitorProtocol`. extension Monitor: RUMMonitorProtocol { // MARK: - attributes diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift index d6c5f9e4b9..d10ada1f43 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift @@ -15,6 +15,20 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { static let sessionMaxDuration: TimeInterval = 4 * 60 * 60 // 4 hours } + /// Whether a session is timed out due to inactivity, given the time of its last interaction. + /// Shared with `TimeseriesSessionCollector`, which self-enforces this same rule from a live + /// `RUMSessionActivityReader` snapshot instead of reacting to `RUMCommand`s. + static func hasTimedOut(lastInteractionTime: Date, currentTime: Date) -> Bool { + currentTime.timeIntervalSince(lastInteractionTime) >= Constants.sessionTimeoutDuration + } + + /// Whether a session has exceeded its maximum duration, given its start time. + /// Shared with `TimeseriesSessionCollector`, which self-enforces this same rule from a live + /// `RUMSessionActivityReader` snapshot instead of reacting to `RUMCommand`s. + static func hasExpired(sessionStartTime: Date, currentTime: Date) -> Bool { + currentTime.timeIntervalSince(sessionStartTime) >= Constants.sessionMaxDuration + } + /// The reason of ending a session. enum EndReason: String { /// The session timed out because it received no interaction for x minutes. @@ -95,9 +109,11 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { /// If this is the very first session created in the current app process (`false` for session created upon expiration of a previous one). let isInitialSession: Bool /// The start time of this Session, measured in device date. In initial session this is the time of SDK init. - private let sessionStartTime: Date + /// Exposed internally (not `private`) so `Monitor` can snapshot it for `RUMSessionActivityReader`. + let sessionStartTime: Date /// Time of the last RUM interaction noticed by this Session. - private var lastInteractionTime: Date + /// Exposed internally (not `private`) so `Monitor` can snapshot it for `RUMSessionActivityReader`. + private(set) var lastInteractionTime: Date /// Indicates whether the "ApplicationLaunch" view was active when the app entered the background. private var hadApplicationLaunchViewWhenEnteringBackground: Bool? = nil /// The reason why this session has ended or `nil` if it is still active. @@ -175,8 +191,7 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { dependencies.timeseriesCollector?.start( sessionID: sessionUUID.toRUMDataFormat, applicationID: dependencies.rumApplicationID, - sessionType: dependencies.sessionType, - startTime: startTime + sessionType: dependencies.sessionType ) if !context.applicationStateHistory.currentState.isRunningInForeground { dependencies.timeseriesCollector?.pause(sessionID: sessionUUID.toRUMDataFormat) @@ -257,7 +272,6 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { if command.isUserInteraction { lastInteractionTime = command.time - dependencies.timeseriesCollector?.noteActivity(sessionID: sessionUUID.toRUMDataFormat, at: command.time) } if !sampler.isSampled { @@ -498,12 +512,10 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { } private func hasTimedOut(currentTime: Date) -> Bool { - let timeElapsedSinceLastInteraction = currentTime.timeIntervalSince(lastInteractionTime) - return timeElapsedSinceLastInteraction >= Constants.sessionTimeoutDuration + Self.hasTimedOut(lastInteractionTime: lastInteractionTime, currentTime: currentTime) } private func hasExpired(currentTime: Date) -> Bool { - let sessionDuration = currentTime.timeIntervalSince(sessionStartTime) - return sessionDuration >= Constants.sessionMaxDuration + Self.hasExpired(sessionStartTime: sessionStartTime, currentTime: currentTime) } } diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index 41fdbe3bdf..ab95c3c548 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -12,13 +12,14 @@ internal protocol TimeseriesCollecting: AnyObject { /// Provides global custom attributes and the active view at sample time. Set by `RUMFeature` once `Monitor` /// is constructed, since the collector is created before it. var activeContextReader: RUMActiveContextReader? { get set } - func start(sessionID: String, applicationID: String, sessionType: RUMSessionType, startTime: Date) + /// Provides the active session's start time and last-interaction time at sample time, so the collector can + /// self-enforce `RUMSessionScope`'s own expiry rules without maintaining a shadow copy of that state. + /// Set by `RUMFeature` once `Monitor` is constructed, since the collector is created before it. + var sessionActivityReader: RUMSessionActivityReader? { get set } + func start(sessionID: String, applicationID: String, sessionType: RUMSessionType) func pause(sessionID: String) func resume(sessionID: String) func stop(sessionID: String) - /// Reports that a RUM interaction was just processed, so the collector can self-enforce - /// the session inactivity timeout even if no further commands ever arrive to call `stop(sessionID:)`. - func noteActivity(sessionID: String, at time: Date) /// Synchronously flushes any buffered samples. **Blocks the caller thread.** func flush() } @@ -60,6 +61,8 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { /// `Monitor`'s conformance is safe to read from any thread. weak var activeContextReader: RUMActiveContextReader? + /// `Monitor`'s conformance is safe to read from any thread. + weak var sessionActivityReader: RUMSessionActivityReader? private var memoryBuffer: [MemorySample] = [] private var cpuBuffer: [CPUSample] = [] @@ -68,21 +71,10 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { private var sessionType: RUMSessionType = .user private var timer: DispatchSourceTimer? private var isPaused: Bool = false - /// Set when `sample()` self-stops the timer because it locally observed the session as expired. - /// `noteActivity(sessionID:at:)` uses this to distinguish "self-stopped, may need to resume if that - /// expiry check raced with a genuine activity update" from an explicit `stop(sessionID:)` call, which - /// must never be resumed by a later (possibly stale) `noteActivity` call for the same session. - private var selfStoppedDueToExpiry = false /// The view ID of the samples currently buffered, so a view change can trigger a flush before /// mixing samples from two different views into the same batch. `nil` means either no view was /// active yet, or no sample has been buffered since the last flush. private var currentBatchViewID: String? - /// The start time of the current session, used to self-enforce `RUMSessionScope.Constants.sessionMaxDuration` - /// even if the RUM command pipeline never notifies this collector of the session's expiry. - private var sessionStartTime: Date = .distantPast - /// The time of the last RUM interaction reported via `noteActivity(sessionID:at:)`, used to self-enforce - /// `RUMSessionScope.Constants.sessionTimeoutDuration` even when the app goes idle with no RUM commands. - private var lastActivityTime: Date = .distantPast private let now: () -> Date /// All buffer mutations and timer events run on this queue. @@ -161,7 +153,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } /// Resets state, flips the compression coin, and starts the sampling timer for the new session. - func start(sessionID: String, applicationID: String, sessionType: RUMSessionType, startTime: Date = Date()) { + func start(sessionID: String, applicationID: String, sessionType: RUMSessionType) { queue.async { [weak self] in guard let self = self else { return @@ -171,13 +163,10 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.sessionID = sessionID self.applicationID = applicationID self.sessionType = sessionType - self.sessionStartTime = startTime - self.lastActivityTime = startTime self.memoryBuffer = [] self.cpuBuffer = [] self.isPaused = false self.currentBatchViewID = nil - self.selfStoppedDueToExpiry = false self.timer?.cancel() self.timer = self.makeTimer() @@ -197,12 +186,7 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } self.timer?.cancel() self.timer = nil - // Record the paused state even if the timer had already self-stopped (e.g. an inactivity - // self-stop racing with this call): `noteActivity` must not treat that self-stop as - // recoverable while backgrounded, and `resume(sessionID:)` on foregrounding must still be - // able to restart sampling for this still-active session. self.isPaused = true - self.selfStoppedDueToExpiry = false self.flushMemory() self.flushCPU() } @@ -230,7 +214,6 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { self.timer?.cancel() self.timer = nil self.isPaused = false - self.selfStoppedDueToExpiry = false self.flushMemory() self.flushCPU() } @@ -244,27 +227,6 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { } } - /// Records that a RUM interaction happened, resetting the inactivity clock this collector uses to - /// self-enforce `RUMSessionScope.Constants.sessionTimeoutDuration` (see `sample()`). - /// No-ops if `sessionID` no longer matches the currently active session. - func noteActivity(sessionID: String, at time: Date) { - queue.async { [weak self] in - guard let self = self, self.sessionID == sessionID else { - return - } - self.lastActivityTime = time - - // This activity update can race with `sample()`'s self-stop check: both run on `queue`, so if a - // timer tick was already enqueued when this activity happened, it can self-stop on stale - // `lastActivityTime` just before this block runs. Since the session is still genuinely active, - // resume sampling now rather than leaving this collector silently stopped until the next session. - if self.selfStoppedDueToExpiry && !self.isPaused { - self.selfStoppedDueToExpiry = false - self.timer = self.makeTimer() - } - } - } - /// Returns the most recently active view among the given samples, searching from the end of the batch /// backwards, or `nil` if no sample in the batch had a view. private static func lastKnownView( @@ -295,12 +257,16 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { // has expired without any RUM command arriving to call `stop(sessionID:)` (e.g. the app went // idle with no user interaction). This is a safety net only — it does not affect RUM's own // session state, it just stops this collector from uploading data past session expiry. - let sessionExceededMaxDuration = currentDate.timeIntervalSince(sessionStartTime) >= RUMSessionScope.Constants.sessionMaxDuration - let sessionExceededInactivityTimeout = currentDate.timeIntervalSince(lastActivityTime) >= RUMSessionScope.Constants.sessionTimeoutDuration - if sessionExceededMaxDuration || sessionExceededInactivityTimeout { - // Only the inactivity check can race with `noteActivity(sessionID:at:)` (see its comment) — max - // duration is a hard cap unrelated to activity timing, so it should never be resumed. - selfStoppedDueToExpiry = sessionExceededInactivityTimeout && !sessionExceededMaxDuration + // + // Pulled fresh from `sessionActivityReader` on every tick, rather than from a locally pushed + // copy, so there's a single live source of truth and no race with how/when that state is updated. + // If the reader's session doesn't match (e.g. a session transition is still propagating), skip + // the check for this tick rather than guessing. + let activity = sessionActivityReader?.sessionActivity + if let activity = activity, activity.sessionID == sessionID, + let sessionStartTime = activity.sessionStartTime, let lastInteractionTime = activity.lastInteractionTime, + RUMSessionScope.hasExpired(sessionStartTime: sessionStartTime, currentTime: currentDate) + || RUMSessionScope.hasTimedOut(lastInteractionTime: lastInteractionTime, currentTime: currentDate) { timer?.cancel() timer = nil flushMemory() diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift index 50424edbf0..05a47eadb9 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift @@ -867,26 +867,23 @@ class RUMSessionScopeTests: XCTestCase { private class TimeseriesCollectorSpy: TimeseriesCollecting { weak var activeContextReader: RUMActiveContextReader? + weak var sessionActivityReader: RUMSessionActivityReader? var startCallCount = 0 var pauseCallCount = 0 var resumeCallCount = 0 var stopCallCount = 0 - var noteActivityCallCount = 0 var lastStartedSessionID: String? var lastStartedApplicationID: String? var lastStartedSessionType: RUMSessionType? - var lastStartedStartTime: Date? var lastStoppedSessionID: String? var lastPausedSessionID: String? var lastResumedSessionID: String? - var lastNoteActivitySessionID: String? - func start(sessionID: String, applicationID: String, sessionType: RUMSessionType, startTime: Date) { + func start(sessionID: String, applicationID: String, sessionType: RUMSessionType) { startCallCount += 1 lastStartedSessionID = sessionID lastStartedApplicationID = applicationID lastStartedSessionType = sessionType - lastStartedStartTime = startTime } func pause(sessionID: String) { @@ -904,11 +901,6 @@ private class TimeseriesCollectorSpy: TimeseriesCollecting { lastStoppedSessionID = sessionID } - func noteActivity(sessionID: String, at time: Date) { - noteActivityCallCount += 1 - lastNoteActivitySessionID = sessionID - } - var flushCallCount = 0 func flush() { diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index 08e8f33607..d6765d4bfa 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -824,7 +824,9 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.activeContextReader = contextReader let startTime = clock.date - collector.start(sessionID: "session-expired", applicationID: "app-1", sessionType: .user, startTime: startTime) + let activityReader = RUMSessionActivityReaderMock(sessionID: "session-expired", sessionStartTime: startTime, lastInteractionTime: startTime) + collector.sessionActivityReader = activityReader + collector.start(sessionID: "session-expired", applicationID: "app-1", sessionType: .user) let beforeExpiryExpectation = self.expectation(description: "samples collected before expiry") beforeExpiryExpectation.assertForOverFulfill = false @@ -832,8 +834,11 @@ class TimeseriesSessionCollectorTests: XCTestCase { waitForExpectations(timeout: 2) XCTAssertTrue(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, "Batch should not be flushed while the session is still within its max duration") - // When — advance the (injected) clock past the session's max duration, without ever calling stop() + // When — advance the (injected) clock and the activity reader's snapshot past the session's max + // duration, mirroring how `Monitor` refreshes its snapshot on every processed command, without ever + // calling stop() directly clock.date = startTime.addingTimeInterval(RUMSessionScope.Constants.sessionMaxDuration) + activityReader.sessionActivity.lastInteractionTime = clock.date let selfStopExpectation = self.expectation(description: "self-stop settled") DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3) { selfStopExpectation.fulfill() } waitForExpectations(timeout: 2) @@ -867,7 +872,9 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.activeContextReader = contextReader let startTime = clock.date - collector.start(sessionID: "session-idle", applicationID: "app-1", sessionType: .user, startTime: startTime) + let activityReader = RUMSessionActivityReaderMock(sessionID: "session-idle", sessionStartTime: startTime, lastInteractionTime: startTime) + collector.sessionActivityReader = activityReader + collector.start(sessionID: "session-idle", applicationID: "app-1", sessionType: .user) let beforeTimeoutExpectation = self.expectation(description: "samples collected before timeout") beforeTimeoutExpectation.assertForOverFulfill = false @@ -875,7 +882,8 @@ class TimeseriesSessionCollectorTests: XCTestCase { waitForExpectations(timeout: 2) XCTAssertTrue(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty) - // When — advance the clock past the inactivity timeout without ever calling noteActivity(sessionID:at:) + // When — advance the clock past the inactivity timeout while the activity reader's `lastInteractionTime` + // stays fixed at `startTime` (i.e. no RUM interaction was ever processed) clock.date = startTime.addingTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration) let selfStopExpectation = self.expectation(description: "self-stop settled") DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3) { selfStopExpectation.fulfill() } @@ -885,7 +893,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { XCTAssertFalse(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, "Expected the collector to self-flush once idle past the inactivity timeout") } - func testNoteActivity_preventsSelfStopWithinTimeoutWindow() { + func testWhenActivityReaderReportsRecentInteraction_preventsSelfStopWithinTimeoutWindow() { // Given memoryReader.vitalData = 1_000_000 let clock = MutableClock() @@ -902,11 +910,14 @@ class TimeseriesSessionCollectorTests: XCTestCase { collector.activeContextReader = contextReader let startTime = clock.date - collector.start(sessionID: "session-active", applicationID: "app-1", sessionType: .user, startTime: startTime) + let activityReader = RUMSessionActivityReaderMock(sessionID: "session-active", sessionStartTime: startTime, lastInteractionTime: startTime) + collector.sessionActivityReader = activityReader + collector.start(sessionID: "session-active", applicationID: "app-1", sessionType: .user) - // When — activity is reported right before what would have been the inactivity timeout, resetting the clock + // When — a RUM interaction is reported right before what would have been the inactivity timeout, + // resetting the clock the reader exposes to `sample()` clock.date = startTime.addingTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration - 1) - collector.noteActivity(sessionID: "session-active", at: clock.date) + activityReader.sessionActivity.lastInteractionTime = clock.date clock.date = clock.date.addingTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration - 1) let expectation = self.expectation(description: "still sampling") @@ -914,132 +925,11 @@ class TimeseriesSessionCollectorTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } waitForExpectations(timeout: 2) - // Then — sampling continued since activity reset the inactivity clock before it lapsed + // Then — sampling continued since the reader's last interaction time reset the inactivity clock before it lapsed XCTAssertFalse(featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).isEmpty, "Expected sampling to continue since activity reset the inactivity clock") collector.stop(sessionID: "session-active") } - func testNoteActivity_resumesSamplingAfterRacingWithSelfStop() { - // Given - memoryReader.vitalData = 1_000_000 - let clock = MutableClock() - let collector = TimeseriesSessionCollector( - memoryReader: memoryReader, - featureScope: featureScope, - batchSize: 100, // won't auto-flush — only self-expiry/explicit flush triggers the flush - samplingInterval: 0.05, - cpuUsageProvider: { nil }, - totalRAM: 4_000_000_000, - now: clock.now - ) - let contextReader = RUMActiveContextReaderMock() - collector.activeContextReader = contextReader - - let startTime = clock.date - collector.start(sessionID: "session-race", applicationID: "app-1", sessionType: .user, startTime: startTime) - - let beforeTimeoutExpectation = self.expectation(description: "samples collected before timeout") - beforeTimeoutExpectation.assertForOverFulfill = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { beforeTimeoutExpectation.fulfill() } - waitForExpectations(timeout: 2) - - // When — the session goes idle past the inactivity timeout, so `sample()` self-stops the timer... - clock.date = startTime.addingTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration) - let selfStopExpectation = self.expectation(description: "self-stop settled") - DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3) { selfStopExpectation.fulfill() } - waitForExpectations(timeout: 2) - let countAfterSelfStop = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count - XCTAssertGreaterThan(countAfterSelfStop, 0, "Expected the collector to have self-stopped due to inactivity") - - // ...but a RUM interaction actually happened around the same time: the command pipeline still calls - // `noteActivity(sessionID:at:)` even though this collector already self-stopped on stale state — this - // is the race between `sample()`'s self-stop check and `noteActivity` enqueued on the same serial queue. - collector.noteActivity(sessionID: "session-race", at: clock.date) - - // Then — sampling resumes rather than staying silently stopped for the rest of the (still active) session - let resumeExpectation = self.expectation(description: "sampling resumed after race") - resumeExpectation.assertForOverFulfill = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { resumeExpectation.fulfill() } - waitForExpectations(timeout: 2) - collector.flush() - XCTAssertGreaterThan( - featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count, - countAfterSelfStop, - "Expected sampling to resume and produce new batches after noteActivity raced with the self-stop" - ) - - collector.stop(sessionID: "session-race") - } - - func testPause_afterSelfStopDueToExpiry_preventsLaterNoteActivityFromResuming() { - // Given - memoryReader.vitalData = 1_000_000 - let clock = MutableClock() - let collector = TimeseriesSessionCollector( - memoryReader: memoryReader, - featureScope: featureScope, - batchSize: 100, // won't auto-flush — only self-expiry/explicit flush triggers the flush - samplingInterval: 0.05, - cpuUsageProvider: { nil }, - totalRAM: 4_000_000_000, - now: clock.now - ) - let contextReader = RUMActiveContextReaderMock() - collector.activeContextReader = contextReader - - let startTime = clock.date - collector.start(sessionID: "session-backgrounded", applicationID: "app-1", sessionType: .user, startTime: startTime) - - let beforeTimeoutExpectation = self.expectation(description: "samples collected before timeout") - beforeTimeoutExpectation.assertForOverFulfill = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { beforeTimeoutExpectation.fulfill() } - waitForExpectations(timeout: 2) - - // When — the session goes idle past the inactivity timeout, so `sample()` self-stops the timer - // (nil-ing it out) just before the app is backgrounded... - clock.date = startTime.addingTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration) - let selfStopExpectation = self.expectation(description: "self-stop settled") - DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3) { selfStopExpectation.fulfill() } - waitForExpectations(timeout: 2) - let countAfterSelfStop = featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count - XCTAssertGreaterThan(countAfterSelfStop, 0, "Expected the collector to have self-stopped due to inactivity") - - // ...and `didEnterBackground` is processed right after — with the timer already nil from the - // self-stop, this must still record the paused state, not silently no-op. - collector.pause(sessionID: "session-backgrounded") - - // ...but a stale/racing RUM interaction for the same session still reaches `noteActivity` afterwards. - collector.noteActivity(sessionID: "session-backgrounded", at: clock.date) - - // Then — sampling must stay stopped since the app is backgrounded, not resume behind the scenes - let settleExpectation = self.expectation(description: "settle after pause + noteActivity") - settleExpectation.assertForOverFulfill = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { settleExpectation.fulfill() } - waitForExpectations(timeout: 2) - collector.flush() - XCTAssertEqual( - featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count, - countAfterSelfStop, - "Expected sampling to remain stopped since the app was backgrounded, despite the racing noteActivity call" - ) - - // And — a later `willEnterForeground` resume must still be able to restart sampling for this - // still-active session, even though the timer was already nil going into `pause(sessionID:)`. - collector.resume(sessionID: "session-backgrounded") - let resumeExpectation = self.expectation(description: "sampling resumed on foreground") - resumeExpectation.assertForOverFulfill = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { resumeExpectation.fulfill() } - waitForExpectations(timeout: 2) - collector.flush() - XCTAssertGreaterThan( - featureScope.eventsWritten(ofType: RUMTimeseriesMemoryEvent.self).count, - countAfterSelfStop, - "Expected sampling to resume once the app returns to the foreground" - ) - - collector.stop(sessionID: "session-backgrounded") - } - // MARK: - Session-ID guard against stale calls func testStaleStopCall_afterNewSessionStarted_doesNotStopNewSession() { @@ -1118,6 +1008,14 @@ private class RUMActiveContextReaderMock: RUMActiveContextReader { } } +private class RUMSessionActivityReaderMock: RUMSessionActivityReader { + var sessionActivity: (sessionID: String?, sessionStartTime: Date?, lastInteractionTime: Date?) + + init(sessionID: String? = nil, sessionStartTime: Date? = nil, lastInteractionTime: Date? = nil) { + self.sessionActivity = (sessionID, sessionStartTime, lastInteractionTime) + } +} + /// A `Date` provider whose current time can be advanced manually, used to deterministically test the /// collector's self-enforced session expiry without waiting on real wall-clock durations. private class MutableClock { From 2b5f03eb7a8794529cbf8674cba04ac0a64e89dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barbora=20Pla=C5=A1ovsk=C3=A1?= Date: Tue, 11 Aug 2026 10:45:58 +0200 Subject: [PATCH 100/102] RUM-17879 Merge session-activity reader into active-context reader and drop collector's session-scope dependency --- DatadogRUM/Sources/Feature/RUMFeature.swift | 1 - DatadogRUM/Sources/RUMMonitor/Monitor.swift | 35 ++++++++------ .../RUMMonitor/Scopes/RUMSessionScope.swift | 6 --- .../TimeseriesSessionCollector.swift | 22 +++------ .../Scopes/RUMSessionScopeTests.swift | 1 - .../TimeseriesSessionCollectorTests.swift | 46 +++++++++---------- 6 files changed, 48 insertions(+), 63 deletions(-) diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 0574cc64d5..b53bee893f 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -225,7 +225,6 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider ) timeseriesCollector?.activeContextReader = monitor - timeseriesCollector?.sessionActivityReader = monitor self.timeseriesCollector = timeseriesCollector if let refreshRateVital = dependencies.vitalsReaders?.refreshRate as? RenderLoopReader { diff --git a/DatadogRUM/Sources/RUMMonitor/Monitor.swift b/DatadogRUM/Sources/RUMMonitor/Monitor.swift index 1dd33c5acc..d404f8db85 100644 --- a/DatadogRUM/Sources/RUMMonitor/Monitor.swift +++ b/DatadogRUM/Sources/RUMMonitor/Monitor.swift @@ -98,22 +98,20 @@ internal typealias RUMErrorCategory = RUMErrorEvent.Error.Category /// Exposes monitor state for readers that operate outside the `RUMCommand` pipeline /// (e.g. the timer-driven `TimeseriesSessionCollector`), which otherwise have no access to -/// `command.globalAttributes` or the scope tree's active view. +/// `command.globalAttributes`, the scope tree's active view, or `RUMSessionScope`'s own session +/// lifetime rules. internal protocol RUMActiveContextReader: AnyObject { /// The current global attributes set through `addAttribute(forKey:value:)` / `addAttributes(_:)`. - /// Safe to read from any thread. + /// Conformers must guarantee this is safe to read from any thread. var globalAttributes: [AttributeKey: AttributeValue] { get } - /// The currently active view, if any. Safe to read from any thread. + /// The currently active view, if any. Conformers must guarantee this is safe to read from any thread. var activeView: (id: String?, path: String?, name: String?) { get } -} - -/// Exposes the active session's lifetime state for readers that operate outside the `RUMCommand` pipeline -/// (e.g. the timer-driven `TimeseriesSessionCollector`), so they can evaluate `RUMSessionScope`'s own -/// expiry rules against a live, single source of truth instead of maintaining a shadow copy of that state. -internal protocol RUMSessionActivityReader: AnyObject { - /// The active session's ID, start time, and time of last RUM interaction, or `nil` values if there is - /// no active session. Safe to read from any thread. - var sessionActivity: (sessionID: String?, sessionStartTime: Date?, lastInteractionTime: Date?) { get } + /// Whether the session identified by `sessionID` has expired (exceeded its max duration or inactivity + /// timeout) as of `date`, evaluated against a live, single source of truth instead of a shadow copy of + /// that state. Returns `false` if `sessionID` doesn't match the currently active session (e.g. a session + /// transition is still propagating), so callers should treat that as "skip the check for now", not + /// "not expired". Conformers must guarantee this is safe to call from any thread. + func isSessionExpired(sessionID: String, at date: Date) -> Bool } internal class Monitor: RUMCommandSubscriber { @@ -239,10 +237,17 @@ internal class Monitor: RUMCommandSubscriber { extension Monitor: RUMActiveContextReader { var globalAttributes: [AttributeKey: AttributeValue] { attributes } var activeView: (id: String?, path: String?, name: String?) { activeViewSnapshot } -} -extension Monitor: RUMSessionActivityReader { - var sessionActivity: (sessionID: String?, sessionStartTime: Date?, lastInteractionTime: Date?) { sessionActivitySnapshot } + func isSessionExpired(sessionID: String, at date: Date) -> Bool { + let activity = sessionActivitySnapshot + guard activity.sessionID == sessionID, + let sessionStartTime = activity.sessionStartTime, + let lastInteractionTime = activity.lastInteractionTime else { + return false + } + return RUMSessionScope.hasExpired(sessionStartTime: sessionStartTime, currentTime: date) + || RUMSessionScope.hasTimedOut(lastInteractionTime: lastInteractionTime, currentTime: date) + } } /// Declares `Monitor` conformance to public `RUMMonitorProtocol`. diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift index d10ada1f43..95aeb9a429 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift @@ -16,15 +16,11 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { } /// Whether a session is timed out due to inactivity, given the time of its last interaction. - /// Shared with `TimeseriesSessionCollector`, which self-enforces this same rule from a live - /// `RUMSessionActivityReader` snapshot instead of reacting to `RUMCommand`s. static func hasTimedOut(lastInteractionTime: Date, currentTime: Date) -> Bool { currentTime.timeIntervalSince(lastInteractionTime) >= Constants.sessionTimeoutDuration } /// Whether a session has exceeded its maximum duration, given its start time. - /// Shared with `TimeseriesSessionCollector`, which self-enforces this same rule from a live - /// `RUMSessionActivityReader` snapshot instead of reacting to `RUMCommand`s. static func hasExpired(sessionStartTime: Date, currentTime: Date) -> Bool { currentTime.timeIntervalSince(sessionStartTime) >= Constants.sessionMaxDuration } @@ -109,10 +105,8 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { /// If this is the very first session created in the current app process (`false` for session created upon expiration of a previous one). let isInitialSession: Bool /// The start time of this Session, measured in device date. In initial session this is the time of SDK init. - /// Exposed internally (not `private`) so `Monitor` can snapshot it for `RUMSessionActivityReader`. let sessionStartTime: Date /// Time of the last RUM interaction noticed by this Session. - /// Exposed internally (not `private`) so `Monitor` can snapshot it for `RUMSessionActivityReader`. private(set) var lastInteractionTime: Date /// Indicates whether the "ApplicationLaunch" view was active when the app entered the background. private var hadApplicationLaunchViewWhenEnteringBackground: Bool? = nil diff --git a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift index ab95c3c548..4e640f9a95 100644 --- a/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift +++ b/DatadogRUM/Sources/Timeseries/TimeseriesSessionCollector.swift @@ -9,13 +9,11 @@ import DatadogInternal /// Defines the interface for collecting timeseries data during a RUM session. internal protocol TimeseriesCollecting: AnyObject { - /// Provides global custom attributes and the active view at sample time. Set by `RUMFeature` once `Monitor` - /// is constructed, since the collector is created before it. + /// Provides global custom attributes, the active view, and the active session's expiry state at sample + /// time, so the collector can self-enforce `RUMSessionScope`'s own expiry rules without maintaining a + /// shadow copy of that state. Set by `RUMFeature` once `Monitor` is constructed, since the collector is + /// created before it. var activeContextReader: RUMActiveContextReader? { get set } - /// Provides the active session's start time and last-interaction time at sample time, so the collector can - /// self-enforce `RUMSessionScope`'s own expiry rules without maintaining a shadow copy of that state. - /// Set by `RUMFeature` once `Monitor` is constructed, since the collector is created before it. - var sessionActivityReader: RUMSessionActivityReader? { get set } func start(sessionID: String, applicationID: String, sessionType: RUMSessionType) func pause(sessionID: String) func resume(sessionID: String) @@ -61,8 +59,6 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { /// `Monitor`'s conformance is safe to read from any thread. weak var activeContextReader: RUMActiveContextReader? - /// `Monitor`'s conformance is safe to read from any thread. - weak var sessionActivityReader: RUMSessionActivityReader? private var memoryBuffer: [MemorySample] = [] private var cpuBuffer: [CPUSample] = [] @@ -258,15 +254,9 @@ internal class TimeseriesSessionCollector: TimeseriesCollecting { // idle with no user interaction). This is a safety net only — it does not affect RUM's own // session state, it just stops this collector from uploading data past session expiry. // - // Pulled fresh from `sessionActivityReader` on every tick, rather than from a locally pushed + // Pulled fresh from `activeContextReader` on every tick, rather than from a locally pushed // copy, so there's a single live source of truth and no race with how/when that state is updated. - // If the reader's session doesn't match (e.g. a session transition is still propagating), skip - // the check for this tick rather than guessing. - let activity = sessionActivityReader?.sessionActivity - if let activity = activity, activity.sessionID == sessionID, - let sessionStartTime = activity.sessionStartTime, let lastInteractionTime = activity.lastInteractionTime, - RUMSessionScope.hasExpired(sessionStartTime: sessionStartTime, currentTime: currentDate) - || RUMSessionScope.hasTimedOut(lastInteractionTime: lastInteractionTime, currentTime: currentDate) { + if activeContextReader?.isSessionExpired(sessionID: sessionID, at: currentDate) == true { timer?.cancel() timer = nil flushMemory() diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift index 05a47eadb9..85d3e7c596 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift @@ -867,7 +867,6 @@ class RUMSessionScopeTests: XCTestCase { private class TimeseriesCollectorSpy: TimeseriesCollecting { weak var activeContextReader: RUMActiveContextReader? - weak var sessionActivityReader: RUMSessionActivityReader? var startCallCount = 0 var pauseCallCount = 0 var resumeCallCount = 0 diff --git a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift index d6765d4bfa..9e3b2a3670 100644 --- a/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift +++ b/DatadogRUM/Tests/Timeseries/TimeseriesSessionCollectorTests.swift @@ -820,12 +820,9 @@ class TimeseriesSessionCollectorTests: XCTestCase { totalRAM: 4_000_000_000, now: clock.now ) - let contextReader = RUMActiveContextReaderMock() - collector.activeContextReader = contextReader - let startTime = clock.date - let activityReader = RUMSessionActivityReaderMock(sessionID: "session-expired", sessionStartTime: startTime, lastInteractionTime: startTime) - collector.sessionActivityReader = activityReader + let contextReader = RUMActiveContextReaderMock(sessionID: "session-expired", sessionStartTime: startTime, lastInteractionTime: startTime) + collector.activeContextReader = contextReader collector.start(sessionID: "session-expired", applicationID: "app-1", sessionType: .user) let beforeExpiryExpectation = self.expectation(description: "samples collected before expiry") @@ -838,7 +835,7 @@ class TimeseriesSessionCollectorTests: XCTestCase { // duration, mirroring how `Monitor` refreshes its snapshot on every processed command, without ever // calling stop() directly clock.date = startTime.addingTimeInterval(RUMSessionScope.Constants.sessionMaxDuration) - activityReader.sessionActivity.lastInteractionTime = clock.date + contextReader.sessionActivity.lastInteractionTime = clock.date let selfStopExpectation = self.expectation(description: "self-stop settled") DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3) { selfStopExpectation.fulfill() } waitForExpectations(timeout: 2) @@ -868,12 +865,9 @@ class TimeseriesSessionCollectorTests: XCTestCase { totalRAM: 4_000_000_000, now: clock.now ) - let contextReader = RUMActiveContextReaderMock() - collector.activeContextReader = contextReader - let startTime = clock.date - let activityReader = RUMSessionActivityReaderMock(sessionID: "session-idle", sessionStartTime: startTime, lastInteractionTime: startTime) - collector.sessionActivityReader = activityReader + let contextReader = RUMActiveContextReaderMock(sessionID: "session-idle", sessionStartTime: startTime, lastInteractionTime: startTime) + collector.activeContextReader = contextReader collector.start(sessionID: "session-idle", applicationID: "app-1", sessionType: .user) let beforeTimeoutExpectation = self.expectation(description: "samples collected before timeout") @@ -906,18 +900,15 @@ class TimeseriesSessionCollectorTests: XCTestCase { totalRAM: 4_000_000_000, now: clock.now ) - let contextReader = RUMActiveContextReaderMock() - collector.activeContextReader = contextReader - let startTime = clock.date - let activityReader = RUMSessionActivityReaderMock(sessionID: "session-active", sessionStartTime: startTime, lastInteractionTime: startTime) - collector.sessionActivityReader = activityReader + let contextReader = RUMActiveContextReaderMock(sessionID: "session-active", sessionStartTime: startTime, lastInteractionTime: startTime) + collector.activeContextReader = contextReader collector.start(sessionID: "session-active", applicationID: "app-1", sessionType: .user) // When — a RUM interaction is reported right before what would have been the inactivity timeout, // resetting the clock the reader exposes to `sample()` clock.date = startTime.addingTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration - 1) - activityReader.sessionActivity.lastInteractionTime = clock.date + contextReader.sessionActivity.lastInteractionTime = clock.date clock.date = clock.date.addingTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration - 1) let expectation = self.expectation(description: "still sampling") @@ -998,21 +989,28 @@ class TimeseriesSessionCollectorTests: XCTestCase { private class RUMActiveContextReaderMock: RUMActiveContextReader { var globalAttributes: [AttributeKey: AttributeValue] var activeView: (id: String?, path: String?, name: String?) + var sessionActivity: (sessionID: String?, sessionStartTime: Date?, lastInteractionTime: Date?) init( globalAttributes: [AttributeKey: AttributeValue] = [:], - activeView: (id: String?, path: String?, name: String?) = (.mockAny(), .mockAny(), nil) + activeView: (id: String?, path: String?, name: String?) = (.mockAny(), .mockAny(), nil), + sessionID: String? = nil, + sessionStartTime: Date? = nil, + lastInteractionTime: Date? = nil ) { self.globalAttributes = globalAttributes self.activeView = activeView + self.sessionActivity = (sessionID, sessionStartTime, lastInteractionTime) } -} -private class RUMSessionActivityReaderMock: RUMSessionActivityReader { - var sessionActivity: (sessionID: String?, sessionStartTime: Date?, lastInteractionTime: Date?) - - init(sessionID: String? = nil, sessionStartTime: Date? = nil, lastInteractionTime: Date? = nil) { - self.sessionActivity = (sessionID, sessionStartTime, lastInteractionTime) + func isSessionExpired(sessionID: String, at date: Date) -> Bool { + guard sessionActivity.sessionID == sessionID, + let sessionStartTime = sessionActivity.sessionStartTime, + let lastInteractionTime = sessionActivity.lastInteractionTime else { + return false + } + return RUMSessionScope.hasExpired(sessionStartTime: sessionStartTime, currentTime: date) + || RUMSessionScope.hasTimedOut(lastInteractionTime: lastInteractionTime, currentTime: date) } } From 9811f84c68f8b26b8e7000199074cd06966dd5b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barbora=20Pla=C5=A1ovsk=C3=A1?= Date: Wed, 12 Aug 2026 15:37:40 +0200 Subject: [PATCH 101/102] Fix lint, api surface and SPI docs --- .../Sources/Models/RUM/RUMDataModels.swift | 48 +----- .../RUMMonitor/Scopes/RUMViewScope.swift | 160 +++++++++--------- 2 files changed, 89 insertions(+), 119 deletions(-) diff --git a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift index a793dbf421..2fdd921011 100644 --- a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift +++ b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift @@ -5776,47 +5776,6 @@ public struct RUMTimeseriesCpuEvent: RUMDataModel { self.url = url } } - - /// View properties - public struct View: Codable { - /// UUID of the view - public let id: String - - /// User defined name of the view - public var name: String? - - /// URL that linked to the initial view of the page - public var referrer: String? - - /// URL of the view - public var url: String - - public enum CodingKeys: String, CodingKey { - case id = "id" - case name = "name" - case referrer = "referrer" - case url = "url" - } - - /// View properties - /// - /// - Parameters: - /// - id: UUID of the view - /// - name: User defined name of the view - /// - referrer: URL that linked to the initial view of the page - /// - url: URL of the view - public init( - id: String, - name: String? = nil, - referrer: String? = nil, - url: String - ) { - self.id = id - self.name = name - self.referrer = referrer - self.url = url - } - } } /// Schema for a memory timeseries event. @@ -6803,6 +6762,9 @@ public struct RUMViewEvent: RUMDataModel { /// The id of the remote configuration applied to the SDK, if any public let remoteConfigurationId: String? + /// Session Replay experimental features enabled in the SDK configuration + public let sessionReplayExperimentalFeatures: [String]? + /// The percentage of sessions with RUM & Session Replay pricing tracked public let sessionReplaySampleRate: Double? @@ -6818,6 +6780,7 @@ public struct RUMViewEvent: RUMDataModel { public enum CodingKeys: String, CodingKey { case profilingSampleRate = "profiling_sample_rate" case remoteConfigurationId = "remote_configuration_id" + case sessionReplayExperimentalFeatures = "session_replay_experimental_features" case sessionReplaySampleRate = "session_replay_sample_rate" case sessionSampleRate = "session_sample_rate" case startSessionReplayRecordingManually = "start_session_replay_recording_manually" @@ -6829,6 +6792,7 @@ public struct RUMViewEvent: RUMDataModel { /// - Parameters: /// - profilingSampleRate: The percentage of sessions profiled /// - remoteConfigurationId: The id of the remote configuration applied to the SDK, if any + /// - sessionReplayExperimentalFeatures: Session Replay experimental features enabled in the SDK configuration /// - sessionReplaySampleRate: The percentage of sessions with RUM & Session Replay pricing tracked /// - sessionSampleRate: The percentage of sessions tracked /// - startSessionReplayRecordingManually: Whether session replay recording configured to start manually @@ -6836,6 +6800,7 @@ public struct RUMViewEvent: RUMDataModel { public init( profilingSampleRate: Double? = nil, remoteConfigurationId: String? = nil, + sessionReplayExperimentalFeatures: [String]? = nil, sessionReplaySampleRate: Double? = nil, sessionSampleRate: Double, startSessionReplayRecordingManually: Bool? = nil, @@ -6843,6 +6808,7 @@ public struct RUMViewEvent: RUMDataModel { ) { self.profilingSampleRate = profilingSampleRate self.remoteConfigurationId = remoteConfigurationId + self.sessionReplayExperimentalFeatures = sessionReplayExperimentalFeatures self.sessionReplaySampleRate = sessionReplaySampleRate self.sessionSampleRate = sessionSampleRate self.startSessionReplayRecordingManually = startSessionReplayRecordingManually diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift index d5eb6e78a7..7413bc3482 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift @@ -567,30 +567,88 @@ extension RUMViewScope { let sessionReplayConfig = context.sessionReplayConfiguration let profiling = context.additionalContext(ofType: ProfilingContext.self)?.ddProfiling - let viewEvent = RUMViewEvent( - dd: .init( - browserSdkVersion: nil, - cls: nil, - configuration: .init( - sessionReplayExperimentalFeatures: sessionReplayConfig?.experimentalFeatures, - sessionReplaySampleRate: sessionReplayConfig.map { Double($0.sampleRate) }, - sessionSampleRate: Double(dependencies.samplingRate), - startSessionReplayRecordingManually: sessionReplayConfig?.startRecordingManually, - traceSampleRate: dependencies.distributedTracingSampleRate.map(Double.init) - ), - documentVersion: version.toInt64, - pageStates: nil, - profiling: profiling, - replayStats: .init( - recordsCount: context.recordsCountByViewID[viewUUID.toRUMDataFormat], - segmentsCount: nil, - segmentsTotalRawSize: nil - ), - session: .init( - plan: .plan1, - sessionPrecondition: self.context.sessionPrecondition - ) + let viewEventDD: RUMViewEvent.DD = .init( + browserSdkVersion: nil, + cls: nil, + configuration: .init( + sessionReplayExperimentalFeatures: sessionReplayConfig?.experimentalFeatures, + sessionReplaySampleRate: sessionReplayConfig.map { Double($0.sampleRate) }, + sessionSampleRate: Double(dependencies.samplingRate), + startSessionReplayRecordingManually: sessionReplayConfig?.startRecordingManually, + traceSampleRate: dependencies.distributedTracingSampleRate.map(Double.init) + ), + documentVersion: version.toInt64, + pageStates: nil, + profiling: profiling, + replayStats: .init( + recordsCount: context.recordsCountByViewID[viewUUID.toRUMDataFormat], + segmentsCount: nil, + segmentsTotalRawSize: nil ), + session: .init( + plan: .plan1, + sessionPrecondition: self.context.sessionPrecondition + ) + ) + + let viewEventView: RUMViewEvent.View = .init( + accessibility: accessibility, + action: .init(count: actionsCount.toInt64), + cpuTicksCount: cpuInfo?.greatestDiff, + cpuTicksPerSecond: timeSpent > 1.0 ? cpuInfo?.greatestDiff?.dd.divideIfNotZero(by: Double(timeSpent)) : nil, + crash: isCrash ? .init(count: 1) : .init(count: 0), + cumulativeLayoutShift: nil, + cumulativeLayoutShiftTargetSelector: nil, + cumulativeLayoutShiftTime: nil, + customTimings: .init(customTimingsInfo: customTimings.reduce(into: [:]) { acc, element in + acc[sanitizeCustomTimingName(customTiming: element.key)] = element.value + }), + domComplete: nil, + domContentLoaded: nil, + domInteractive: nil, + error: .init(count: errorsCount.toInt64), + firstByte: nil, + firstContentfulPaint: nil, + firstInputDelay: nil, + firstInputTargetSelector: nil, + firstInputTime: nil, + flutterBuildTime: viewPerformanceMetrics[.flutterBuildTime]?.asFlutterBuildTime(), + flutterRasterTime: viewPerformanceMetrics[.flutterRasterTime]?.asFlutterRasterTime(), + freezeRate: freezeRate, + frozenFrame: .init(count: frozenFramesCount), + frustration: .init(count: frustrationCount), + id: viewUUID.toRUMDataFormat, + inForegroundPeriods: nil, + interactionToNextPaint: nil, + interactionToNextPaintTargetSelector: nil, + interactionToNextPaintTime: nil, + interactionToNextViewTime: interactionToNextViewTime.value?.dd.toInt64Nanoseconds, + isActive: isActive, + isSlowRendered: isSlowRendered ?? false, + jsRefreshRate: viewPerformanceMetrics[.jsFrameTimeSeconds]?.asJsRefreshRate(), + largestContentfulPaint: nil, + largestContentfulPaintTargetSelector: nil, + loadEvent: nil, + loadingTime: viewLoadingTime?.dd.toInt64Nanoseconds, + loadingType: nil, + longTask: .init(count: longTasksCount), + memoryAverage: memoryInfo?.meanValue, + memoryMax: memoryInfo?.maxValue, + name: viewName, + networkSettledTime: networkSettledTime.value?.dd.toInt64Nanoseconds, + performance: performance, + referrer: nil, + refreshRateAverage: refreshRateInfo?.meanValue, + refreshRateMin: refreshRateInfo?.minValue, + resource: .init(count: resourcesCount.toInt64), + slowFrames: viewHitchesReader?.dataModel.hitches.map { .init(duration: $0.duration, start: $0.start) }, + slowFramesRate: slowFramesRate, + timeSpent: timeSpent.dd.toInt64Nanoseconds, + url: viewPath + ) + + let viewEvent = RUMViewEvent( + dd: viewEventDD, account: .init(context: context), application: .init(currentLocale: context.localeInfo.currentLocale, id: self.context.rumApplicationID), buildId: context.buildId, @@ -618,61 +676,7 @@ extension RUMViewScope { synthetics: dependencies.syntheticsTest, usr: .init(context: context), version: context.version, - view: .init( - accessibility: accessibility, - action: .init(count: actionsCount.toInt64), - cpuTicksCount: cpuInfo?.greatestDiff, - cpuTicksPerSecond: timeSpent > 1.0 ? cpuInfo?.greatestDiff?.dd.divideIfNotZero(by: Double(timeSpent)) : nil, - crash: isCrash ? .init(count: 1) : .init(count: 0), - cumulativeLayoutShift: nil, - cumulativeLayoutShiftTargetSelector: nil, - cumulativeLayoutShiftTime: nil, - customTimings: .init(customTimingsInfo: customTimings.reduce(into: [:]) { acc, element in - acc[sanitizeCustomTimingName(customTiming: element.key)] = element.value - }), - domComplete: nil, - domContentLoaded: nil, - domInteractive: nil, - error: .init(count: errorsCount.toInt64), - firstByte: nil, - firstContentfulPaint: nil, - firstInputDelay: nil, - firstInputTargetSelector: nil, - firstInputTime: nil, - flutterBuildTime: viewPerformanceMetrics[.flutterBuildTime]?.asFlutterBuildTime(), - flutterRasterTime: viewPerformanceMetrics[.flutterRasterTime]?.asFlutterRasterTime(), - freezeRate: freezeRate, - frozenFrame: .init(count: frozenFramesCount), - frustration: .init(count: frustrationCount), - id: viewUUID.toRUMDataFormat, - inForegroundPeriods: nil, - interactionToNextPaint: nil, - interactionToNextPaintTargetSelector: nil, - interactionToNextPaintTime: nil, - interactionToNextViewTime: interactionToNextViewTime.value?.dd.toInt64Nanoseconds, - isActive: isActive, - isSlowRendered: isSlowRendered ?? false, - jsRefreshRate: viewPerformanceMetrics[.jsFrameTimeSeconds]?.asJsRefreshRate(), - largestContentfulPaint: nil, - largestContentfulPaintTargetSelector: nil, - loadEvent: nil, - loadingTime: viewLoadingTime?.dd.toInt64Nanoseconds, - loadingType: nil, - longTask: .init(count: longTasksCount), - memoryAverage: memoryInfo?.meanValue, - memoryMax: memoryInfo?.maxValue, - name: viewName, - networkSettledTime: networkSettledTime.value?.dd.toInt64Nanoseconds, - performance: performance, - referrer: nil, - refreshRateAverage: refreshRateInfo?.meanValue, - refreshRateMin: refreshRateInfo?.minValue, - resource: .init(count: resourcesCount.toInt64), - slowFrames: viewHitchesReader?.dataModel.hitches.map { .init(duration: $0.duration, start: $0.start) }, - slowFramesRate: slowFramesRate, - timeSpent: timeSpent.dd.toInt64Nanoseconds, - url: viewPath - ) + view: viewEventView ) if let event = dependencies.eventBuilder.build(from: viewEvent) { From 7ba76c1d3fda9e4900620b3bb4fd3abf0feddf0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barbora=20Pla=C5=A1ovsk=C3=A1?= Date: Wed, 12 Aug 2026 16:22:19 +0200 Subject: [PATCH 102/102] Regenerate RUM models from rum-events-format master schema --- .../Sources/Models/RUM/RUMDataModels.swift | 88 ++++++++++++++++++- .../DataModels/RUMDataModels+objc.swift | 63 ++++++++++++- api-surface-objc | 12 +++ 3 files changed, 158 insertions(+), 5 deletions(-) diff --git a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift index 2fdd921011..4ce90166be 100644 --- a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift +++ b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift @@ -5405,6 +5405,9 @@ public struct RUMTimeseriesCpuEvent: RUMDataModel { /// The percentage of sessions profiled public let profilingSampleRate: Double? + /// Session Replay experimental features enabled in the SDK configuration + public let sessionReplayExperimentalFeatures: [String]? + /// The percentage of sessions with RUM & Session Replay pricing tracked public let sessionReplaySampleRate: Double? @@ -5416,6 +5419,7 @@ public struct RUMTimeseriesCpuEvent: RUMDataModel { public enum CodingKeys: String, CodingKey { case profilingSampleRate = "profiling_sample_rate" + case sessionReplayExperimentalFeatures = "session_replay_experimental_features" case sessionReplaySampleRate = "session_replay_sample_rate" case sessionSampleRate = "session_sample_rate" case traceSampleRate = "trace_sample_rate" @@ -5425,16 +5429,19 @@ public struct RUMTimeseriesCpuEvent: RUMDataModel { /// /// - Parameters: /// - profilingSampleRate: The percentage of sessions profiled + /// - sessionReplayExperimentalFeatures: Session Replay experimental features enabled in the SDK configuration /// - sessionReplaySampleRate: The percentage of sessions with RUM & Session Replay pricing tracked /// - sessionSampleRate: The percentage of sessions tracked /// - traceSampleRate: The percentage of sessions with traced resources public init( profilingSampleRate: Double? = nil, + sessionReplayExperimentalFeatures: [String]? = nil, sessionReplaySampleRate: Double? = nil, sessionSampleRate: Double, traceSampleRate: Double? = nil ) { self.profilingSampleRate = profilingSampleRate + self.sessionReplayExperimentalFeatures = sessionReplayExperimentalFeatures self.sessionReplaySampleRate = sessionReplaySampleRate self.sessionSampleRate = sessionSampleRate self.traceSampleRate = traceSampleRate @@ -5655,7 +5662,6 @@ public struct RUMTimeseriesCpuEvent: RUMDataModel { /// Wire-shape discriminator for the data field public let schema: String = "object-v2" - /// Timestamp of the first sample in nanoseconds from epoch public let start: Int64 @@ -5733,7 +5739,6 @@ public struct RUMTimeseriesCpuEvent: RUMDataModel { } } } - } /// View properties @@ -6004,6 +6009,9 @@ public struct RUMTimeseriesMemoryEvent: RUMDataModel { /// The percentage of sessions profiled public let profilingSampleRate: Double? + /// Session Replay experimental features enabled in the SDK configuration + public let sessionReplayExperimentalFeatures: [String]? + /// The percentage of sessions with RUM & Session Replay pricing tracked public let sessionReplaySampleRate: Double? @@ -6015,6 +6023,7 @@ public struct RUMTimeseriesMemoryEvent: RUMDataModel { public enum CodingKeys: String, CodingKey { case profilingSampleRate = "profiling_sample_rate" + case sessionReplayExperimentalFeatures = "session_replay_experimental_features" case sessionReplaySampleRate = "session_replay_sample_rate" case sessionSampleRate = "session_sample_rate" case traceSampleRate = "trace_sample_rate" @@ -6024,16 +6033,19 @@ public struct RUMTimeseriesMemoryEvent: RUMDataModel { /// /// - Parameters: /// - profilingSampleRate: The percentage of sessions profiled + /// - sessionReplayExperimentalFeatures: Session Replay experimental features enabled in the SDK configuration /// - sessionReplaySampleRate: The percentage of sessions with RUM & Session Replay pricing tracked /// - sessionSampleRate: The percentage of sessions tracked /// - traceSampleRate: The percentage of sessions with traced resources public init( profilingSampleRate: Double? = nil, + sessionReplayExperimentalFeatures: [String]? = nil, sessionReplaySampleRate: Double? = nil, sessionSampleRate: Double, traceSampleRate: Double? = nil ) { self.profilingSampleRate = profilingSampleRate + self.sessionReplayExperimentalFeatures = sessionReplayExperimentalFeatures self.sessionReplaySampleRate = sessionReplaySampleRate self.sessionSampleRate = sessionSampleRate self.traceSampleRate = traceSampleRate @@ -6254,7 +6266,6 @@ public struct RUMTimeseriesMemoryEvent: RUMDataModel { /// Wire-shape discriminator for the data field public let schema: String = "object-v2" - /// Timestamp of the first sample in nanoseconds from epoch public let start: Int64 @@ -8936,6 +8947,9 @@ public struct RUMViewUpdateEvent: RUMDataModel { /// The id of the remote configuration applied to the SDK, if any public let remoteConfigurationId: String? + /// Session Replay experimental features enabled in the SDK configuration + public let sessionReplayExperimentalFeatures: [String]? + /// The percentage of sessions with RUM & Session Replay pricing tracked public let sessionReplaySampleRate: Double? @@ -8951,6 +8965,7 @@ public struct RUMViewUpdateEvent: RUMDataModel { public enum CodingKeys: String, CodingKey { case profilingSampleRate = "profiling_sample_rate" case remoteConfigurationId = "remote_configuration_id" + case sessionReplayExperimentalFeatures = "session_replay_experimental_features" case sessionReplaySampleRate = "session_replay_sample_rate" case sessionSampleRate = "session_sample_rate" case startSessionReplayRecordingManually = "start_session_replay_recording_manually" @@ -8962,6 +8977,7 @@ public struct RUMViewUpdateEvent: RUMDataModel { /// - Parameters: /// - profilingSampleRate: The percentage of sessions profiled /// - remoteConfigurationId: The id of the remote configuration applied to the SDK, if any + /// - sessionReplayExperimentalFeatures: Session Replay experimental features enabled in the SDK configuration /// - sessionReplaySampleRate: The percentage of sessions with RUM & Session Replay pricing tracked /// - sessionSampleRate: The percentage of sessions tracked /// - startSessionReplayRecordingManually: Whether session replay recording configured to start manually @@ -8969,6 +8985,7 @@ public struct RUMViewUpdateEvent: RUMDataModel { public init( profilingSampleRate: Double? = nil, remoteConfigurationId: String? = nil, + sessionReplayExperimentalFeatures: [String]? = nil, sessionReplaySampleRate: Double? = nil, sessionSampleRate: Double, startSessionReplayRecordingManually: Bool? = nil, @@ -8976,6 +8993,7 @@ public struct RUMViewUpdateEvent: RUMDataModel { ) { self.profilingSampleRate = profilingSampleRate self.remoteConfigurationId = remoteConfigurationId + self.sessionReplayExperimentalFeatures = sessionReplayExperimentalFeatures self.sessionReplaySampleRate = sessionReplaySampleRate self.sessionSampleRate = sessionSampleRate self.startSessionReplayRecordingManually = startSessionReplayRecordingManually @@ -13085,6 +13103,9 @@ public struct TelemetryConfigurationEvent: RUMDataModel { /// The version of React used in a ReactNative application public var reactVersion: String? + /// Metadata of the remote configuration currently applied for this session + public var remoteConfiguration: RemoteConfiguration? + /// The id of the remote configuration public var remoteConfigurationId: String? @@ -13321,6 +13342,7 @@ public struct TelemetryConfigurationEvent: RUMDataModel { case propagateTraceBaggage = "propagate_trace_baggage" case reactNativeVersion = "react_native_version" case reactVersion = "react_version" + case remoteConfiguration = "remote_configuration" case remoteConfigurationId = "remote_configuration_id" case replaySampleRate = "replay_sample_rate" case sdkVersion = "sdk_version" @@ -13425,6 +13447,7 @@ public struct TelemetryConfigurationEvent: RUMDataModel { /// - propagateTraceBaggage: Whether trace baggage is propagated to child spans /// - reactNativeVersion: The version of ReactNative used in a ReactNative application /// - reactVersion: The version of React used in a ReactNative application + /// - remoteConfiguration: Metadata of the remote configuration currently applied for this session /// - remoteConfigurationId: The id of the remote configuration /// - replaySampleRate: The percentage of sessions with Browser RUM & Session Replay pricing tracked (deprecated in favor of session_replay_sample_rate) /// - sdkVersion: The version of the SDK that is running. @@ -13525,6 +13548,7 @@ public struct TelemetryConfigurationEvent: RUMDataModel { propagateTraceBaggage: Bool? = nil, reactNativeVersion: String? = nil, reactVersion: String? = nil, + remoteConfiguration: RemoteConfiguration? = nil, remoteConfigurationId: String? = nil, replaySampleRate: Int64? = nil, sdkVersion: String? = nil, @@ -13625,6 +13649,7 @@ public struct TelemetryConfigurationEvent: RUMDataModel { self.propagateTraceBaggage = propagateTraceBaggage self.reactNativeVersion = reactNativeVersion self.reactVersion = reactVersion + self.remoteConfiguration = remoteConfiguration self.remoteConfigurationId = remoteConfigurationId self.replaySampleRate = replaySampleRate self.sdkVersion = sdkVersion @@ -13802,6 +13827,61 @@ public struct TelemetryConfigurationEvent: RUMDataModel { } } + /// Metadata of the remote configuration currently applied for this session + public struct RemoteConfiguration: Codable { + /// Identifier of the remote configuration bundle this metadata belongs to + public var configId: String? + + /// Timestamp at which this configuration version was first observed as applied by the device, in ms from epoch. Stamped once and reused on every subsequent session that runs on the same version + public var firstApplied: Int64? + + /// CDN publish timestamp of the applied configuration, in ms from epoch + public var lastModified: Int64? + + /// Timestamp at which the device fetched and cached this configuration version, in ms from epoch + public var lastSynced: Int64? + + /// Identifier of the sync that produced this configuration version, used to deduplicate repeat sessions from the same device without a persistent identifier + public var syncId: String? + + /// CDN version identifier of the applied configuration + public var versionId: String? + + public enum CodingKeys: String, CodingKey { + case configId = "config_id" + case firstApplied = "first_applied" + case lastModified = "last_modified" + case lastSynced = "last_synced" + case syncId = "sync_id" + case versionId = "version_id" + } + + /// Metadata of the remote configuration currently applied for this session + /// + /// - Parameters: + /// - configId: Identifier of the remote configuration bundle this metadata belongs to + /// - firstApplied: Timestamp at which this configuration version was first observed as applied by the device, in ms from epoch. Stamped once and reused on every subsequent session that runs on the same version + /// - lastModified: CDN publish timestamp of the applied configuration, in ms from epoch + /// - lastSynced: Timestamp at which the device fetched and cached this configuration version, in ms from epoch + /// - syncId: Identifier of the sync that produced this configuration version, used to deduplicate repeat sessions from the same device without a persistent identifier + /// - versionId: CDN version identifier of the applied configuration + public init( + configId: String? = nil, + firstApplied: Int64? = nil, + lastModified: Int64? = nil, + lastSynced: Int64? = nil, + syncId: String? = nil, + versionId: String? = nil + ) { + self.configId = configId + self.firstApplied = firstApplied + self.lastModified = lastModified + self.lastSynced = lastSynced + self.syncId = syncId + self.versionId = versionId + } + } + public enum SelectedTracingPropagators: String, Codable { case datadog = "datadog" case b3 = "b3" @@ -15552,4 +15632,4 @@ extension TelemetryUsageEvent.Telemetry { } } -// Generated from https://github.com/DataDog/rum-events-format/tree/ece51fc7977b612330049af36095ab2310a001af +// Generated from https://github.com/DataDog/rum-events-format/tree/7e92fa29cb294a0a069e9212bc0f0dd76ec8432d diff --git a/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift b/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift index 9ebd3cb66c..803577d2ea 100644 --- a/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift +++ b/DatadogRUM/Sources/DataModels/RUMDataModels+objc.swift @@ -6370,6 +6370,10 @@ public class objc_RUMTimeseriesCpuEventDDConfiguration: NSObject { root.swiftModel.dd.configuration!.profilingSampleRate as NSNumber? } + public var sessionReplayExperimentalFeatures: [String]? { + root.swiftModel.dd.configuration!.sessionReplayExperimentalFeatures + } + public var sessionReplaySampleRate: NSNumber? { root.swiftModel.dd.configuration!.sessionReplaySampleRate as NSNumber? } @@ -7291,6 +7295,10 @@ public class objc_RUMTimeseriesMemoryEventDDConfiguration: NSObject { root.swiftModel.dd.configuration!.profilingSampleRate as NSNumber? } + public var sessionReplayExperimentalFeatures: [String]? { + root.swiftModel.dd.configuration!.sessionReplayExperimentalFeatures + } + public var sessionReplaySampleRate: NSNumber? { root.swiftModel.dd.configuration!.sessionReplaySampleRate as NSNumber? } @@ -8263,6 +8271,10 @@ public class objc_RUMViewEventDDConfiguration: NSObject { root.swiftModel.dd.configuration!.remoteConfigurationId } + public var sessionReplayExperimentalFeatures: [String]? { + root.swiftModel.dd.configuration!.sessionReplayExperimentalFeatures + } + public var sessionReplaySampleRate: NSNumber? { root.swiftModel.dd.configuration!.sessionReplaySampleRate as NSNumber? } @@ -10420,6 +10432,10 @@ public class objc_RUMViewUpdateEventDDConfiguration: NSObject { root.swiftModel.dd.configuration!.remoteConfigurationId } + public var sessionReplayExperimentalFeatures: [String]? { + root.swiftModel.dd.configuration!.sessionReplayExperimentalFeatures + } + public var sessionReplaySampleRate: NSNumber? { root.swiftModel.dd.configuration!.sessionReplaySampleRate as NSNumber? } @@ -16181,6 +16197,10 @@ public class objc_TelemetryConfigurationEventTelemetryConfiguration: NSObject { get { root.swiftModel.telemetry.configuration.reactVersion } } + public var remoteConfiguration: objc_TelemetryConfigurationEventTelemetryConfigurationRemoteConfiguration? { + root.swiftModel.telemetry.configuration.remoteConfiguration != nil ? objc_TelemetryConfigurationEventTelemetryConfigurationRemoteConfiguration(root: root) : nil + } + public var remoteConfigurationId: String? { set { root.swiftModel.telemetry.configuration.remoteConfigurationId = newValue } get { root.swiftModel.telemetry.configuration.remoteConfigurationId } @@ -16564,6 +16584,47 @@ public class objc_TelemetryConfigurationEventTelemetryConfigurationPlugins: NSOb } } +@objc(DDTelemetryConfigurationEventTelemetryConfigurationRemoteConfiguration) +@objcMembers +@_spi(objc) +public class objc_TelemetryConfigurationEventTelemetryConfigurationRemoteConfiguration: NSObject { + internal let root: objc_TelemetryConfigurationEvent + + internal init(root: objc_TelemetryConfigurationEvent) { + self.root = root + } + + public var configId: String? { + set { root.swiftModel.telemetry.configuration.remoteConfiguration!.configId = newValue } + get { root.swiftModel.telemetry.configuration.remoteConfiguration!.configId } + } + + public var firstApplied: NSNumber? { + set { root.swiftModel.telemetry.configuration.remoteConfiguration!.firstApplied = newValue?.int64Value } + get { root.swiftModel.telemetry.configuration.remoteConfiguration!.firstApplied as NSNumber? } + } + + public var lastModified: NSNumber? { + set { root.swiftModel.telemetry.configuration.remoteConfiguration!.lastModified = newValue?.int64Value } + get { root.swiftModel.telemetry.configuration.remoteConfiguration!.lastModified as NSNumber? } + } + + public var lastSynced: NSNumber? { + set { root.swiftModel.telemetry.configuration.remoteConfiguration!.lastSynced = newValue?.int64Value } + get { root.swiftModel.telemetry.configuration.remoteConfiguration!.lastSynced as NSNumber? } + } + + public var syncId: String? { + set { root.swiftModel.telemetry.configuration.remoteConfiguration!.syncId = newValue } + get { root.swiftModel.telemetry.configuration.remoteConfiguration!.syncId } + } + + public var versionId: String? { + set { root.swiftModel.telemetry.configuration.remoteConfiguration!.versionId = newValue } + get { root.swiftModel.telemetry.configuration.remoteConfiguration!.versionId } + } +} + @objc(DDTelemetryConfigurationEventTelemetryConfigurationSelectedTracingPropagators) @_spi(objc) public enum objc_TelemetryConfigurationEventTelemetryConfigurationSelectedTracingPropagators: Int { @@ -17478,4 +17539,4 @@ public class objc_TelemetryErrorEventView: NSObject { // swiftlint:enable force_unwrapping -// Generated from https://github.com/DataDog/rum-events-format/tree/ece51fc7977b612330049af36095ab2310a001af +// Generated from https://github.com/DataDog/rum-events-format/tree/7e92fa29cb294a0a069e9212bc0f0dd76ec8432d diff --git a/api-surface-objc b/api-surface-objc index 32cd2f8a33..a1e98d4530 100644 --- a/api-surface-objc +++ b/api-surface-objc @@ -1635,6 +1635,7 @@ public class objc_RUMTimeseriesCpuEventDD: NSObject public var session: objc_RUMTimeseriesCpuEventDDSession? public class objc_RUMTimeseriesCpuEventDDConfiguration: NSObject public var profilingSampleRate: NSNumber? + public var sessionReplayExperimentalFeatures: [String]? public var sessionReplaySampleRate: NSNumber? public var sessionSampleRate: NSNumber public var traceSampleRate: NSNumber? @@ -1816,6 +1817,7 @@ public class objc_RUMTimeseriesMemoryEventDD: NSObject public var session: objc_RUMTimeseriesMemoryEventDDSession? public class objc_RUMTimeseriesMemoryEventDDConfiguration: NSObject public var profilingSampleRate: NSNumber? + public var sessionReplayExperimentalFeatures: [String]? public var sessionReplaySampleRate: NSNumber? public var sessionSampleRate: NSNumber public var traceSampleRate: NSNumber? @@ -2008,6 +2010,7 @@ public class objc_RUMViewEventDDCLS: NSObject public class objc_RUMViewEventDDConfiguration: NSObject public var profilingSampleRate: NSNumber? public var remoteConfigurationId: String? + public var sessionReplayExperimentalFeatures: [String]? public var sessionReplaySampleRate: NSNumber? public var sessionSampleRate: NSNumber public var startSessionReplayRecordingManually: NSNumber? @@ -2435,6 +2438,7 @@ public class objc_RUMViewUpdateEventDDCLS: NSObject public class objc_RUMViewUpdateEventDDConfiguration: NSObject public var profilingSampleRate: NSNumber? public var remoteConfigurationId: String? + public var sessionReplayExperimentalFeatures: [String]? public var sessionReplaySampleRate: NSNumber? public var sessionSampleRate: NSNumber public var startSessionReplayRecordingManually: NSNumber? @@ -3577,6 +3581,7 @@ public class objc_TelemetryConfigurationEventTelemetryConfiguration: NSObject public var propagateTraceBaggage: NSNumber? public var reactNativeVersion: String? public var reactVersion: String? + public var remoteConfiguration: objc_TelemetryConfigurationEventTelemetryConfigurationRemoteConfiguration? public var remoteConfigurationId: String? public var replaySampleRate: NSNumber? public var sdkVersion: String? @@ -3654,6 +3659,13 @@ public class objc_TelemetryConfigurationEventTelemetryConfigurationForwardReport public class objc_TelemetryConfigurationEventTelemetryConfigurationPlugins: NSObject public var name: String public var pluginsInfo: [String: Any] +public class objc_TelemetryConfigurationEventTelemetryConfigurationRemoteConfiguration: NSObject + public var configId: String? + public var firstApplied: NSNumber? + public var lastModified: NSNumber? + public var lastSynced: NSNumber? + public var syncId: String? + public var versionId: String? public enum objc_TelemetryConfigurationEventTelemetryConfigurationSelectedTracingPropagators: Int case none case datadog