From d7175062cd8995002bed0326fe389135d4b1301a Mon Sep 17 00:00:00 2001 From: maxep <6815992+maxep@users.noreply.github.com> Date: Tue, 19 May 2026 18:17:14 +0200 Subject: [PATCH] [chain-pr 10/10] Documentation updates --- AGENTS.md | 3 + docs/ARCHITECTURE.md | 53 +++++++------ docs/MESSAGE_BUS.md | 178 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 25 deletions(-) create mode 100644 docs/MESSAGE_BUS.md diff --git a/AGENTS.md b/AGENTS.md index a62b0646ce..c1d944584b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,8 @@ docs/ │ RFC process, build & test quick reference ├── TESTING.md ← Test conventions, mock infrastructure (.mockAny(), │ .mockRandom(), .mockWith()), DatadogCoreProxy usage +├── MESSAGE_BUS.md ← Typed pub/sub bus: subscription patterns, all message types, +│ how to add a new message ├── KNOWN_CONCERNS.md ← Fragile areas requiring extra caution ├── SWIZZLING.md ← Mandatory swizzling patterns and real incidents ├── LLM_FEATURE_DOCS_GUIDELINES.md ← How to update *_FEATURE.md files @@ -46,6 +48,7 @@ Feature-specific docs (in each module directory): | Add a new feature, command, or provider | `docs/DEVELOPMENT.md` | | Write or fix tests | `docs/TESTING.md` | | Check naming, lint, commit format | `docs/CONVENTIONS.md` | +| Send or receive messages between features | `docs/MESSAGE_BUS.md` | | Touch swizzling code | `docs/SWIZZLING.md` | | Modify a fragile area | `docs/KNOWN_CONCERNS.md` | | Work on RUM specifically | `DatadogRUM/RUM_FEATURE.md` | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5453fdf041..ead4e05302 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -106,59 +106,62 @@ DataUploadWorker (periodic) → DataReader → RequestBuilder → HTTPClient → ## Message Bus -### Message Types +> **Current design:** See `docs/MESSAGE_BUS.md` — typed `BusMessage` protocol, all supported messages, subscription patterns, and how to add a new message. -Inter-feature communication uses `FeatureMessage` (defined in `DatadogInternal/Sources/MessageBus/FeatureMessage.swift`): +The subsections below describe the **deprecated** `FeatureMessage`-based API (`FeatureMessageReceiver`, `CombinedFeatureMessageReceiver`). This API is being removed. Do not add new receivers or senders using it. + +### ~~Message Types~~ (deprecated) + +~~Inter-feature communication uses `FeatureMessage` (defined in `DatadogInternal/Sources/MessageBus/FeatureMessage.swift`):~~ | Case | When to use | |------|------------| -| `.context(DatadogContext)` | **Shared state that changes over time.** Broadcast automatically on every context update. Receivers extract what they need from `DatadogContext.additionalContext`. | -| `.payload(Any)` | **One-off events or commands.** Sender explicitly calls `core.send(message: .payload(...))`. Receiver downcasts to the expected type. | -| `.webview(WebViewMessage)` | Browser SDK events from the JS bridge (logs, RUM, telemetry, session replay records). | -| `.telemetry(TelemetryMessage)` | SDK internal telemetry (debug, error, configuration, metric, usage). | +| ~~`.context(DatadogContext)`~~ | ~~**Shared state that changes over time.** Broadcast automatically on every context update. Receivers extract what they need from `DatadogContext.additionalContext`.~~ | +| ~~`.payload(Any)`~~ | ~~**One-off events or commands.** Sender explicitly calls `core.send(message: .payload(...))`. Receiver downcasts to the expected type.~~ | +| ~~`.webview(WebViewMessage)`~~ | ~~Browser SDK events from the JS bridge (logs, RUM, telemetry, session replay records).~~ | +| ~~`.telemetry(TelemetryMessage)`~~ | ~~SDK internal telemetry (debug, error, configuration, metric, usage).~~ | -### `.context` Pattern — Reading Shared State +**Replacement:** send a concrete `BusMessage` type directly via `core.messageBus.send(message:else:)`. -Use this when a feature needs to track another feature's evolving state (e.g., current RUM view, session sampling decision). Context is propagated automatically — no explicit sends required. +### ~~`.context` Pattern~~ (deprecated) -**How it works:** -1. A feature sets its context via `featureScope.set(context: { RUMCoreContext(...) })` — this updates `DatadogContext.additionalContext`. -2. Any context change triggers `DatadogCore` to broadcast `.context(datadogContext)` to every registered feature. -3. Receivers extract what they need: `context.additionalContext(ofType: RUMCoreContext.self)`. +~~Use this when a feature needs to track another feature's evolving state (e.g., current RUM view, session sampling decision). Context is propagated automatically — no explicit sends required.~~ -**Canonical example** — Session Replay's `RUMContextReceiver` (`DatadogSessionReplay/Sources/Feature/RUMContextReceiver.swift`): +**Replacement:** subscribe to `DatadogContext` on the typed bus — it is broadcast automatically on every context update, identical to the old `.context` case but without the enum wrapper. ```swift +// Before (deprecated) func receive(message: FeatureMessage, from core: DatadogCoreProtocol) -> Bool { guard case let .context(context) = message else { return false } let new = context.additionalContext(ofType: RUMCoreContext.self) if new != previous { onNew?(new); previous = new } return true } -``` -Other `.context` receivers: Trace's `ContextMessageReceiver`, `NetworkContextCoreProvider`, `CrashContextCoreProvider`, `WatchdogTerminationMonitor`, `ContextSharingTransformer`. +// After +func receive(message: DatadogContext, from core: DatadogCoreProtocol) { + let new = message.additionalContext(ofType: RUMCoreContext.self) + if new != previous { onNew?(new); previous = new } +} +``` -### `.payload` Pattern — One-Off Events +### ~~`.payload` Pattern~~ (deprecated) -Use this for discrete events that one feature sends and another consumes — crash reports, error forwarding, flag evaluations. +~~Use this for discrete events that one feature sends and another consumes — crash reports, error forwarding, flag evaluations.~~ -**Examples:** -- `RemoteLogger` sends `.payload(RUMErrorMessage)` → RUM's `ErrorMessageReceiver` adds a RUM error -- `CrashReportSender` sends `.payload(Crash)` → RUM's `CrashReportReceiver` writes crash error events -- `FatalErrorContextNotifier` sends `.payload(RUMViewEvent)` → `CrashContextCoreProvider` persists the last view for crash reports +**Replacement:** define a dedicated `BusMessage` struct for the payload type and subscribe via `BusMessageReceiver`. -### `CombinedFeatureMessageReceiver` — Ordering Matters +### ~~`CombinedFeatureMessageReceiver`~~ (deprecated) -`CombinedFeatureMessageReceiver` uses `contains(where:)` — it **short-circuits** after the first receiver returns `true`. Receivers later in the list will not see the message. This is intentional for deduplication but means **ordering of receivers within a feature matters**. +~~`CombinedFeatureMessageReceiver` uses `contains(where:)` — it short-circuits after the first receiver returns `true`.~~ -Note: `MessageBus.send()` does NOT short-circuit across features — every registered feature receives every message. +**Replacement:** all typed-bus subscribers receive every message independently — there is no short-circuiting. ### `RUMCoreContext` Defined in `DatadogInternal/Sources/Models/RUM/RUMCoreContext.swift`. Key fields: `applicationID`, `sessionID`, `viewID`, `userActionID`, `viewServerTimeOffset`, `sessionSampler`. Conforms to `AdditionalContext` (key: `"rum"`) and `Equatable`. -Set by `Monitor.swift` after each command via `featureScope.set(context:)`. Consumed by any receiver that calls `context.additionalContext(ofType: RUMCoreContext.self)`. +Set by `Monitor.swift` after each command via `featureScope.set(context:)`. Consumed by any receiver that calls `context.additionalContext(ofType: RUMCoreContext.self)`. This mechanism is not deprecated — `AdditionalContext` piggybacks on the `DatadogContext` bus message and is unaffected by the `FeatureMessage` removal. ## Error Handling Strategy diff --git a/docs/MESSAGE_BUS.md b/docs/MESSAGE_BUS.md new file mode 100644 index 0000000000..d1c9a02213 --- /dev/null +++ b/docs/MESSAGE_BUS.md @@ -0,0 +1,178 @@ +# Message Bus + +The SDK's typed publish/subscribe channel for inter-feature communication. Features registered to the same core can exchange strongly-typed values without importing each other. + +## Core Protocols + +| Protocol | Role | +|----------|------| +| `BusMessage` | A value type (struct or enum) carried on the bus. Declares a stable `key`. | +| `BusMessageReceiver` | A class-bound receiver for one `BusMessage` type. Subscribed by identity. | +| `MessageBus` | The channel. Subscribe, unsubscribe, send. | +| `MessageBusSubscription` | Opaque handle returned by the closure-based `subscribe(block:)` API. | + +All types live in `DatadogInternal/Sources/MessageBus/MessageBus.swift`. The concrete implementation is `CoreMessageBus` in `DatadogCore/Sources/Core/CoreMessageBus.swift`. + +## Subscription Patterns + +### Receiver-based (long-lived objects) + +Implement `BusMessageReceiver` when the subscriber already has a natural lifecycle (a `Feature`, an instrumentation component). The bus retains the receiver until `unsubscribe` is called. + +```swift +final class MyReceiver: BusMessageReceiver { + typealias Message = RUMSessionState + + func receive(message: RUMSessionState, from core: DatadogCoreProtocol) { + // handle on the bus's serial queue — do not block + } +} + +let receiver = MyReceiver() +core.messageBus.subscribe(receiver: receiver) +// ... +core.messageBus.unsubscribe(receiver: receiver) +``` + +Subscribe at feature enable time, typically in the module's `enable(with:in:)` function: + +```swift +// DatadogRUM/Sources/RUM.swift +core.messageBus.subscribe(receiver: rum.crashReportReceiver) +core.messageBus.subscribe(receiver: rum.telemetryReceiver) +``` + +### Closure-based (ad-hoc subscriptions) + +Use `subscribe(block:)` when no natural receiver object exists. The returned `MessageBusSubscription` owns the subscription — store it for the lifetime you need, then pass it to `unsubscribe(_:)`. + +```swift +var subscriptions: [MessageBusSubscription] = [] + +subscriptions += [ + bus.subscribe { [weak self] (message: RUMViewEvent, _) in + self?.update(viewEvent: message) + }, + bus.subscribe { [weak self] (_: RUMViewReset, _) in + self?.clearViewEvent() + }, +] + +// cancel all at teardown +subscriptions.forEach { bus.unsubscribe($0) } +``` + +`CrashContextCoreProvider` uses this pattern to subscribe to multiple message types on one bus, retaining all handles in a `[MessageBusSubscription]` array. See `DatadogCrashReporting/Sources/CrashContextProvider.swift`. + +## Sending Messages + +```swift +// Fire-and-forget — no fallback needed +core.messageBus.send(message: RUMViewReset()) + +// With a fallback when no subscriber is registered +core.messageBus.send(message: WebViewLogMessage(event: event), else: { + DD.logger.warn("A WebView log is lost because Logging is disabled in the SDK") +}) +``` + +`send` is asynchronous — it dispatches on the bus's serial queue. Do not assume the message is delivered by the time `send` returns. + +## Supported Messages + +The table below lists every `BusMessage` type registered across the SDK. + +| Type | Key | Sent by | Consumed by | +|------|-----|---------|-------------| +| `DatadogContext` | `"core.context"` | `DatadogCore` (on every context update) | `ContextSharingTransformer`, `NetworkContextCoreProvider`, `WatchdogTerminationMonitor`, `RUMContextReceiver` (SR), `ContextMessageReceiver` (Trace), `CrashContextCoreProvider` | +| `TelemetryMessage` | `"telemetry"` | Any feature via `core.telemetry.*` | `TelemetryReceiver` (RUM) | +| `LogMessage` | `"log-message"` | `TracingWithLoggingIntegration` (Trace) | `LogMessageReceiver` (Logs) | +| `LogEventAttributes` | `"log-event-attributes"` | `Logs.enable` (shared global attributes) | `CrashContextCoreProvider` | +| `Crash` | `"crash-report"` | `CrashReportSender` (CrashReporting) | `CrashReportReceiver` (RUM) | +| `RUMViewEvent` | `"rum-view-event"` | `FatalErrorContextNotifier` (RUM) | `CrashContextCoreProvider` | +| `RUMEventAttributes` | `"rum-event-attributes"` | `FatalErrorContextNotifier` (RUM) | `CrashContextCoreProvider` | +| `RUMViewReset` | `"rum-view-reset"` | `FatalErrorContextNotifier` (RUM) | `CrashContextCoreProvider` | +| `RUMSessionState` | `"rum-session-state"` | `FatalErrorContextNotifier` (RUM) | `CrashContextCoreProvider` | +| `RUMErrorMessage` | `"rum-error"` | `RemoteLogger` (Logs) | `ErrorMessageReceiver` (RUM) | +| `RUMFlagEvaluationMessage` | `"rum-flag-evaluation"` | `RUMFlagEvaluationReporter` (Flags) | `FlagEvaluationReceiver` (RUM) | +| `WebViewLogMessage` | `"webview-log"` | `MessageEmitter` (WebViewTracking) | `WebViewLogReceiver` (Logs) | +| `WebViewRUMMessage` | `"webview-rum"` | `MessageEmitter` (WebViewTracking) | `WebViewEventReceiver` (RUM) | +| `WebViewRecordMessage` | `"webview-record"` | `MessageEmitter` (WebViewTracking) | `WebViewRecordReceiver` (SR) | + +### `TelemetryMessage` — special dispatch + +`TelemetryMessage.configuration(...)` is intercepted by `CoreMessageBus` and **not** delivered immediately. The bus accumulates configuration updates and dispatches a single merged `TelemetryMessage.configuration` to subscribers 5 seconds after initialization. All other `TelemetryMessage` variants (`.debug`, `.error`, `.metric`, `.usage`) are delivered normally. + +## How to Add a New Message + +### 1. Define the message type in `DatadogInternal` + +Messages live in `DatadogInternal/Sources/Models/` alongside the domain they belong to. Prefer immutable value types. + +```swift +// DatadogInternal/Sources/Models/MyFeature/MyMessage.swift +public struct MyMessage: BusMessage { + public static let key = "my-feature.my-message" // globally unique, namespaced + + public let value: String + + public init(value: String) { + self.value = value + } +} +``` + +Rules for `key`: +- Must be **globally unique** across the SDK — check the table above before choosing. +- Use `"."` format (e.g. `"rum-session-state"`, `"webview-log"`). +- Treat it as **immutable** after the first release — downstream tooling and crash-context serialization may depend on it. + +Add the new file to the `DatadogInternal` Xcode target via the `xcode-file-management` skill. + +### 2. Implement a receiver in the consuming feature + +```swift +// DatadogMyFeature/Sources/Feature/MyMessageReceiver.swift +internal final class MyMessageReceiver: BusMessageReceiver { + func receive(message: MyMessage, from core: DatadogCoreProtocol) { + // called on the bus's serial queue — do not block + } +} +``` + +### 3. Subscribe at feature enable time + +```swift +// DatadogMyFeature/Sources/MyFeature.swift +core.messageBus.subscribe(receiver: feature.myMessageReceiver) +``` + +If you need multiple subscriptions from a single object without a natural `BusMessageReceiver` conformance, use the closure-based API and retain the handles (see `CrashContextCoreProvider` for the canonical pattern). + +### 4. Send the message from the producing feature + +```swift +core.messageBus.send(message: MyMessage(value: "hello"), else: { + // invoked if no subscriber is registered +}) +``` + +### 5. Write tests + +- Subscribe to `PassthroughCoreMock.messageBus` in unit tests. +- Use `core.messageBus.send(message:)` to drive receivers in isolation. +- Assert side effects via the receiver's internal state or the core mock's recorded events. + +See `DatadogInternal/Tests/MessageBus/MessageBusTests.swift` for bus-level tests and `DatadogCrashReporting/Tests/CrashContextCoreProviderTests.swift` for a feature-level example. + +## Threading + +All delivery runs on the bus's internal serial queue (`com.datadoghq.ios-sdk-message-bus`, QoS `.utility`). Receivers must not block — doing so delays every other subscriber. Move work off the queue immediately if it requires significant computation. + +`send` and `subscribe`/`unsubscribe` are safe to call from any thread. + +## Subscription Lifetime and Retain Semantics + +- `subscribe(receiver:)` — the bus **strongly retains** `receiver`. Call `unsubscribe(receiver:)` at teardown, or the receiver (and anything it captures) will leak. +- `subscribe(block:)` — the bus retains the internal wrapper. The caller owns the `MessageBusSubscription`; dropping it without calling `unsubscribe(_:)` leaks the subscription. +- Features must **not** retain the `core` reference passed to `receive(message:from:)` — use it transiently within the call.