Events and Observables
WebDriver BiDi is an event-driven protocol. This guide explains how to work with events and the observable pattern in WebDriverBiDi.NET.
Overview
Browser events allow you to react to things happening in the browser in real-time:
- Navigation events (page loads, redirects)
- Network events (requests, responses)
- Console log messages
- Browsing context creation/destruction
- And more...
The Two-Step Subscription Process
Receiving an event takes two steps:
- Add an observer or data collector to handle or accumulate the event
- Subscribe to the event through the Session module
After that, events are delivered as they occur; wait for them with the capture API or a data collector if your code needs to synchronize on them.
Important: The two steps are separate by design to prevent race conditions. Adding an observer or data collector registers your handler locally; subscribing tells the browser to send events. The recommended order is to add observers/collectors first (step 1), then subscribe (step 2).
Complete Example
// Step 1: Add observer
driver.Log.OnEntryAdded.AddObserver((EntryAddedEventArgs e) =>
{
Console.WriteLine($"Console: {e.Text}");
});
// Step 2: Subscribe to events
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.Log.OnEntryAdded.EventName);
await driver.Session.SubscribeAsync(subscribe);
// Step 3: Events will now trigger your observer
await driver.BrowsingContext.NavigateAsync(
new NavigateCommandParameters(contextId, "https://example.com"));
Observable Events
Each event is exposed as an ObservableEvent<TEventArgs> property on the relevant module.
Available Events by Module
BrowsingContext Module
_ = driver.BrowsingContext.OnLoad;
_ = driver.BrowsingContext.OnDomContentLoaded;
_ = driver.BrowsingContext.OnNavigationStarted;
_ = driver.BrowsingContext.OnNavigationCommitted;
_ = driver.BrowsingContext.OnNavigationAborted;
_ = driver.BrowsingContext.OnNavigationFailed;
_ = driver.BrowsingContext.OnFragmentNavigated;
_ = driver.BrowsingContext.OnHistoryUpdated;
_ = driver.BrowsingContext.OnDownloadWillBegin;
_ = driver.BrowsingContext.OnDownloadEnd;
_ = driver.BrowsingContext.OnContextCreated;
_ = driver.BrowsingContext.OnContextDestroyed;
_ = driver.BrowsingContext.OnUserPromptOpened;
_ = driver.BrowsingContext.OnUserPromptClosed;
driver.BrowsingContext.OnLoad // Page load complete
driver.BrowsingContext.OnDomContentLoaded // DOM ready
driver.BrowsingContext.OnNavigationStarted // Navigation begins
driver.BrowsingContext.OnNavigationCommitted // Navigation committed
driver.BrowsingContext.OnNavigationAborted // Navigation cancelled
driver.BrowsingContext.OnNavigationFailed // Navigation error
driver.BrowsingContext.OnFragmentNavigated // Hash navigation
driver.BrowsingContext.OnHistoryUpdated // History entry updated
driver.BrowsingContext.OnDownloadWillBegin // Download about to begin
driver.BrowsingContext.OnDownloadEnd // Download completed
driver.BrowsingContext.OnContextCreated // New tab/window/iframe
driver.BrowsingContext.OnContextDestroyed // Tab/window closed
driver.BrowsingContext.OnUserPromptOpened // Alert/confirm/prompt
driver.BrowsingContext.OnUserPromptClosed // Dialog closed
Network Module
_ = driver.Network.OnBeforeRequestSent;
_ = driver.Network.OnResponseStarted;
_ = driver.Network.OnResponseCompleted;
_ = driver.Network.OnFetchError;
_ = driver.Network.OnAuthRequired;
driver.Network.OnBeforeRequestSent // Request about to be sent
driver.Network.OnResponseStarted // Response headers received
driver.Network.OnResponseCompleted // Response fully received
driver.Network.OnFetchError // Network error occurred
driver.Network.OnAuthRequired // Authentication needed
Log Module
_ = driver.Log.OnEntryAdded;
driver.Log.OnEntryAdded // Console log message
Script Module
_ = driver.Script.OnMessage;
_ = driver.Script.OnRealmCreated;
_ = driver.Script.OnRealmDestroyed;
driver.Script.OnMessage // Message from preload script
driver.Script.OnRealmCreated // New execution realm
driver.Script.OnRealmDestroyed // Realm destroyed
Input Module
_ = driver.Input.OnFileDialogOpened;
driver.Input.OnFileDialogOpened // File selection dialog opened
Speculation Module
_ = driver.Speculation.OnPrefetchStatusUpdated;
driver.Speculation.OnPrefetchStatusUpdated // Prefetch status of a resource updated
Bluetooth Module
_ = driver.Bluetooth.OnCharacteristicEventGenerated;
_ = driver.Bluetooth.OnDescriptorEventGenerated;
_ = driver.Bluetooth.OnGattConnectionAttempted;
_ = driver.Bluetooth.OnRequestDevicePromptUpdated;
driver.Bluetooth.OnCharacteristicEventGenerated // GATT characteristic event generated
driver.Bluetooth.OnDescriptorEventGenerated // GATT descriptor event generated
driver.Bluetooth.OnGattConnectionAttempted // GATT connection attempted
driver.Bluetooth.OnRequestDevicePromptUpdated // Device request prompt updated
Modules Without Observable Events
The remaining modules define commands only, and expose no observable events: Browser,
DigitalCredentials, Emulation, Permissions, Session, Storage, UserAgentClientHints,
and WebExtension. Together with the seven modules listed above, this accounts for all 29
observable events in the library.
Driver-Level Observable Events
In addition to the module events above, BiDiDriver exposes six observable events that reflect the
library's own communication layer. These events do not correspond to WebDriver BiDi protocol events
and do not require a session.SubscribeAsync call — they fire whenever the transport or driver
itself raises the underlying condition.
// These events do not require session.SubscribeAsync — they are library-internal signals
_ = driver.OnEventReceived; // Every protocol event, after module dispatch
_ = driver.OnUnexpectedErrorReceived; // Error with no matching pending command
_ = driver.OnUnknownMessageReceived; // Message that did not match any protocol structure
_ = driver.OnEventHandlerErrorOccurred; // An observer threw an exception
_ = driver.OnLogMessage; // A library-internal log message
_ = driver.OnConnectionLost; // The connection ended without StopAsync
These events are also available through the IBiDiDriverEvents interface, which means they can be
observed on any object that implements the interface.
OnEventReceived
Fires once for every protocol event message that the transport delivers to the driver, after the
event has been dispatched to the relevant module observers. The two stages are independent: a fault in a
module observer does not suppress this event, and faults from both stages are surfaced together, each
governed by EventHandlerExceptionBehavior exactly as it would be in isolation. This is useful for
protocol-level logging, auditing, or routing custom module events.
driver.OnEventReceived.AddObserver((EventReceivedEventArgs e) =>
{
Console.WriteLine($"Event received: {e.EventName}");
Console.WriteLine($"Event data type: {e.EventData?.GetType().Name ?? "null"}");
});
// No session.SubscribeAsync needed — fires for every protocol event the transport delivers
The EventName property contains the full protocol event name (e.g., "log.entryAdded"). EventData
contains the deserialized event payload, whose concrete type depends on the event (for example,
log.entryAdded carries a LogEntry). Only events whose name has been registered with the driver — the
built-in module events, plus any custom events registered via RegisterEvent before StartAsync — reach
this observable. An event message the driver does not recognize is never delivered here; it is routed to
OnUnknownMessageReceived and counted under UnknownMessageBehavior.
OnUnexpectedErrorReceived
Fires when the browser sends an error response that does not correspond to any pending command — for
example, a spontaneous error message. This is distinct from command errors, which are surfaced as
exceptions from ExecuteCommandAsync.
driver.OnUnexpectedErrorReceived.AddObserver((ErrorReceivedEventArgs e) =>
{
Console.WriteLine($"Unexpected error: {e.ErrorData.ErrorCode} — {e.ErrorData.ErrorMessage}");
if (e.ErrorData.StackTrace is string stackTrace)
{
Console.WriteLine($"Stack trace: {stackTrace}");
}
});
The ErrorData property is an ErrorResult containing ErrorCode, ErrorMessage, and an optional
StackTrace. Whether this event causes the driver to throw depends on UnexpectedErrorBehavior
(default: TransportErrorBehavior.Ignore).
OnUnknownMessageReceived
Fires when the transport receives a message it does not recognize: one that is not valid JSON, or one that
is neither a response to a command the driver sent, an error response, nor a registered event. The raw
message text is available in Message.
driver.OnUnknownMessageReceived.AddObserver((UnknownMessageReceivedEventArgs e) =>
{
Console.WriteLine($"Unknown message: {e.Message}");
});
Whether this event causes the driver to throw depends on UnknownMessageBehavior (default:
TransportErrorBehavior.Ignore).
OnEventHandlerErrorOccurred
Fires when an exception is thrown inside any observer registered on any ObservableEvent<T> in the
driver — including module-level events. This is a cross-cutting diagnostic hook; it fires in addition to
(not instead of) the normal error-behavior flow controlled by EventHandlerExceptionBehavior.
driver.OnEventHandlerErrorOccurred.AddObserver((EventHandlerErrorOccurredEventArgs e) =>
{
EventObserverErrorInfo info = e.ErrorInfo;
Console.WriteLine($"Observer error on event: {info.ObservableEventName}");
Console.WriteLine($"Observer ID: {info.ObserverId}");
Console.WriteLine($"Observer description: {info.ObserverDescription}");
Console.WriteLine($"Async handler: {info.IsAsynchronousHandler}");
Console.WriteLine($"Faulted after return: {info.FaultOccurredAfterHandlerReturned}");
Console.WriteLine($"Exception: {info.Exception.Message}");
});
The ErrorInfo property is an EventObserverErrorInfo record with the following fields:
| Property | Description |
|---|---|
ObservableEventName |
The name of the event the faulted observer was added to (e.g., "log.entryAdded", "driver.unknownMessageReceived") |
ObserverId |
The Id of the observer that faulted, as returned by AddObserver |
ObserverDescription |
The Description of the observer that faulted |
Exception |
The exception thrown by the observer; each failing observer is reported separately |
IsAsynchronousHandler |
true if the observer was registered with RunHandlerAsynchronously |
FaultOccurredAfterHandlerReturned |
true for async handlers, whose failure always surfaces after their Task was returned |
OnLogMessage
Fires when the library itself emits a diagnostic log message. These are library-internal messages (e.g., "connecting to transport", "disposing driver"), not browser console messages. Use this event to route library diagnostics to your own logging infrastructure.
driver.OnLogMessage.AddObserver((LogMessageEventArgs e) =>
{
// Level is WebDriverBiDiLogLevel: Trace, Debug, Info, Warn, Error, Fatal
// ComponentName identifies which part of the library emitted the message
if (e.Level >= WebDriverBiDiLogLevel.Warn)
{
Console.WriteLine($"[{e.Timestamp:HH:mm:ss}] [{e.Level}] [{e.ComponentName}] {e.Message}");
}
});
// Note: this is the library's internal log, not browser console messages.
// For browser console messages, use driver.Log.OnEntryAdded instead.
Level is a WebDriverBiDiLogLevel value (Trace, Debug, Info, Warn, Error, Fatal). Only
messages at or above BiDiDriver.TransportConfiguration.LogLevel are raised; it defaults to Info, so Debug (a message
per command) and Trace (every message exchanged with the remote end) must be opted into. The enum's
Off member is never the level of a raised message: it exists to be assigned to LogLevel, where
it suppresses everything.
ComponentName identifies the part of the library that emitted the message: "BiDiDriver", "Transport" or
"Connection", the LoggerComponentName constant of the emitting type. Timestamp is set to
DateTime.UtcNow at the time the message was created.
Note: For browser console log messages, use
driver.Log.OnEntryAdded(a module-level event that requiressession.SubscribeAsync).OnLogMessageis for library diagnostics only.
OnConnectionLost
Fires when an established connection ends without the driver being stopped: the browser closed it, as it does when it exits, or the connection failed. Without it, a browser that quits is noticed only when the next command fails.
driver.OnConnectionLost.AddObserver((ConnectionLostEventArgs e) =>
{
// For example, "Remote end closed the connection" when the browser exits
Console.WriteLine($"Connection lost: {e.Exception.Message}");
});
// No session.SubscribeAsync needed, and StopAsync does not raise it
The event is raised by the loop that delivers events, after every event received before the loss, once the
session has been torn down: IsStarted is false and commands in flight have failed with
WebDriverBiDiConnectionException. Like any observer run on that loop, an observer of it can call StopAsync,
but calling StartAsync from it waits for the loop to finish, for at most Transport.ShutdownTimeout;
reconnect from outside the observer, or from one added with ObservableEventHandlerOptions.RunHandlerAsynchronously. The
Exception property is a WebDriverBiDiConnectionException whose message says whether the remote end closed
the connection or the connection failed; for a failure, its InnerException is the connection's error.
StopAsync does not raise it, and neither does a connection lost while StartAsync is still connecting,
which fails StartAsync instead. See Recovering From a Remote Disconnect
for reconnecting afterwards.
Event Names
Each observable event has an EventName property with the protocol event name:
Console.WriteLine(driver.Log.OnEntryAdded.EventName);
// Output: "log.entryAdded"
Console.WriteLine(driver.Network.OnBeforeRequestSent.EventName);
// Output: "network.beforeRequestSent"
Counting Observers
ObservableEvent<T>.CurrentObserverCount reports how many observers are currently attached. It is most
useful when deciding whether a Session.UnsubscribeAsync is safe — an event with observers still attached
should stay subscribed — and when asserting in tests that an observer was disposed. A data collector counts
as one observer.
Collecting Event Data
EventDataCollector<T> is an alternative to observers for scenarios where you want to accumulate event data and examine it at a convenient point, rather than reacting to each event as it arrives. It is created from any ObservableEvent<T> via AddDataCollector() and counts as one observer.
When to Use a Data Collector
Use a data collector instead of an observer when:
- You want to inspect results after an operation rather than reacting event-by-event
- You are writing test assertions against events that occurred during a step
- You need a per-step snapshot: drain before an action, do the action, drain again — each drain contains only that step's events
- You don't need to run code on every individual event — just need the list when you're done
Use an observer when you need to react immediately to each event (e.g., intercept a network request, log to a stream, abort on an error condition).
Basic Usage
// Create a data collector — no handler code required
await using EventDataCollector<BeforeRequestSentEventArgs> collector =
driver.Network.OnBeforeRequestSent.AddDataCollector();
// Subscribe and trigger events
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.Network.OnBeforeRequestSent.EventName);
await driver.Session.SubscribeAsync(subscribe);
await driver.BrowsingContext.NavigateAsync(navParams);
// Drain all events collected since the last call (empties the queue)
IReadOnlyList<BeforeRequestSentEventArgs> requests = collector.GetCollectedEventData();
Console.WriteLine($"Captured {requests.Count} requests");
foreach (BeforeRequestSentEventArgs e in requests)
{
Console.WriteLine($" {e.Request.Method} {e.Request.Url}");
}
Drain and Reset Pattern
GetCollectedEventData() atomically drains the internal queue and returns all accumulated events as a read-only list. Events that arrive after the call will appear in the next drain. This makes it straightforward to isolate events from individual steps:
await using EventDataCollector<BeforeRequestSentEventArgs> collector =
driver.Network.OnBeforeRequestSent.AddDataCollector();
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.Network.OnBeforeRequestSent.EventName);
await driver.Session.SubscribeAsync(subscribe);
// First navigation
await driver.BrowsingContext.NavigateAsync(nav1);
// GetCollectedEventData drains and resets — requests1 contains only page-1 requests
IReadOnlyList<BeforeRequestSentEventArgs> requests1 = collector.GetCollectedEventData();
// Second navigation
await driver.BrowsingContext.NavigateAsync(nav2);
// requests2 contains only page-2 requests
IReadOnlyList<BeforeRequestSentEventArgs> requests2 = collector.GetCollectedEventData();
Console.WriteLine($"Page 1: {requests1.Count} requests, Page 2: {requests2.Count} requests");
Filtering Collected Events
Pass a predicate to AddDataCollector to discard events at collection time rather than filtering the
drained list after the fact. Only events for which the predicate returns true are enqueued; all others
are silently dropped and never appear in GetCollectedEventData().
// Only accumulate responses whose URL contains "api.example.com"
await using EventDataCollector<ResponseCompletedEventArgs> collector =
driver.Network.OnResponseCompleted.AddDataCollector(
filter: e => e.Response.Url.Contains("api.example.com"));
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.Network.OnResponseCompleted.EventName);
await driver.Session.SubscribeAsync(subscribe);
await driver.BrowsingContext.NavigateAsync(navParams);
// Only API responses are in the list — all others were discarded at collection time
IReadOnlyList<ResponseCompletedEventArgs> apiResponses = collector.GetCollectedEventData();
Console.WriteLine($"API responses: {apiResponses.Count}");
Filtering at the collector level keeps the queue small and eliminates the need for a post-drain Where
call on the result. If no filter is provided the collector behaves as before, accumulating every event.
Cleanup
EventDataCollector<T> implements IDisposable and IAsyncDisposable. Use await using to ensure the collector is automatically removed from the event when the scope exits:
// await using ensures the collector is removed from the event when the scope exits
await using EventDataCollector<EntryAddedEventArgs> collector =
driver.Log.OnEntryAdded.AddDataCollector(description: "my log collector");
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.Log.OnEntryAdded.EventName);
await driver.Session.SubscribeAsync(subscribe);
await driver.BrowsingContext.NavigateAsync(navParams);
IReadOnlyList<EntryAddedEventArgs> entries = collector.GetCollectedEventData();
Console.WriteLine($"Captured {entries.Count} log entries");
// Collector automatically unregisters here — no memory leak
Always dispose the collector when you no longer need it. A collector that is never disposed continues to receive and queue events, which is a memory leak.
Streaming Events
EventDataCollector<T> also exposes an Events property of type IAsyncEnumerable<T>. This lets you consume events one at a time via await foreach rather than draining the buffer in a single call:
await using EventDataCollector<EntryAddedEventArgs> collector =
driver.Log.OnEntryAdded.AddDataCollector();
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.Log.OnEntryAdded.EventName);
await driver.Session.SubscribeAsync(subscribe);
await driver.BrowsingContext.NavigateAsync(navParams);
// Stream each log entry as it is buffered. Disposing the collector ends the loop; canceling the
// token instead throws OperationCanceledException out of the enumerator, so catch it where the
// cancellation is expected.
await foreach (EntryAddedEventArgs entry in
collector.Events.WithCancellation(cancellationToken))
{
Console.WriteLine($"[{entry.Level}] {entry.Text}");
}
Use break to exit the loop early when a condition is met:
await using EventDataCollector<EntryAddedEventArgs> collector =
driver.Log.OnEntryAdded.AddDataCollector();
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.Log.OnEntryAdded.EventName);
await driver.Session.SubscribeAsync(subscribe);
await driver.BrowsingContext.NavigateAsync(navParams);
// Collect entries until an entry at Error level arrives, then stop
await foreach (EntryAddedEventArgs entry in collector.Events)
{
Console.WriteLine($"[{entry.Level}] {entry.Text}");
if (entry.Level == LogLevel.Error)
{
break;
}
}
A few things to be aware of when using Events:
EventsandGetCollectedEventData()share the same buffer. Consuming an item via one removes it from the other. Use one reading approach at a time per collector.- The sequence ends when the collector is disposed. An active
await foreachwill drain any remaining buffered items and then exit cleanly whenDisposeorDisposeAsyncis called. - The filter predicate applies to the stream. Events that do not satisfy the predicate are never buffered, so they never appear in
Eventseither. await foreachblocks the current async method while waiting for the next event. If you need to both stream events and do other work concurrently, drive theawait foreachfrom a separateTask.
Data Collector vs Observer
// Observer: react to each event immediately as it arrives
driver.Log.OnEntryAdded.AddObserver((EntryAddedEventArgs e) =>
{
if (e.Level == LogLevel.Error)
{
Console.WriteLine($"ERROR: {e.Text}");
}
});
// Data collector: accumulate events and inspect on demand
await using EventDataCollector<BeforeRequestSentEventArgs> collector =
driver.Network.OnBeforeRequestSent.AddDataCollector();
SubscribeCommandParameters subscribe = new SubscribeCommandParameters(
[
driver.Log.OnEntryAdded.EventName,
driver.Network.OnBeforeRequestSent.EventName,
]);
await driver.Session.SubscribeAsync(subscribe);
await driver.BrowsingContext.NavigateAsync(navParams);
// Inspect collected network data at a convenient time
IReadOnlyList<BeforeRequestSentEventArgs> requests = collector.GetCollectedEventData();
Console.WriteLine($"Page made {requests.Count} network requests");
| Observer | Data Collector | |
|---|---|---|
| Runs code on each event | Yes — your handler runs immediately | No — events are queued |
| Access event data later | Only if you capture it yourself | Yes — GetCollectedEventData() |
| Per-step isolation | Manual (clear a list yourself) | Built-in (each drain is independent) |
| Built-in filtering | Manual (if-check inside handler) | Yes — predicate passed to AddDataCollector |
| Thread safety | Handler options control execution | Channel-based; always safe |
| Cleanup | Unobserve() / using / DisposeAsync() |
using / await using / DisposeAsync() |
IObservable<T> Support
Any ObservableEvent<T> can be adapted to IObservable<T> via the ToObservable() extension method. This enables integration with Reactive Extensions (Rx) operators and any code that consumes the standard BCL IObservable<T>/IObserver<T> interfaces.
Basic Usage
// ToObservable() wraps any ObservableEvent<T> as an IObservable<T>
IObservable<EntryAddedEventArgs> observable = driver.Log.OnEntryAdded.ToObservable();
// BCL IObservable<T>.Subscribe requires an IObserver<T> implementation.
// With System.Reactive installed, convenient lambda overloads are also available.
using IDisposable subscription = observable.Subscribe(new LogEntryObserver());
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.Log.OnEntryAdded.EventName);
await driver.Session.SubscribeAsync(subscribe);
await driver.BrowsingContext.NavigateAsync(navParams);
// Disposing subscription calls OnCompleted and removes it from the event
The BCL IObservable<T>.Subscribe method requires an IObserver<T> implementation. If you add the System.Reactive NuGet package, convenient lambda overloads and the full suite of Rx operators (.Where, .Take, .Buffer, .Throttle, etc.) become available:
// With System.Reactive installed, standard Rx operators become available.
// Without it, pass any IObserver<T> implementation to Subscribe directly.
IObservable<EntryAddedEventArgs> observable = driver.Log.OnEntryAdded.ToObservable();
// With System.Reactive:
// using IDisposable subscription = observable
// .Where(e => e.Level == LogLevel.Error)
// .Take(5)
// .Subscribe(e => Console.WriteLine($"Error: {e.Text}"));
// Without System.Reactive:
using IDisposable subscription = observable.Subscribe(new LogEntryObserver());
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.Log.OnEntryAdded.EventName);
await driver.Session.SubscribeAsync(subscribe);
await driver.BrowsingContext.NavigateAsync(navParams);
How It Works
Each call to Subscribe creates an independent EventDataCollector<T> on the source event. A background task drains that collector's channel and calls observer.OnNext for each item. When you dispose the handle returned by Subscribe, the collector is removed from the event, the channel completes, and observer.OnCompleted is called once the drain loop exits.
The handle is an ObservableEventSubscription<T> (the BCL Subscribe signature types it as IDisposable, so cast it to reach the extra member). Its CompletionTask completes once delivery has ended — after OnCompleted has returned following disposal, or after OnError has returned when OnNext threw — so you can await it after disposing to be certain the observer will receive no further calls before tearing down anything the observer uses:
IObservable<EntryAddedEventArgs> observable = driver.Log.OnEntryAdded.ToObservable();
// The BCL Subscribe signature returns IDisposable, so cast to reach CompletionTask.
ObservableEventSubscription<EntryAddedEventArgs> subscription =
(ObservableEventSubscription<EntryAddedEventArgs>)observable.Subscribe(new LogEntryObserver());
// ... receive events ...
subscription.Dispose();
// CompletionTask completes once OnCompleted has returned; the observer is now quiescent.
await subscription.CompletionTask;
Contract Notes
The adapter partially satisfies the Rx push-stream contract. Be aware of these differences from a fully conformant IObservable<T>:
OnCompletedis triggered by disposal, not by a natural stream end. BiDi events are an indefinite stream with no terminal signal from the browser. The sequence ends only when you dispose the subscription handle.OnErroris called only ifOnNextthrows. Exceptions thrown by other observers on the sameObservableEvent<T>do not flow throughOnError.- Each
Subscribecall counts as one observer againstObservableEvent<T>.MaxObserverCount. OnNextis called on a background thread. If your observer implementation is not thread-safe, synchronize access to shared state.
Adding Observers
Observers are functions that get called when an event occurs.
Simple Observer
driver.Log.OnEntryAdded.AddObserver((EntryAddedEventArgs e) =>
{
Console.WriteLine($"Level: {e.Level}");
Console.WriteLine($"Text: {e.Text}");
Console.WriteLine($"Timestamp: {e.Timestamp}");
});
Observer with Type Inference
driver.Log.OnEntryAdded.AddObserver((e) =>
{
// Type is inferred as EntryAddedEventArgs
Console.WriteLine(e.Text);
});
Async Observer
For long-running or async operations in handlers:
driver.Network.OnBeforeRequestSent.AddObserver(
async (BeforeRequestSentEventArgs e) =>
{
// Can use await
await LogRequestAsync(e.Request.Url);
await Task.Delay(100);
},
ObservableEventHandlerOptions.RunHandlerAsynchronously);
EventObserver Cleanup Pattern
AddObserver returns an EventObserver<T> that you should store when you need to:
- Remove the observer when it is no longer needed (
Unobserve()) - Use the capture API for synchronization (
StartCapturingTasks(),WaitForCapturedTasksAsync(),WaitForCapturedTasksCompleteAsync()) - Dispose resources when the observer goes out of scope
Always store the observer reference when you intend to remove it or use the capture API. Failing to remove observers when done can lead to memory leaks and handlers continuing to run after they are no longer needed.
Basic Cleanup with try/finally
EventObserver<EntryAddedEventArgs> observer =
driver.Log.OnEntryAdded.AddObserver((e) =>
{
Console.WriteLine(e.Text);
});
try
{
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.Log.OnEntryAdded.EventName);
await driver.Session.SubscribeAsync(subscribe);
// Use the driver...
await driver.BrowsingContext.NavigateAsync(navParams);
}
finally
{
// Remove observer when done to prevent memory leaks
observer.Unobserve();
}
Using Statement for Automatic Cleanup
EventObserver<T> implements IDisposable, so you can use using for automatic cleanup:
using EventObserver<EntryAddedEventArgs> observer =
driver.Log.OnEntryAdded.AddObserver((e) =>
{
Console.WriteLine(e.Text);
});
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.Log.OnEntryAdded.EventName);
await driver.Session.SubscribeAsync(subscribe);
// Use the driver...
await driver.BrowsingContext.NavigateAsync(navParams);
// Observer automatically removed when scope exits
Cleanup When Using the Capture API
When using the capture API, you must store the observer to call StartCapturingTasks(), WaitForCapturedTasksAsync(), or WaitForCapturedTasksCompleteAsync(). Clean up the observer when you are done:
EventObserver<NavigationEventArgs> observer =
driver.BrowsingContext.OnLoad.AddObserver((e) =>
{
Console.WriteLine($"Loaded: {e.Url}");
});
try
{
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.BrowsingContext.OnLoad.EventName);
await driver.Session.SubscribeAsync(subscribe);
observer.StartCapturingTasks();
await driver.BrowsingContext.NavigateAsync(navParams);
Task[] tasks = await observer.WaitForCapturedTasksAsync(1, TimeSpan.FromSeconds(30));
bool loaded = tasks.Length == 1;
observer.StopCapturingTasks();
}
finally
{
observer.Unobserve();
}
Unobserve vs Dispose
Unobserve() removes the observer from the event. Dispose() (and DisposeAsync()) does the same and also releases internal resources. For most scenarios, either is sufficient. Use Unobserve() when you only need to stop receiving events; use using with Dispose() when you want automatic cleanup at scope exit.
Subscribing to Events
Before events are sent by the browser, you must subscribe to them.
Basic Subscription
Prefer the EventName property from observable events to avoid typos and stay in sync with the API:
SubscribeCommandParameters subscribe = new SubscribeCommandParameters(
[
driver.Log.OnEntryAdded.EventName,
driver.Network.OnResponseCompleted.EventName,
]
);
SubscribeCommandResult result = await driver.Session.SubscribeAsync(subscribe);
Console.WriteLine($"Subscription ID: {result.SubscriptionId}");
Single Event Subscription
For a single event, use the constructor that accepts one event name:
SubscribeCommandParameters subscribe = new SubscribeCommandParameters(
driver.Log.OnEntryAdded.EventName);
await driver.Session.SubscribeAsync(subscribe);
Subscription Scope
You can limit subscriptions to specific contexts:
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.Network.OnBeforeRequestSent.EventName);
// Only receive events for this specific context
subscribe.Contexts.Add(contextId);
await driver.Session.SubscribeAsync(subscribe);
Unsubscribing
// Unsubscribe by subscription ID
UnsubscribeByIdsCommandParameters unsubscribe =
new UnsubscribeByIdsCommandParameters(subscriptionId);
await driver.Session.UnsubscribeAsync(unsubscribe);
// Or unsubscribe by event names
UnsubscribeByAttributesCommandParameters unsubscribe =
new UnsubscribeByAttributesCommandParameters(
[driver.Log.OnEntryAdded.EventName, driver.Network.OnResponseCompleted.EventName]);
await driver.Session.UnsubscribeAsync(unsubscribe);
Event Synchronization
The EventObserver<T> class provides a capture API for synchronizing with events.
Waiting for a Single Event
EventObserver<NavigationEventArgs> observer =
driver.BrowsingContext.OnLoad.AddObserver((e) =>
{
Console.WriteLine($"Loaded: {e.Url}");
});
// Start capturing events
observer.StartCapturingTasks();
// Trigger navigation
await driver.BrowsingContext.NavigateAsync(navParams);
// Wait up to 10 seconds for the event
Task[] tasks = await observer.WaitForCapturedTasksAsync(1, TimeSpan.FromSeconds(10));
bool eventOccurred = tasks.Length == 1;
if (eventOccurred)
{
Console.WriteLine("Page loaded!");
}
else
{
Console.WriteLine("Timeout waiting for page load");
}
observer.StopCapturingTasks();
Waiting for Multiple Events
EventObserver<ResponseCompletedEventArgs> observer =
driver.Network.OnResponseCompleted.AddObserver((e) =>
{
Console.WriteLine($"Response: {e.Response.Url}");
});
// Start capturing, then wait for 5 network responses
observer.StartCapturingTasks();
await driver.BrowsingContext.NavigateAsync(navParams);
// Wait for all 5 responses
Task[] tasks = await observer.WaitForCapturedTasksAsync(5, TimeSpan.FromSeconds(10));
bool allReceived = tasks.Length == 5;
Console.WriteLine($"Received all 5 responses: {allReceived}");
observer.StopCapturingTasks();
Restarting a Capture Session
EventObserver<EntryAddedEventArgs> observer =
driver.Log.OnEntryAdded.AddObserver((e) => { });
// First navigation
observer.StartCapturingTasks();
await driver.BrowsingContext.NavigateAsync(params1);
await observer.WaitForCapturedTasksAsync(3, TimeSpan.FromSeconds(5));
observer.StopCapturingTasks();
// Second navigation - start a fresh capture session
observer.StartCapturingTasks();
await driver.BrowsingContext.NavigateAsync(params2);
await observer.WaitForCapturedTasksAsync(2, TimeSpan.FromSeconds(5));
observer.StopCapturingTasks();
Capture API Thread Safety
Capture API methods are thread-safe. Concurrent calls to WaitForCapturedTasksAsync or GetCapturedTasks are
serialized internally — each caller gets a contiguous, non-interleaved slice of captured tasks.
StartCapturingTasks, StopCapturingTasks, and the observer's notification path are all safe to call from
any thread.
Note for async callers:
GetCapturedTasks()is a synchronous method that acquires an internal reader lock. If a concurrentWaitForCapturedTasksAsynccall holds the lock,GetCapturedTasks()will block the calling thread until it is released. In async contexts — particularly those with a single-threadedSynchronizationContext, such as WPF or legacy ASP.NET — preferWaitForCapturedTasksAsyncto avoid blocking the calling thread.
Only one capture session may be active at a time. Calling StartCapturingTasks when a session is already
active throws WebDriverBiDiException. EventObserver<T>.IsCapturing reports whether a session is open, so
helper code that may be called with or without one can check rather than catch.
Disposing the observer ends any active capture session. A WaitForCapturedTasksAsync or
WaitForCapturedTasksCompleteAsync call that is still waiting when the observer is disposed completes with
ObjectDisposedException rather than waiting out its timeout, and any capture method called after disposal
throws ObjectDisposedException. Unobserve(), StopCapturingTasks() and repeated disposal remain safe no-ops.
Async Event Handlers
When event handlers perform async operations or I/O, you must use asynchronous handler execution to avoid blocking the transport thread.
Observable Event Handler Options
The ObservableEventHandlerOptions enum controls how event handlers execute:
public enum ObservableEventHandlerOptions
{
RunHandlerSynchronously = 0, // Synchronous execution (default)
RunHandlerAsynchronously = 1 // Asynchronous execution
}
Synchronous Handlers (Default)
By default, event handlers run synchronously on the transport thread:
// Default behavior - runs synchronously
driver.Log.OnEntryAdded.AddObserver((e) =>
{
// This runs on the transport thread
// Blocks all message processing until complete
Console.WriteLine(e.Text);
});
Use When:
- Handler completes quickly (<10ms)
- Performing simple, in-memory operations (counters, collections)
- No I/O operations
- Order of execution matters
The Blocking Problem
// ❌ BAD: Handler blocks message processing
driver.Network.OnBeforeRequestSent.AddObserver((e) =>
{
// This blocks the Transport thread for 5 seconds!
Thread.Sleep(5000);
Console.WriteLine($"Request: {e.Request.Url}");
// During these 5 seconds:
// - No other events are processed
// - No responses are received
// - Commands may timeout
// - Browser may become unresponsive
});
Asynchronous Handlers
Use RunHandlerAsynchronously for I/O operations or long-running work:
// ✅ GOOD: Handler runs asynchronously
EventObserver<BeforeRequestSentEventArgs> observer =
driver.Network.OnBeforeRequestSent.AddObserver(
async (e) =>
{
// Doesn't block message processing
await Task.Delay(5000);
Console.WriteLine($"Request: {e.Request.Url}");
// During these 5 seconds:
// - Transport thread continues processing
// - Other events are handled normally
// - The continuation after 'await' runs on a thread pool thread.
// (The code *before* the first await still ran on the transport
// thread: the option detaches the returned Task, it does not
// move the start of the handler.)
},
ObservableEventHandlerOptions.RunHandlerAsynchronously
);
Use When:
- Handler performs I/O (file, network, database)
- Handler does CPU-intensive work
- You need to call driver commands from the handler
- Handler might take more than a few milliseconds
What the option does — and does not — do:
RunHandlerAsynchronously changes what the dispatcher does with the Task your handler returns: it stops awaiting it. It does not change where the handler starts. Every handler is invoked on the thread that is dispatching the event (the transport's message-processing thread), so:
- In an
asynclambda, everything up to the firstawaitthat does not complete synchronously still runs on the dispatching thread. Put theawaitbefore the heavy work (anawait Task.Yield();as the first statement is the simplest way to guarantee it). - A non-
asyncTask-returning handler that does its work synchronously and then returns a completed task —e => { DoSlowThing(); return Task.CompletedTask; }— is not offloaded at all. The option cannot help it; make the handlerasync(andawaitfirst) or wrap the work inTask.Run. - Handlers added with the
Action<T>overload are the exception: with the option set, the whole action is queued to the thread pool, so none of it runs on the dispatching thread.
The BIDI007 and BIDI023 analyzers report handlers where the option is present but cannot help. That means every blocking operation or module command in a non-async Task-returning handler, and those placed before the first await of an async one. Their code fix converts a non-async lambda into an async one that awaits Task.Yield() first, and inserts await Task.Yield(); at the top of an async lambda.
Practical Examples
Quick Operations (Synchronous)
// Counter - quick in-memory operation
int requestCount = 0;
driver.Network.OnBeforeRequestSent.AddObserver((e) =>
{
requestCount++; // Fast, synchronous is fine
});
// List collection - quick in-memory operation
List<string> urls = new List<string>();
driver.Network.OnResponseCompleted.AddObserver((e) =>
{
urls.Add(e.Response.Url); // Quick, synchronous is fine
});
I/O Operations (Asynchronous)
// File I/O - use async
driver.Log.OnEntryAdded.AddObserver(
async (e) =>
{
await File.AppendAllTextAsync("log.txt", $"{e.Text}\n");
},
ObservableEventHandlerOptions.RunHandlerAsynchronously
);
// Database operations - use async
driver.Log.OnEntryAdded.AddObserver(
async (e) =>
{
await dbContext.Logs.AddAsync(new DbLogEntry
{
Level = e.Level,
Message = e.Text,
Timestamp = e.Timestamp
});
await dbContext.SaveChangesAsync();
},
ObservableEventHandlerOptions.RunHandlerAsynchronously
);
Synchronizing with Async Handlers
When handlers are async, you need to synchronize if you want to ensure they complete before continuing.
Using WaitForCapturedTasksCompleteAsync (Recommended)
The simplest way is to use the built-in helper method:
EventObserver<BeforeRequestSentEventArgs> observer =
driver.Network.OnBeforeRequestSent.AddObserver(
async (e) =>
{
await ProcessRequestAsync(e);
},
ObservableEventHandlerOptions.RunHandlerAsynchronously
);
// Start capturing events
observer.StartCapturingTasks();
// Trigger events
await driver.BrowsingContext.NavigateAsync(navParams);
// Wait for events to occur AND all handlers to complete
bool occurred = await observer.WaitForCapturedTasksCompleteAsync(3, TimeSpan.FromSeconds(10));
if (occurred)
{
Console.WriteLine("All 3 events occurred and their handlers completed");
}
else
{
Console.WriteLine("Timeout waiting for events");
}
observer.StopCapturingTasks();
This method waits, in order, for:
- The requested number of events to arrive (the capture phase)
- All of the captured handler tasks to complete (the completion phase)
Note: The single timeout argument is a budget for both phases: whatever time remains after the
events arrive is the time allowed for the handlers to finish. A false return therefore means either that
fewer than the requested number of events arrived, or that all events arrived but one or more handlers had
not finished before the budget was exhausted; if you need to tell these apart, use
WaitForCapturedTasksAsync and await the returned tasks with your own timeout handling. Pass
Timeout.InfiniteTimeSpan to wait indefinitely for both phases.
Important: When you use WaitForCapturedTasksCompleteAsync(), exceptions from the captured async handler tasks are propagated through this method. Those exceptions are considered owned by the caller and are not surfaced again through transport-level EventHandlerExceptionBehavior.
Manual Synchronization (For Fine-Grained Control)
For scenarios where you need to inspect or manipulate tasks before waiting:
EventObserver<BeforeRequestSentEventArgs> observer =
driver.Network.OnBeforeRequestSent.AddObserver(
async (e) =>
{
await ProcessRequestAsync(e);
},
ObservableEventHandlerOptions.RunHandlerAsynchronously
);
// Start capturing events
observer.StartCapturingTasks();
// Trigger events
await driver.BrowsingContext.NavigateAsync(navParams);
// Wait for 3 events to occur
Task[] handlerTasks = await observer.WaitForCapturedTasksAsync(3, TimeSpan.FromSeconds(10));
bool occurred = handlerTasks.Length == 3;
if (occurred)
{
Console.WriteLine($"Waiting for {handlerTasks.Length} handlers to complete...");
// Wait for all async handlers to complete
await Task.WhenAll(handlerTasks);
Console.WriteLine("All handlers completed");
}
observer.StopCapturingTasks();
When using WaitForCapturedTasksAsync() followed by Task.WhenAll(), you take ownership of those tasks and their exceptions. This lets you inspect or await handler failures directly without having those same failures also re-surfaced through the transport's event handler error behavior.
Waiting for Async Handlers to Complete
For long-running async handlers, use WaitForCapturedTasksCompleteAsync or WaitForCapturedTasksAsync with Task.WhenAll:
EventObserver<BeforeRequestSentEventArgs> observer =
driver.Network.OnBeforeRequestSent.AddObserver(
async (e) =>
{
Console.WriteLine($"Processing request: {e.Request.Url}");
// Long-running operation
await Task.Delay(TimeSpan.FromSeconds(4));
await ProcessRequestAsync(e);
Console.WriteLine($"Completed request: {e.Request.Url}");
},
ObservableEventHandlerOptions.RunHandlerAsynchronously
);
// Subscribe to events
SubscribeCommandParameters subscribe = new(driver.Network.OnBeforeRequestSent.EventName);
await driver.Session.SubscribeAsync(subscribe);
// Start capturing and trigger navigation
observer.StartCapturingTasks();
NavigateCommandParameters navParams = new(contextId, "https://example.com")
{
Wait = ReadinessState.Complete
};
NavigateCommandResult navigation = await driver.BrowsingContext.NavigateAsync(navParams);
Console.WriteLine("Navigation command completed");
// Important: The navigation command completes before handlers finish.
// WaitForCapturedTasksCompleteAsync waits for events to occur AND handlers to complete.
bool occurred = await observer.WaitForCapturedTasksCompleteAsync(5, TimeSpan.FromSeconds(10));
if (occurred)
{
Console.WriteLine("All event handlers completed");
}
else
{
Console.WriteLine("Timeout waiting for events");
}
observer.StopCapturingTasks();
Why This Matters:
Without synchronization, your main code might exit before async handlers complete:
// ❌ PROBLEM: Main thread may exit before handlers complete
EventObserver<BeforeRequestSentEventArgs> badObserver =
driver.Network.OnBeforeRequestSent.AddObserver(
async (e) =>
{
await Task.Delay(TimeSpan.FromSeconds(5)); // Long operation
Console.WriteLine($"Request: {e.Request.Url}");
},
ObservableEventHandlerOptions.RunHandlerAsynchronously
);
await driver.BrowsingContext.NavigateAsync(navParams);
// Navigation completes...
// Main code continues...
// Application might exit before handlers finish!
// ✅ SOLUTION: Use WaitForCapturedTasksCompleteAsync to wait for events and their handlers
EventObserver<BeforeRequestSentEventArgs> goodObserver =
driver.Network.OnBeforeRequestSent.AddObserver(
async (e) =>
{
await Task.Delay(TimeSpan.FromSeconds(5));
Console.WriteLine($"Request: {e.Request.Url}");
},
ObservableEventHandlerOptions.RunHandlerAsynchronously
);
goodObserver.StartCapturingTasks();
await driver.BrowsingContext.NavigateAsync(navParams);
// Waits for 5 events to occur AND all their handlers to complete
bool occurred = await goodObserver.WaitForCapturedTasksCompleteAsync(5, TimeSpan.FromSeconds(10));
goodObserver.StopCapturingTasks();
Calling Commands in Event Handlers
Calling commands within event handlers requires async mode:
EventObserver<BeforeRequestSentEventArgs> observer =
driver.Network.OnBeforeRequestSent.AddObserver(
async (e) =>
{
if (e.IsBlocked)
{
// Can call commands in async handler
ProvideResponseCommandParameters provideResponse =
new ProvideResponseCommandParameters(e.Request.RequestId)
{
StatusCode = 404,
ReasonPhrase = "Not Found"
};
await driver.Network.ProvideResponseAsync(provideResponse);
}
},
ObservableEventHandlerOptions.RunHandlerAsynchronously // MUST use async mode to call commands
);
Event Filtering
You can filter events in your observer:
// Only log errors
driver.Log.OnEntryAdded.AddObserver((e) =>
{
if (e.Level == LogLevel.Error)
{
Console.WriteLine($"ERROR: {e.Text}");
}
});
// Only log HTML requests
driver.Network.OnResponseCompleted.AddObserver((e) =>
{
if (e.Response.Url.EndsWith(".html"))
{
Console.WriteLine($"HTML page: {e.Response.Url}");
}
});
Multiple Observers
You can add multiple observers for the same event:
// Observer 1: Log to console
driver.Log.OnEntryAdded.AddObserver((e) =>
{
Console.WriteLine($"Console: {e.Text}");
});
// Observer 2: Write to file
EventObserver<EntryAddedEventArgs> fileLogger =
driver.Log.OnEntryAdded.AddObserver(async (e) =>
{
await File.AppendAllTextAsync("log.txt", e.Text + "\n");
},
ObservableEventHandlerOptions.RunHandlerAsynchronously);
// Observer 3: Count errors
int errorCount = 0;
driver.Log.OnEntryAdded.AddObserver((e) =>
{
if (e.Level == LogLevel.Error)
{
errorCount++;
}
});
Common Patterns
Pattern 1: Wait for Page Load
// Add observer for page load event
EventObserver<NavigationEventArgs> observer =
driver.BrowsingContext.OnLoad.AddObserver((e) => { });
// Subscribe to the event
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.BrowsingContext.OnLoad.EventName);
await driver.Session.SubscribeAsync(subscribe);
// Start capturing and trigger navigation
observer.StartCapturingTasks();
await driver.BrowsingContext.NavigateAsync(navParams);
// Wait for the page load event
Task[] tasks = await observer.WaitForCapturedTasksAsync(1, TimeSpan.FromSeconds(30));
bool loaded = tasks.Length == 1;
observer.StopCapturingTasks();
if (loaded)
{
Console.WriteLine("Page loaded successfully");
}
Pattern 2: Collect Network Responses
With a data collector, response accumulation requires no manual list or lock:
// Use a data collector to accumulate responses — no manual list or lock needed
await using EventDataCollector<ResponseCompletedEventArgs> collector =
driver.Network.OnResponseCompleted.AddDataCollector();
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.Network.OnResponseCompleted.EventName);
await driver.Session.SubscribeAsync(subscribe);
await driver.BrowsingContext.NavigateAsync(navParams);
// Drain all responses collected during the navigation
IReadOnlyList<ResponseCompletedEventArgs> responses = collector.GetCollectedEventData();
Console.WriteLine($"Collected {responses.Count} responses");
foreach (ResponseCompletedEventArgs e in responses)
{
Console.WriteLine($" {e.Response.Status} {e.Response.Url}");
}
The original observer-based approach (manually appending to a List<T> in a handler) still works but requires you to manage the list and its thread safety yourself. The data collector is the simpler choice when immediate per-event reaction is not required.
Pattern 3: Wait for Specific Condition
TaskCompletionSource<RemoteValue> elementFound =
new TaskCompletionSource<RemoteValue>();
// Add the observer first, so that no message can arrive before it is listening.
driver.Script.OnMessage.AddObserver((e) =>
{
if (e.ChannelId == "elementWatcher")
{
elementFound.SetResult(e.Data);
}
});
// The channel delivers through the script.message event, which must be subscribed
// once per session; without this the observer above never runs.
await driver.Session.SubscribeAsync(
new SubscribeCommandParameters(driver.Script.OnMessage.EventName));
// Preload script watches for element
string preloadScript = """
(channel) => {
const interval = setInterval(() => {
const element = document.querySelector('.target');
if (element) {
clearInterval(interval);
channel(element);
}
}, 100);
}
""";
ChannelValue channel = new ChannelValue(
new ChannelProperties("elementWatcher"));
AddPreloadScriptCommandParameters preloadParams =
new AddPreloadScriptCommandParameters(preloadScript)
{
Arguments = { channel }
};
await driver.Script.AddPreloadScriptAsync(preloadParams);
await driver.BrowsingContext.NavigateAsync(navParams);
// Wait for element to appear
RemoteValue elementRemoteValue = await elementFound.Task;
if (elementRemoteValue.TryAs(out NodeRemoteValue? element))
{
Console.WriteLine($"Element found: {element.SharedId}");
}
Pattern 4: Temporary Observer
// Add observer just for one operation
EventObserver<EntryAddedEventArgs> observer =
driver.Log.OnEntryAdded.AddObserver((e) =>
{
Console.WriteLine(e.Text);
});
// Do something
await driver.BrowsingContext.NavigateAsync(navParams);
// Remove observer
observer.Unobserve();
Event Args Properties
Each event type has specific properties:
NavigationEventArgs
driver.BrowsingContext.OnLoad.AddObserver((NavigationEventArgs e) =>
{
string contextId = e.BrowsingContextId;
string? navigationId = e.NavigationId; // Null if the event is not part of a navigation
string url = e.Url;
DateTime timestamp = e.Timestamp;
});
EntryAddedEventArgs
driver.Log.OnEntryAdded.AddObserver((EntryAddedEventArgs e) =>
{
LogLevel level = e.Level; // Error, Warn, Info, Debug
string? text = e.Text; // Log message; null for an entry without one
DateTime timestamp = e.Timestamp;
string? source = e.Source.RealmId; // Realm the entry came from
// An entry carries a stack trace only when the remote end sent one, as for console.error.
List<string> stackLines = new List<string>();
foreach (StackFrame frame in e.StackTrace?.CallFrames ?? new List<StackFrame>())
{
stackLines.Add($"{frame.FunctionName} at {frame.Url}:{frame.LineNumber}:{frame.ColumnNumber}");
}
string? stackTrace = string.Join("\n", stackLines);
});
BeforeRequestSentEventArgs
driver.Network.OnBeforeRequestSent.AddObserver((e) =>
{
string requestId = e.Request.RequestId;
string url = e.Request.Url;
string method = e.Request.Method;
IList<ReadOnlyHeader> headers = e.Request.Headers;
bool isBlocked = e.IsBlocked; // True if intercepted
string? contextId = e.BrowsingContextId; // Null for a request with no browsing context
});
ResponseCompletedEventArgs
driver.Network.OnResponseCompleted.AddObserver((e) =>
{
RequestData request = e.Request;
ResponseData response = e.Response;
string url = response.Url;
ulong status = response.Status;
string statusText = response.StatusText;
IList<ReadOnlyHeader> headers = response.Headers;
});
BrowsingContext Navigation and Download Events
The BrowsingContext module provides additional events for navigation lifecycle, history, and downloads:
// Navigation committed - fired when the browser commits to the navigation
driver.BrowsingContext.OnNavigationCommitted.AddObserver((NavigationEventArgs e) =>
{
Console.WriteLine($"Navigation committed to: {e.Url}");
});
// History updated - fired when back/forward history changes
driver.BrowsingContext.OnHistoryUpdated.AddObserver((HistoryUpdatedEventArgs e) =>
{
Console.WriteLine($"History updated: {e.Url} in context {e.BrowsingContextId}");
});
// Download will begin - fired when a download is about to start
driver.BrowsingContext.OnDownloadWillBegin.AddObserver((DownloadWillBeginEventArgs e) =>
{
Console.WriteLine($"Download starting: {e.SuggestedFileName} from {e.Url}");
});
// Download end - fired when a download completes or is canceled
driver.BrowsingContext.OnDownloadEnd.AddObserver((DownloadEndEventArgs e) =>
{
string filePath = (e as DownloadCompleteEventArgs)?.FilePath ?? "N/A";
Console.WriteLine($"Download ended: {e.Status}, path: {filePath}");
});
Best Practices
1. Add Observers Before Subscribing
The recommended order is to add observers first, then subscribe through the Session module:
// ✅ Recommended: Add observer first, then subscribe
driver.Log.OnEntryAdded.AddObserver(handler);
SubscribeCommandParameters subscribe =
new SubscribeCommandParameters(driver.Log.OnEntryAdded.EventName);
await driver.Session.SubscribeAsync(subscribe);
// ✅ Also acceptable: Subscribe then add observer (but less clear)
await driver.Session.SubscribeAsync(subscribe);
driver.Log.OnEntryAdded.AddObserver(handler);
Why Add Observers First?
While both orders work, adding observers before subscribing ensures your handlers are ready before the browser starts sending events. This is especially important when:
- Setting up multiple observers
- The browser might send events immediately after subscription
- You want predictable initialization order
The two-step design (add observer + subscribe) is intentional to prevent race conditions where events arrive before handlers are registered.
2. Remove Observers When Done
EventObserver<EntryAddedEventArgs> observer =
driver.Log.OnEntryAdded.AddObserver(handler);
try
{
// Use observer
}
finally
{
observer.Unobserve();
}
3. Use Async Mode for Long Operations
// ✅ Good: Won't block message processing
driver.Network.OnBeforeRequestSent.AddObserver(
async (e) => await SlowOperationAsync(e),
ObservableEventHandlerOptions.RunHandlerAsynchronously
);
4. Handle Exceptions in Observers
driver.Log.OnEntryAdded.AddObserver((e) =>
{
try
{
ProcessLogEntry(e);
}
catch (Exception ex)
{
Console.WriteLine($"Observer error: {ex.Message}");
}
});
Next Steps
- Common Pitfalls: Avoid common mistakes with event handling
- Module Guides: Learn what events each module provides
- Network Interception Example: Practical event usage
- Preload Scripts Example: Using script.message events
Summary
- Events require two steps: add an observer or data collector locally, then subscribe through the Session module
- Recommended order: add observers/collectors first, then subscribe (ensures handlers are ready before events arrive)
- Use observers (
AddObserver) to react to each event immediately as it occurs - Use data collectors (
AddDataCollector) to accumulate events and inspect them on demand —GetCollectedEventData()drains the buffer atomically and resets it for the next interval; useEvents(IAsyncEnumerable<T>) to stream items one at a time viaawait foreach; pass an optional filter predicate toAddDataCollectorto discard unwanted events at collection time - Store the observer returned by
AddObserverwhen you need to remove it or use the capture API - Use
await usingonEventDataCollector<T>for automatic cleanup; never leave a collector attached after you no longer need it - Use
ToObservable()to adapt anyObservableEvent<T>toIObservable<T>— eachSubscribecall is independent and counts as one observer; dispose the returned handle to stop delivery and triggerOnCompleted, and await itsCompletionTaskwhen you need to know delivery has ended - Use try/finally or
usingto ensure observers are removed when done (prevents memory leaks) - Use
StartCapturingTasks()/WaitForCapturedTasksAsync()to synchronize with events — whenWaitForCapturedTasksAsyncreturns a full batch it automatically ends the capture session; an explicitStopCapturingTasks()call is a no-op and safe to include for clarity - Use
WaitForCapturedTasksCompleteAsync()to wait for async handlers to complete — it also ends the capture session when the requested number of tasks is collected - Use
RunHandlerAsynchronouslyoption for long-running operations or I/O - Multiple observers and data collectors can observe the same event simultaneously