Observability and Diagnostics
WebDriverBiDi.NET provides comprehensive observability through System.Diagnostics.Tracing.EventSource, enabling you to monitor, troubleshoot, and optimize your WebDriver BiDi automation.
Overview
The library emits structured diagnostic events that can be consumed by:
- EventListener - Custom in-process listeners for real-time monitoring
- ETW (Event Tracing for Windows) - Windows performance monitoring
- EventPipe - Cross-platform event collection and analysis
- dotnet-trace - CLI diagnostics tool for production troubleshooting
- OpenTelemetry - Distributed tracing and metrics collection
- APM Tools - Application Insights, Dynatrace, New Relic (via bridges)
Key Benefits:
- ✅ Zero dependencies - Uses only core .NET APIs
- ✅ Low overhead - Minimal performance impact when not actively listening
- ✅ Production-ready - Designed for high-throughput scenarios
- ✅ Extensible - Easy to integrate with existing logging infrastructure
Quick Start
Console Logging
The simplest way to see diagnostic events is a small EventListener that enables the WebDriverBiDi source and writes each event to the console:
public class ObservabilityConsoleEventListener : EventListener
{
protected override void OnEventSourceCreated(EventSource source)
{
if (source.Name == "WebDriverBiDi")
{
EnableEvents(source, EventLevel.Informational);
}
}
protected override void OnEventWritten(EventWrittenEventArgs eventData)
{
Console.WriteLine($"[{eventData.Level}] {eventData.EventName}");
}
}
Create it before the driver so that it sees the source as soon as the library touches it:
// Create a simple console event listener (see ObservabilityConsoleEventListener below)
using var listener = new ObservabilityConsoleEventListener();
// Use WebDriverBiDi normally - events will be logged to console
await using var driver = new BiDiDriver();
await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");
Custom EventListener
Create a custom listener for more control:
public class MyEventListener : EventListener
{
protected override void OnEventSourceCreated(EventSource source)
{
// Enable WebDriverBiDi events at Informational level
if (source.Name == "WebDriverBiDi")
{
EnableEvents(source, EventLevel.Informational);
}
}
protected override void OnEventWritten(EventWrittenEventArgs eventData)
{
Console.WriteLine($"[{eventData.Level}] {eventData.EventName}");
// Access structured payload
if (eventData.PayloadNames != null)
{
for (int i = 0; i < eventData.PayloadNames.Count; i++)
{
Console.WriteLine($" {eventData.PayloadNames[i]}: {eventData.Payload?[i]}");
}
}
}
}
// Usage
using var listener = new MyEventListener();
Event Levels
Choose the appropriate level based on your needs:
| Level | Use Case | Events Included |
|---|---|---|
| Critical | Production alerts | Critical failures (none currently emitted) |
| Error | Error tracking | Command errors, protocol errors, connection errors |
| Warning | Operational monitoring | Command timeouts, commands that failed before transmission, event handler errors, unknown messages |
| Informational | General monitoring | Connection lifecycle, command completion, responses discarded for canceled commands, transport start/stop, custom registrations |
| Verbose | Development/debugging | All events including command sending, event receipt, statistics |
Recommendation for Production: Use EventLevel.Informational or EventLevel.Warning to balance observability with overhead.
Available Events
The ID column is the numeric EventSource event ID, which the
Microsoft.Extensions.Logging bridge surfaces as EventId on every forwarded
entry. IDs are stable; gaps in the sequence are events that existed in an earlier version and have since
been retired.
Connection Lifecycle
| Event | ID | Level | Description | Payload |
|---|---|---|---|---|
ConnectionOpening |
1 | Info | Connection is being established | connectionId, sessionId, url |
ConnectionOpened |
2 | Info | Connection successfully established | connectionId, sessionId, url |
ConnectionClosing |
3 | Info | Connection is being closed | connectionId, sessionId, reason |
ConnectionClosed |
4 | Info | Connection fully closed | connectionId, sessionId |
ConnectionError |
5 | Error | Connection error occurred | connectionId, sessionId, errorMessage |
Every session the transport opens is closed out the same way, however it ends: a session that raised
ConnectionOpened and TransportStarted always ends with exactly one ConnectionClosed and one
TransportStopped. A local stop raises ConnectionClosing first, with the termination reason, and raises the
closing pair even if the connection's own stop fails. A session the remote end closes, or that ends in a
connection error, raises no ConnectionClosing; its TransportStopped reason names the cause
("Remote end closed the connection", or "Connection error: " followed by the error). Application code that
needs to react to such a loss observes driver.OnConnectionLost (see
OnConnectionLost). A connect attempt that
fails after ConnectionOpening raises ConnectionError with the failure, and no ConnectionOpened.
Every event except AsyncHandlerTaskCount begins its payload with connectionId and sessionId, and its
rendered message with [connectionId/sessionId], so a line identifies the driver it came from without
cross-referencing anything.
connectionId is Connection.Id, a GUID string assigned when the Connection is constructed and stable for
its lifetime, including across reconnects; read it from the connection object to correlate your own logging.
sessionId is a GUID assigned to each session the transport opens, so it changes on every reconnect and
distinguishes the work of one session from the next on the same connection. It is empty on ConnectionOpening,
which precedes the session it opens, on the module and event registration events, which are only legal while
the transport is disconnected, and on events raised by a Connection used without a Transport, which has no
sessions. AsyncHandlerTaskCount carries neither: it is a process-wide counter shared by every driver.
Command Execution
| Event | ID | Level | Description | Payload |
|---|---|---|---|---|
CommandSending |
6 | Verbose | Command being sent to remote end | connectionId, sessionId, commandId, method |
CommandCompleted |
7 | Info | Command completed successfully | connectionId, sessionId, commandId, method, elapsedMilliseconds |
CommandTimeout |
8 | Warning | Command timed out | connectionId, sessionId, commandId, method, timeoutMilliseconds |
CommandError |
9 | Error | Command failed with error response | connectionId, sessionId, commandId, method, errorCode, errorType, errorMessage |
CommandSendFailed |
22 | Warning | Command could not be transmitted to the remote end | connectionId, sessionId, commandId, method, failureType, failureMessage, elapsedMilliseconds |
CanceledCommandResponseDiscarded |
24 | Info | A response arrived for a command the local end had already stopped waiting for (timed out, canceled, or pending at connection close) and was discarded | connectionId, sessionId, commandId, method, reason, millisecondsSinceCancellation |
Event Handling
| Event | ID | Level | Description | Payload |
|---|---|---|---|---|
EventReceived |
10 | Verbose | Protocol event received | connectionId, sessionId, eventMethod |
EventHandlerError |
15 | Warning | User event handler threw exception | connectionId, sessionId, eventMethod, errorMessage |
Protocol Processing
| Event | ID | Level | Description | Payload |
|---|---|---|---|---|
UnknownMessageReceived |
13 | Warning | Message that is not valid JSON, or not a command response, error response or registered event | connectionId, sessionId, messageType, messageLength |
ProtocolError |
14 | Error | Error response or registered event whose payload could not be deserialized, or a fault in the message processing loop | connectionId, sessionId, errorMessage, messageSnippet |
Transport & Statistics
| Event | ID | Level | Description | Payload |
|---|---|---|---|---|
TransportStarted |
17 | Info | Transport message processing started | connectionId, sessionId |
TransportStopped |
18 | Info | Transport message processing stopped | connectionId, sessionId, reason |
PendingCommandCount |
16 | Verbose | Current pending command count | connectionId, sessionId, pendingCount |
AsyncHandlerTaskCount |
23 | Verbose | Number of in-flight asynchronous event handler tasks (see Performance) | inFlightCount |
MessageStatistics |
21 | Verbose | Message statistics for a session, raised when the session ends; messagesSent counts commands and messagesReceived command responses |
connectionId, sessionId, messagesSent, messagesReceived, eventsReceived, errorsReceived |
Module & Extensibility
| Event | ID | Level | Description | Payload |
|---|---|---|---|---|
CustomModuleRegistered |
19 | Info | Custom module registered | connectionId, sessionId, moduleName |
CustomEventRegistered |
20 | Info | Custom event type registered | connectionId, sessionId, eventName, eventType |
Common Scenarios
Performance Monitoring
Track command execution times to identify bottlenecks:
public class PerformanceMonitor : EventListener
{
private readonly Dictionary<string, List<long>> timings = new();
protected override void OnEventSourceCreated(EventSource source)
{
if (source.Name == "WebDriverBiDi")
{
EnableEvents(source, EventLevel.Informational);
}
}
protected override void OnEventWritten(EventWrittenEventArgs eventData)
{
if (eventData.EventName == "CommandCompleted")
{
// Payload is [connectionId, sessionId, commandId, method, elapsedMilliseconds].
string method = eventData.Payload?[3]?.ToString() ?? "unknown";
long elapsed = Convert.ToInt64(eventData.Payload?[4]);
if (!timings.ContainsKey(method))
{
timings[method] = new List<long>();
}
timings[method].Add(elapsed);
// Alert on slow commands
if (elapsed > 1000)
{
Console.WriteLine($"SLOW: {method} took {elapsed}ms");
}
}
}
public void PrintStatistics()
{
foreach (var kvp in timings.OrderByDescending(x => x.Value.Average()))
{
Console.WriteLine($"{kvp.Key}: avg={kvp.Value.Average():F2}ms, max={kvp.Value.Max()}ms");
}
}
}
Error Tracking
Monitor errors for debugging and alerting:
public class ErrorTracker : EventListener
{
private int errorCount = 0;
private readonly List<string> recentErrors = new();
protected override void OnEventSourceCreated(EventSource source)
{
if (source.Name == "WebDriverBiDi")
{
EnableEvents(source, EventLevel.Warning); // Warning and above
}
}
protected override void OnEventWritten(EventWrittenEventArgs eventData)
{
if (eventData.Level == EventLevel.Error)
{
errorCount++;
// Pair each payload name with its value; joining PayloadNames alone records the shape of
// the event and none of its detail, so every error of a given kind looks identical.
ReadOnlyCollection<string>? names = eventData.PayloadNames;
ReadOnlyCollection<object?>? values = eventData.Payload;
IEnumerable<string> payload = Enumerable.Range(0, Math.Min(names?.Count ?? 0, values?.Count ?? 0))
.Select(index => $"{names![index]}={values![index]}");
string error = $"{eventData.EventName}: {string.Join(", ", payload)}";
recentErrors.Add(error);
// Keep only last 100 errors
if (recentErrors.Count > 100)
{
recentErrors.RemoveAt(0);
}
// Send to monitoring service
if (errorCount % 10 == 0)
{
Console.WriteLine($"ALERT: {errorCount} errors occurred!");
}
}
}
}
Integration with Microsoft.Extensions.Logging
For ILogger integration, use the WebDriverBiDi.Logging NuGet package:
dotnet add package WebDriverBiDi.Logging
This package bridges WebDriverBiDi EventSource events (not the driver's OnLogMessage messages; see What the Bridge Does Not Forward) to the standard .NET logging infrastructure, enabling integration with Application Insights, Serilog, and other logging providers. The AddWebDriverBiDi() extension method is available on ILoggingBuilder (in the Microsoft.Extensions.Logging namespace) once the package is referenced.
Basic Console Application
// Setup dependency injection
var services = new ServiceCollection();
// Configure logging with WebDriverBiDi events
services.AddLogging(builder =>
{
builder.AddConsole()
.SetMinimumLevel(LogLevel.Debug);
// Add WebDriverBiDi diagnostic event logging
builder.AddWebDriverBiDi(EventLevel.Informational);
});
await using var serviceProvider = services.BuildServiceProvider();
// The bridge starts listening when the logging pipeline is built, which happens the first time
// ILoggerFactory (or an ILogger) is resolved. A generic or web host does this at startup.
_ = serviceProvider.GetRequiredService<ILoggerFactory>();
// Use WebDriverBiDi - events will be automatically logged
await using var driver = new BiDiDriver();
await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");
// Logs will show:
// Every message is prefixed with [connectionId/sessionId]. connectionId is Connection.Id, a GUID
// assigned when the connection is created; sessionId identifies the transport session, a new GUID
// for each connect, left empty on ConnectionOpening, which is raised before that session exists.
// info: WebDriverBiDi.Logging.WebDriverBiDiEventSourceLogger[1]
// [3f2a9c81-5d64-4b0e-9a77-1c8e6b2d4f05/] Opening connection to ws://localhost:9515/session/YOUR-SESSION-ID
// info: WebDriverBiDi.Logging.WebDriverBiDiEventSourceLogger[2]
// [3f2a9c81-5d64-4b0e-9a77-1c8e6b2d4f05/7b41e0d2-9c35-4a18-8f60-2d7e1b9a3c44] Connection opened to ws://localhost:9515/session/YOUR-SESSION-ID
// info: WebDriverBiDi.Logging.WebDriverBiDiEventSourceLogger[17]
// [3f2a9c81-5d64-4b0e-9a77-1c8e6b2d4f05/7b41e0d2-9c35-4a18-8f60-2d7e1b9a3c44] Transport started
await driver.Session.StatusAsync();
// Logs will show:
// info: WebDriverBiDi.Logging.WebDriverBiDiEventSourceLogger[7]
// [3f2a9c81-5d64-4b0e-9a77-1c8e6b2d4f05/7b41e0d2-9c35-4a18-8f60-2d7e1b9a3c44] Command 1 (session.status) completed in 42ms
// (CommandSending is a Verbose-level event and is not emitted at EventLevel.Informational;
// TransportStarted is raised once, when the transport connects, and is already shown above)
Logs will show connection lifecycle and command completion events (e.g., ConnectionOpening, ConnectionOpened, CommandCompleted); CommandSending is a Verbose-level event and appears only if you raise the minimum level to EventLevel.Verbose.
ASP.NET Core Web Application
var builder = WebApplication.CreateBuilder(args);
// Add WebDriverBiDi event logging to the ASP.NET Core logging pipeline
builder.Logging.AddWebDriverBiDi(EventLevel.Informational);
// Configure services
builder.Services.AddScoped<IBrowserAutomationService, BrowserAutomationService>();
var app = builder.Build();
app.MapGet("/test", async (IBrowserAutomationService automation) =>
{
// WebDriverBiDi events will be logged through the ASP.NET Core logging infrastructure
var result = await automation.RunTestAsync();
return Results.Ok(result);
});
app.Run();
Add your services (e.g., IBrowserAutomationService) and endpoints as needed. WebDriverBiDi events will be logged through the ASP.NET Core logging infrastructure.
With Serilog Structured Logging
// Configure Serilog with structured logging
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug() // the bridge logs EventLevel.Verbose events at LogLevel.Debug
.WriteTo.Console(outputTemplate:
"[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}")
.Enrich.FromLogContext()
.CreateLogger();
var services = new ServiceCollection();
services.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddSerilog();
builder.AddWebDriverBiDi(EventLevel.Verbose); // Capture all events including verbose
});
await using var serviceProvider = services.BuildServiceProvider();
// The bridge starts listening when the logging pipeline is built, which happens the first time
// ILoggerFactory (or an ILogger) is resolved. A generic or web host does this at startup.
_ = serviceProvider.GetRequiredService<ILoggerFactory>();
// Structured properties will be captured by Serilog
await using var driver = new BiDiDriver();
await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");
// Serilog renders the message from the event's message template. The properties that template uses
// (connectionId, sessionId, commandId, method, elapsedMilliseconds) are captured on the log event,
// so {Properties} does not repeat them:
// [12:34:56 INF] [3f2a9c81-5d64-4b0e-9a77-1c8e6b2d4f05/7b41e0d2-9c35-4a18-8f60-2d7e1b9a3c44] Command 1 (session.status) completed in 42ms {"EventId": {"Id": 7, "Name": "CommandCompleted"}, "EventName": "CommandCompleted", "EventSource": "WebDriverBiDi", "SourceContext": "WebDriverBiDi.Logging.WebDriverBiDiEventSourceLogger"}
Serilog receives each event's message template, so it renders the message from the template and captures the payload values (e.g., commandId, method, elapsedMilliseconds) as structured properties.
With Application Insights
var services = new ServiceCollection();
// Add Application Insights
services.AddApplicationInsightsTelemetry();
// Configure logging with WebDriverBiDi events
services.AddLogging(builder =>
{
builder.AddApplicationInsights();
builder.AddWebDriverBiDi(EventLevel.Informational);
});
using var serviceProvider = services.BuildServiceProvider();
// The bridge starts listening when the logging pipeline is built, which happens the first time
// ILoggerFactory (or an ILogger) is resolved. A generic or web host does this at startup.
_ = serviceProvider.GetRequiredService<ILoggerFactory>();
// WebDriverBiDi events will be sent to Application Insights with structured properties
// allowing you to query and analyze automation telemetry
WebDriverBiDi events will be sent to Application Insights with structured properties for querying and analysis.
Filtering by Event Level
services.AddLogging(builder =>
{
builder.AddConsole();
// Only capture Warning and Error events
builder.AddWebDriverBiDi(EventLevel.Warning);
// Or use ILogger filtering
builder.AddFilter("WebDriverBiDi.Logging", LogLevel.Warning);
});
Configuration-based Setup
appsettings.json:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"WebDriverBiDi.Logging.WebDriverBiDiEventSourceLogger": "Debug"
},
"Console": {
"IncludeScopes": true
}
}
}
Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Configuration is loaded from appsettings.json
builder.Logging.AddWebDriverBiDi(); // Respects configured log levels
var app = builder.Build();
Custom Event Processing
For scenarios requiring custom processing beyond standard logging:
public class CustomWebDriverEventListener : EventListener
{
private readonly ILogger logger;
public CustomWebDriverEventListener(ILogger logger)
{
this.logger = logger;
}
protected override void OnEventSourceCreated(EventSource eventSource)
{
if (eventSource.Name == "WebDriverBiDi")
{
EnableEvents(eventSource, EventLevel.Informational);
}
}
protected override void OnEventWritten(EventWrittenEventArgs eventData)
{
// Custom processing here
if (eventData.EventName == "CommandCompleted")
{
// Payload is [connectionId, sessionId, commandId, method, elapsedMilliseconds].
long elapsedMs = Convert.ToInt64(eventData.Payload?[4]);
if (elapsedMs > 1000)
{
logger.LogWarning("Slow command detected: {Method} took {ElapsedMs}ms",
eventData.Payload?[3], elapsedMs);
}
}
}
}
// Register as singleton
services.AddSingleton(sp =>
{
var logger = sp.GetRequiredService<ILogger<CustomWebDriverEventListener>>();
return new CustomWebDriverEventListener(logger);
});
See the WebDriverBiDi.Logging package for details.
CLI Tools
Using dotnet-trace
Collect events from a running process without code changes:
# List running .NET processes
dotnet-trace ps
# Collect WebDriverBiDi events
dotnet-trace collect --process-id <pid> --providers WebDriverBiDi
# Collect at a specific event level (5=Verbose, 4=Info, 3=Warning, 2=Error). The provider format is
# name[:keywords[:level]], so the level comes after a keyword mask; every mask matches these events.
dotnet-trace collect --process-id <pid> --providers WebDriverBiDi:0xFFFFFFFFFFFFFFFF:4
# Convert to other formats
dotnet-trace convert trace.nettrace --format speedscope
Using PerfView (Windows)
For Windows-specific ETW collection:
# Collect ETW events
PerfView.exe /OnlyProviders=*WebDriverBiDi collect
# Stop collection
# (Press 's' in PerfView window)
# View events
# Open .etl file in PerfView and navigate to Events view
OpenTelemetry Integration
The library emits EventSource events only; it does not define a System.Diagnostics.ActivitySource, so subscribing OpenTelemetry to a source named WebDriverBiDi collects nothing. Bridge the events into an ActivitySource your application owns with an EventListener, and subscribe to that:
// WebDriverBiDi emits EventSource events, not System.Diagnostics activities, so there is
// nothing for AddSource("WebDriverBiDi") to collect. Bridge the events into an
// ActivitySource owned by your application with an EventListener, and subscribe to that.
ActivitySource activitySource = new ActivitySource("MyApp.WebDriverBiDi");
using ObservabilityActivityBridge bridge = new ObservabilityActivityBridge(activitySource);
var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource("MyApp.WebDriverBiDi")
.AddConsoleExporter()
.Build();
// Now use WebDriverBiDi - each diagnostic event is recorded as an activity
Requires: OpenTelemetry, OpenTelemetry.Exporter.Console.
Best Practices
Production Environments
Use Informational or Warning Level See
MyEventListenerandPerformanceMonitorin the samples—they useEnableEvents(source, EventLevel.Informational)inOnEventSourceCreated.Process Events Quickly
OnEventWrittenis called synchronously- Avoid blocking operations
- Queue events for async processing if needed
Filter by Event Name
if (eventData.EventName == "CommandError") { // Handle only command errors }Monitor Key Metrics
- Command latency (
CommandCompleted) - Error rate (
CommandError,ProtocolError) - Connection stability (
ConnectionError)
- Command latency (
Development Environments
Use Verbose Level Use
EventLevel.VerboseinEnableEvents(source, EventLevel.Verbose)within your EventListener'sOnEventSourceCreated.Enable Detailed Payload Logging
- Log full payload for debugging
- Use
eventData.PayloadNamesandeventData.Payload
Combine with Existing Logging
- Bridge to your preferred logging framework
- Maintain consistent log format
Resource Management
Always dispose EventListener instances:
using var listener = new MyEventListener();
// or
var listener = new MyEventListener();
try
{
// use listener
}
finally
{
listener?.Dispose();
}
Performance Considerations
- Cheap when not enabled - each event method checks
IsEnabledbefore writing, so nothing is emitted without a listener. It is not allocation-free: a few call sites build their arguments first, such as the exception type name inCommandSendFailed - Low overhead - Minimal impact even with Verbose logging
- ETW optimized - On Windows, uses highly optimized ETW infrastructure
- Works with the standard logging pipeline - the
WebDriverBiDi.Loggingbridge forwards events toILogger, whoseLogmethod is synchronous
Overhead:
- With no listener attached, event emission is effectively free: the EventSource short-circuits before building any event data.
- With a listener attached, the per-event cost scales with the configured level and the amount of data captured, and is small relative to the network round-trips the events describe.
The repository does not include a benchmark for EventSource emission, so specific per-event timings are intentionally not quoted here.
Troubleshooting
Events Not Appearing
Verify EventSource is enabled:
if (WebDriverBiDiEventSource.RaiseEvent.IsEnabled()) { Console.WriteLine("EventSource is enabled"); }Check event level:
if (WebDriverBiDiEventSource.RaiseEvent.IsEnabled(EventLevel.Verbose, EventKeywords.None)) { Console.WriteLine("Verbose events are enabled"); }Ensure listener is created before WebDriverBiDi use:
// CORRECT: Listener created first using var listener1 = new MyEventListener(); await using var driver1 = new BiDiDriver(); // INCORRECT: Listener created after driver await using var driver2 = new BiDiDriver(); using var listener2 = new MyEventListener(); // May miss early eventsIf the application is published with Native AOT, enable EventSource support. The ILCompiler sets
EventSourceSupporttofalseby default, which makesEventSource.IsEnabled()return false permanently: no events are emitted, no listener is ever called, and the Microsoft.Extensions.Logging bridge produces nothing. Nothing throws and nothing is logged about it, so it presents exactly as "no events". The Web SDK (Microsoft.NET.Sdk.Web) sets it totruewhen the property is empty, so an ASP.NET Core application already has it; every other SDK needs the opt-in. Opt back in from the project file:<PropertyGroup> <PublishAot>true</PublishAot> <EventSourceSupport>true</EventSourceSupport> </PropertyGroup>This applies only to Native AOT publishing; a normal build, including a trimmed one, is unaffected.
High Event Volume
If experiencing performance issues:
- Increase event level (Info instead of Verbose)
- Filter specific events in
OnEventWritten - Async processing - Queue events for background processing
- Sampling - Process only 1 in N events for high-frequency events
See Also
- Getting Started - Basic library usage
- Common Pitfalls - Avoiding common mistakes
- Architecture - Understanding library structure
- Microsoft EventSource Documentation
- OpenTelemetry .NET