Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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` |
Expand Down
53 changes: 28 additions & 25 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
178 changes: 178 additions & 0 deletions docs/MESSAGE_BUS.md
Original file line number Diff line number Diff line change
@@ -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`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Point contributors at the wired bus implementation

In this branch there is no DatadogCore/Sources/Core/CoreMessageBus.swift (repo-wide rg "CoreMessageBus" finds only this doc), and DatadogCore still owns DatadogCore/Sources/Core/MessageBus.swift, which dispatches legacy FeatureMessage; DatadogCoreProtocol.messageBus currently falls back to NOPMessageBus() with no concrete override. Following this new reference would make feature authors subscribe/send through an unwired no-op typed bus instead of the implementation that actually delivers messages in this commit.

Useful? React with 👍 / 👎.


## 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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the actual bus message type name

The public message type in DatadogInternal/Sources/Models/RUM/RUMPayloadMessages.swift is RUMViewResetMessage, not RUMViewReset (confirmed with repo-wide rg "RUMViewReset"). The sending example as written will not compile for anyone following the new guide, and the same wrong name is repeated in the supported-message table.

Useful? React with 👍 / 👎.


// 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) |
Comment on lines +87 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Correct the documented message keys

These keys do not match the source: DatadogContext.key is "core-context" in DatadogInternal/Sources/Context/DatadogContext.swift, and LogMessage.key is "log" in DatadogInternal/Sources/Models/Logs/LogMessage.swift. Since this table is the new uniqueness reference for adding bus messages, the wrong values can lead contributors to choose colliding or incompatible keys.

Useful? React with 👍 / 👎.

| `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 `"<module>.<purpose>"` 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.