Skip to content

Commit 163a6a9

Browse files
committed
[chain-pr 10/10] Documentation updates
1 parent 23d31d9 commit 163a6a9

3 files changed

Lines changed: 209 additions & 25 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ docs/
2727
│ RFC process, build & test quick reference
2828
├── TESTING.md ← Test conventions, mock infrastructure (.mockAny(),
2929
│ .mockRandom(), .mockWith()), DatadogCoreProxy usage
30+
├── MESSAGE_BUS.md ← Typed pub/sub bus: subscription patterns, all message types,
31+
│ how to add a new message
3032
├── KNOWN_CONCERNS.md ← Fragile areas requiring extra caution
3133
├── SWIZZLING.md ← Mandatory swizzling patterns and real incidents
3234
├── LLM_FEATURE_DOCS_GUIDELINES.md ← How to update *_FEATURE.md files
@@ -46,6 +48,7 @@ Feature-specific docs (in each module directory):
4648
| Add a new feature, command, or provider | `docs/DEVELOPMENT.md` |
4749
| Write or fix tests | `docs/TESTING.md` |
4850
| Check naming, lint, commit format | `docs/CONVENTIONS.md` |
51+
| Send or receive messages between features | `docs/MESSAGE_BUS.md` |
4952
| Touch swizzling code | `docs/SWIZZLING.md` |
5053
| Modify a fragile area | `docs/KNOWN_CONCERNS.md` |
5154
| Work on RUM specifically | `DatadogRUM/RUM_FEATURE.md` |

docs/ARCHITECTURE.md

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -106,59 +106,62 @@ DataUploadWorker (periodic) → DataReader → RequestBuilder → HTTPClient →
106106

107107
## Message Bus
108108

109-
### Message Types
109+
> **Current design:** See `docs/MESSAGE_BUS.md` — typed `BusMessage` protocol, all supported messages, subscription patterns, and how to add a new message.
110110
111-
Inter-feature communication uses `FeatureMessage` (defined in `DatadogInternal/Sources/MessageBus/FeatureMessage.swift`):
111+
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.
112+
113+
### ~~Message Types~~ (deprecated)
114+
115+
~~Inter-feature communication uses `FeatureMessage` (defined in `DatadogInternal/Sources/MessageBus/FeatureMessage.swift`):~~
112116

113117
| Case | When to use |
114118
|------|------------|
115-
| `.context(DatadogContext)` | **Shared state that changes over time.** Broadcast automatically on every context update. Receivers extract what they need from `DatadogContext.additionalContext`. |
116-
| `.payload(Any)` | **One-off events or commands.** Sender explicitly calls `core.send(message: .payload(...))`. Receiver downcasts to the expected type. |
117-
| `.webview(WebViewMessage)` | Browser SDK events from the JS bridge (logs, RUM, telemetry, session replay records). |
118-
| `.telemetry(TelemetryMessage)` | SDK internal telemetry (debug, error, configuration, metric, usage). |
119+
| ~~`.context(DatadogContext)`~~ | ~~**Shared state that changes over time.** Broadcast automatically on every context update. Receivers extract what they need from `DatadogContext.additionalContext`.~~ |
120+
| ~~`.payload(Any)`~~ | ~~**One-off events or commands.** Sender explicitly calls `core.send(message: .payload(...))`. Receiver downcasts to the expected type.~~ |
121+
| ~~`.webview(WebViewMessage)`~~ | ~~Browser SDK events from the JS bridge (logs, RUM, telemetry, session replay records).~~ |
122+
| ~~`.telemetry(TelemetryMessage)`~~ | ~~SDK internal telemetry (debug, error, configuration, metric, usage).~~ |
119123

120-
### `.context` Pattern — Reading Shared State
124+
**Replacement:** send a concrete `BusMessage` type directly via `core.messageBus.send(message:else:)`.
121125

122-
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.
126+
### ~~`.context` Pattern~~ (deprecated)
123127

124-
**How it works:**
125-
1. A feature sets its context via `featureScope.set(context: { RUMCoreContext(...) })` — this updates `DatadogContext.additionalContext`.
126-
2. Any context change triggers `DatadogCore` to broadcast `.context(datadogContext)` to every registered feature.
127-
3. Receivers extract what they need: `context.additionalContext(ofType: RUMCoreContext.self)`.
128+
~~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.~~
128129

129-
**Canonical example** — Session Replay's `RUMContextReceiver` (`DatadogSessionReplay/Sources/Feature/RUMContextReceiver.swift`):
130+
**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.
130131

131132
```swift
133+
// Before (deprecated)
132134
func receive(message: FeatureMessage, from core: DatadogCoreProtocol) -> Bool {
133135
guard case let .context(context) = message else { return false }
134136
let new = context.additionalContext(ofType: RUMCoreContext.self)
135137
if new != previous { onNew?(new); previous = new }
136138
return true
137139
}
138-
```
139140

140-
Other `.context` receivers: Trace's `ContextMessageReceiver`, `NetworkContextCoreProvider`, `CrashContextCoreProvider`, `WatchdogTerminationMonitor`, `ContextSharingTransformer`.
141+
// After
142+
func receive(message: DatadogContext, from core: DatadogCoreProtocol) {
143+
let new = message.additionalContext(ofType: RUMCoreContext.self)
144+
if new != previous { onNew?(new); previous = new }
145+
}
146+
```
141147

142-
### `.payload` Pattern — One-Off Events
148+
### ~~`.payload` Pattern~~ (deprecated)
143149

144-
Use this for discrete events that one feature sends and another consumes — crash reports, error forwarding, flag evaluations.
150+
~~Use this for discrete events that one feature sends and another consumes — crash reports, error forwarding, flag evaluations.~~
145151

146-
**Examples:**
147-
- `RemoteLogger` sends `.payload(RUMErrorMessage)` → RUM's `ErrorMessageReceiver` adds a RUM error
148-
- `CrashReportSender` sends `.payload(Crash)` → RUM's `CrashReportReceiver` writes crash error events
149-
- `FatalErrorContextNotifier` sends `.payload(RUMViewEvent)``CrashContextCoreProvider` persists the last view for crash reports
152+
**Replacement:** define a dedicated `BusMessage` struct for the payload type and subscribe via `BusMessageReceiver`.
150153

151-
### `CombinedFeatureMessageReceiver` — Ordering Matters
154+
### ~~`CombinedFeatureMessageReceiver`~~ (deprecated)
152155

153-
`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**.
156+
~~`CombinedFeatureMessageReceiver` uses `contains(where:)` — it short-circuits after the first receiver returns `true`.~~
154157

155-
Note: `MessageBus.send()` does NOT short-circuit across features — every registered feature receives every message.
158+
**Replacement:** all typed-bus subscribers receive every message independently — there is no short-circuiting.
156159

157160
### `RUMCoreContext`
158161

159162
Defined in `DatadogInternal/Sources/Models/RUM/RUMCoreContext.swift`. Key fields: `applicationID`, `sessionID`, `viewID`, `userActionID`, `viewServerTimeOffset`, `sessionSampler`. Conforms to `AdditionalContext` (key: `"rum"`) and `Equatable`.
160163

161-
Set by `Monitor.swift` after each command via `featureScope.set(context:)`. Consumed by any receiver that calls `context.additionalContext(ofType: RUMCoreContext.self)`.
164+
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.
162165

163166
## Error Handling Strategy
164167

docs/MESSAGE_BUS.md

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# Message Bus
2+
3+
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.
4+
5+
## Core Protocols
6+
7+
| Protocol | Role |
8+
|----------|------|
9+
| `BusMessage` | A value type (struct or enum) carried on the bus. Declares a stable `key`. |
10+
| `BusMessageReceiver` | A class-bound receiver for one `BusMessage` type. Subscribed by identity. |
11+
| `MessageBus` | The channel. Subscribe, unsubscribe, send. |
12+
| `MessageBusSubscription` | Opaque handle returned by the closure-based `subscribe(block:)` API. |
13+
14+
All types live in `DatadogInternal/Sources/MessageBus/MessageBus.swift`. The concrete implementation is `CoreMessageBus` in `DatadogCore/Sources/Core/CoreMessageBus.swift`.
15+
16+
## Subscription Patterns
17+
18+
### Receiver-based (long-lived objects)
19+
20+
Implement `BusMessageReceiver` when the subscriber already has a natural lifecycle (a `Feature`, an instrumentation component). The bus retains the receiver until `unsubscribe` is called.
21+
22+
```swift
23+
final class MyReceiver: BusMessageReceiver {
24+
typealias Message = RUMSessionState
25+
26+
func receive(message: RUMSessionState, from core: DatadogCoreProtocol) {
27+
// handle on the bus's serial queue — do not block
28+
}
29+
}
30+
31+
let receiver = MyReceiver()
32+
core.messageBus.subscribe(receiver: receiver)
33+
// ...
34+
core.messageBus.unsubscribe(receiver: receiver)
35+
```
36+
37+
Subscribe at feature enable time, typically in the module's `enable(with:in:)` function:
38+
39+
```swift
40+
// DatadogRUM/Sources/RUM.swift
41+
core.messageBus.subscribe(receiver: rum.crashReportReceiver)
42+
core.messageBus.subscribe(receiver: rum.telemetryReceiver)
43+
```
44+
45+
### Closure-based (ad-hoc subscriptions)
46+
47+
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(_:)`.
48+
49+
```swift
50+
var subscriptions: [MessageBusSubscription] = []
51+
52+
subscriptions += [
53+
bus.subscribe { [weak self] (message: RUMViewEvent, _) in
54+
self?.update(viewEvent: message)
55+
},
56+
bus.subscribe { [weak self] (_: RUMViewReset, _) in
57+
self?.clearViewEvent()
58+
},
59+
]
60+
61+
// cancel all at teardown
62+
subscriptions.forEach { bus.unsubscribe($0) }
63+
```
64+
65+
`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`.
66+
67+
## Sending Messages
68+
69+
```swift
70+
// Fire-and-forget — no fallback needed
71+
core.messageBus.send(message: RUMViewReset())
72+
73+
// With a fallback when no subscriber is registered
74+
core.messageBus.send(message: WebViewLogMessage(event: event), else: {
75+
DD.logger.warn("A WebView log is lost because Logging is disabled in the SDK")
76+
})
77+
```
78+
79+
`send` is asynchronous — it dispatches on the bus's serial queue. Do not assume the message is delivered by the time `send` returns.
80+
81+
## Supported Messages
82+
83+
The table below lists every `BusMessage` type registered across the SDK.
84+
85+
| Type | Key | Sent by | Consumed by |
86+
|------|-----|---------|-------------|
87+
| `DatadogContext` | `"core.context"` | `DatadogCore` (on every context update) | `ContextSharingTransformer`, `NetworkContextCoreProvider`, `WatchdogTerminationMonitor`, `RUMContextReceiver` (SR), `ContextMessageReceiver` (Trace), `CrashContextCoreProvider` |
88+
| `TelemetryMessage` | `"telemetry"` | Any feature via `core.telemetry.*` | `TelemetryReceiver` (RUM) |
89+
| `LogMessage` | `"log-message"` | `TracingWithLoggingIntegration` (Trace) | `LogMessageReceiver` (Logs) |
90+
| `LogEventAttributes` | `"log-event-attributes"` | `Logs.enable` (shared global attributes) | `CrashContextCoreProvider` |
91+
| `Crash` | `"crash-report"` | `CrashReportSender` (CrashReporting) | `CrashReportReceiver` (RUM) |
92+
| `RUMViewEvent` | `"rum-view-event"` | `FatalErrorContextNotifier` (RUM) | `CrashContextCoreProvider` |
93+
| `RUMEventAttributes` | `"rum-event-attributes"` | `FatalErrorContextNotifier` (RUM) | `CrashContextCoreProvider` |
94+
| `RUMViewReset` | `"rum-view-reset"` | `FatalErrorContextNotifier` (RUM) | `CrashContextCoreProvider` |
95+
| `RUMSessionState` | `"rum-session-state"` | `FatalErrorContextNotifier` (RUM) | `CrashContextCoreProvider` |
96+
| `RUMErrorMessage` | `"rum-error"` | `RemoteLogger` (Logs) | `ErrorMessageReceiver` (RUM) |
97+
| `RUMFlagEvaluationMessage` | `"rum-flag-evaluation"` | `RUMFlagEvaluationReporter` (Flags) | `FlagEvaluationReceiver` (RUM) |
98+
| `WebViewLogMessage` | `"webview-log"` | `MessageEmitter` (WebViewTracking) | `WebViewLogReceiver` (Logs) |
99+
| `WebViewRUMMessage` | `"webview-rum"` | `MessageEmitter` (WebViewTracking) | `WebViewEventReceiver` (RUM) |
100+
| `WebViewRecordMessage` | `"webview-record"` | `MessageEmitter` (WebViewTracking) | `WebViewRecordReceiver` (SR) |
101+
102+
### `TelemetryMessage` — special dispatch
103+
104+
`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.
105+
106+
## How to Add a New Message
107+
108+
### 1. Define the message type in `DatadogInternal`
109+
110+
Messages live in `DatadogInternal/Sources/Models/` alongside the domain they belong to. Prefer immutable value types.
111+
112+
```swift
113+
// DatadogInternal/Sources/Models/MyFeature/MyMessage.swift
114+
public struct MyMessage: BusMessage {
115+
public static let key = "my-feature.my-message" // globally unique, namespaced
116+
117+
public let value: String
118+
119+
public init(value: String) {
120+
self.value = value
121+
}
122+
}
123+
```
124+
125+
Rules for `key`:
126+
- Must be **globally unique** across the SDK — check the table above before choosing.
127+
- Use `"<module>.<purpose>"` format (e.g. `"rum-session-state"`, `"webview-log"`).
128+
- Treat it as **immutable** after the first release — downstream tooling and crash-context serialization may depend on it.
129+
130+
Add the new file to the `DatadogInternal` Xcode target via the `xcode-file-management` skill.
131+
132+
### 2. Implement a receiver in the consuming feature
133+
134+
```swift
135+
// DatadogMyFeature/Sources/Feature/MyMessageReceiver.swift
136+
internal final class MyMessageReceiver: BusMessageReceiver {
137+
func receive(message: MyMessage, from core: DatadogCoreProtocol) {
138+
// called on the bus's serial queue — do not block
139+
}
140+
}
141+
```
142+
143+
### 3. Subscribe at feature enable time
144+
145+
```swift
146+
// DatadogMyFeature/Sources/MyFeature.swift
147+
core.messageBus.subscribe(receiver: feature.myMessageReceiver)
148+
```
149+
150+
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).
151+
152+
### 4. Send the message from the producing feature
153+
154+
```swift
155+
core.messageBus.send(message: MyMessage(value: "hello"), else: {
156+
// invoked if no subscriber is registered
157+
})
158+
```
159+
160+
### 5. Write tests
161+
162+
- Subscribe to `PassthroughCoreMock.messageBus` in unit tests.
163+
- Use `core.messageBus.send(message:)` to drive receivers in isolation.
164+
- Assert side effects via the receiver's internal state or the core mock's recorded events.
165+
166+
See `DatadogInternal/Tests/MessageBus/MessageBusTests.swift` for bus-level tests and `DatadogCrashReporting/Tests/CrashContextCoreProviderTests.swift` for a feature-level example.
167+
168+
## Threading
169+
170+
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.
171+
172+
`send` and `subscribe`/`unsubscribe` are safe to call from any thread.
173+
174+
## Subscription Lifetime and Retain Semantics
175+
176+
- `subscribe(receiver:)` — the bus **strongly retains** `receiver`. Call `unsubscribe(receiver:)` at teardown, or the receiver (and anything it captures) will leak.
177+
- `subscribe(block:)` — the bus retains the internal wrapper. The caller owns the `MessageBusSubscription`; dropping it without calling `unsubscribe(_:)` leaks the subscription.
178+
- Features must **not** retain the `core` reference passed to `receive(message:from:)` — use it transiently within the call.

0 commit comments

Comments
 (0)