Table of Contents

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:

  1. Add an observer or data collector to handle or accumulate the event
  2. 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 requires session.SubscribeAsync). OnLogMessage is 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:

  • Events and GetCollectedEventData() 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 foreach will drain any remaining buffered items and then exit cleanly when Dispose or DisposeAsync is called.
  • The filter predicate applies to the stream. Events that do not satisfy the predicate are never buffered, so they never appear in Events either.
  • await foreach blocks the current async method while waiting for the next event. If you need to both stream events and do other work concurrently, drive the await foreach from a separate Task.

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>:

  • OnCompleted is 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.
  • OnError is called only if OnNext throws. Exceptions thrown by other observers on the same ObservableEvent<T> do not flow through OnError.
  • Each Subscribe call counts as one observer against ObservableEvent<T>.MaxObserverCount.
  • OnNext is 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 concurrent WaitForCapturedTasksAsync call holds the lock, GetCapturedTasks() will block the calling thread until it is released. In async contexts — particularly those with a single-threaded SynchronizationContext, such as WPF or legacy ASP.NET — prefer WaitForCapturedTasksAsync to 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 async lambda, everything up to the first await that does not complete synchronously still runs on the dispatching thread. Put the await before the heavy work (an await Task.Yield(); as the first statement is the simplest way to guarantee it).
  • A non-async Task-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 handler async (and await first) or wrap the work in Task.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.

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:

  1. The requested number of events to arrive (the capture phase)
  2. 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:

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

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; use Events (IAsyncEnumerable<T>) to stream items one at a time via await foreach; pass an optional filter predicate to AddDataCollector to discard unwanted events at collection time
  • Store the observer returned by AddObserver when you need to remove it or use the capture API
  • Use await using on EventDataCollector<T> for automatic cleanup; never leave a collector attached after you no longer need it
  • Use ToObservable() to adapt any ObservableEvent<T> to IObservable<T> — each Subscribe call is independent and counts as one observer; dispose the returned handle to stop delivery and trigger OnCompleted, and await its CompletionTask when you need to know delivery has ended
  • Use try/finally or using to ensure observers are removed when done (prevents memory leaks)
  • Use StartCapturingTasks()/WaitForCapturedTasksAsync() to synchronize with events — when WaitForCapturedTasksAsync returns a full batch it automatically ends the capture session; an explicit StopCapturingTasks() 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 RunHandlerAsynchronously option for long-running operations or I/O
  • Multiple observers and data collectors can observe the same event simultaneously