diff --git a/.gitignore b/.gitignore
index 246f2c4bdaf7d..aeb238e4e07a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -143,3 +143,7 @@ javascript/selenium-webdriver/.vscode/settings.json
dotnet-bin
.metadata/
.npmrc
+
+# HAR files and directories generated during tests
+*.har
+selenium-har-*/
diff --git a/dotnet/src/webdriver/BiDi/Network/Har/BiDi.HarExtensions.cs b/dotnet/src/webdriver/BiDi/Network/Har/BiDi.HarExtensions.cs
new file mode 100644
index 0000000000000..5499681e0963c
--- /dev/null
+++ b/dotnet/src/webdriver/BiDi/Network/Har/BiDi.HarExtensions.cs
@@ -0,0 +1,73 @@
+//
+// Licensed to the Software Freedom Conservancy (SFC) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The SFC licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Threading.Tasks;
+
+namespace OpenQA.Selenium.BiDi.Network.Har;
+
+///
+/// Extension methods for BiDi class to capture network traffic as HAR.
+///
+public static class BiDiHarExtensions
+{
+ ///
+ /// Records network traffic and returns a HAR recorder that can be used to save the recorded traffic.
+ ///
+ /// The BiDi instance.
+ /// Optional configuration options.
+ /// A task that represents the asynchronous operation and returns a IHarRecorder.
+ public static async Task RecordHarAsync(this BiDi bidi, HarRecordingOptions? options = null)
+ {
+ if (bidi is null) throw new ArgumentNullException(nameof(bidi));
+
+ var recorder = new HarRecorder(bidi, options ?? new HarRecordingOptions());
+ await recorder.StartAsync().ConfigureAwait(false);
+ return recorder;
+ }
+}
+
+///
+/// Options for HAR recording.
+///
+public sealed class HarRecordingOptions
+{
+ ///
+ /// Gets or sets the browser name to include in the HAR file.
+ ///
+ public string? BrowserName { get; set; }
+
+ ///
+ /// Gets or sets the browser version to include in the HAR file.
+ ///
+ public string? BrowserVersion { get; set; }
+}
+
+///
+/// Interface for recording network traffic and saving it as HAR format.
+///
+public interface IHarRecorder : IAsyncDisposable
+{
+ ///
+ /// Saves the captured network traffic to a HAR file.
+ ///
+ /// The path where the HAR file should be saved.
+ /// A task that represents the asynchronous operation.
+ Task SaveAsync(string filePath);
+}
diff --git a/dotnet/src/webdriver/BiDi/Network/Har/HarEntry.cs b/dotnet/src/webdriver/BiDi/Network/Har/HarEntry.cs
new file mode 100644
index 0000000000000..e633874fca361
--- /dev/null
+++ b/dotnet/src/webdriver/BiDi/Network/Har/HarEntry.cs
@@ -0,0 +1,469 @@
+//
+// Licensed to the Software Freedom Conservancy (SFC) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The SFC licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+
+namespace OpenQA.Selenium.BiDi.Network.Har;
+
+///
+/// Represents a HAR entry object containing request and response information.
+///
+public sealed class HarEntry
+{
+ ///
+ /// Gets or sets the reference to the parent page.
+ ///
+ public string? Pageref { get; set; }
+
+ ///
+ /// Gets or sets the date and time stamp of the request start.
+ ///
+ public string StartedDateTime { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the total elapsed time in milliseconds.
+ ///
+ public double Time { get; set; }
+
+ ///
+ /// Gets or sets the request information.
+ ///
+ public HarRequest Request { get; set; } = new HarRequest();
+
+ ///
+ /// Gets or sets the response information.
+ ///
+ public HarResponse Response { get; set; } = new HarResponse();
+
+ ///
+ /// Gets or sets the cache information.
+ ///
+ public HarCache Cache { get; set; } = new HarCache();
+
+ ///
+ /// Gets or sets the timing information.
+ ///
+ public HarTimings Timings { get; set; } = new HarTimings();
+
+ ///
+ /// Gets or sets the server IP address.
+ ///
+ public string? ServerIPAddress { get; set; }
+
+ ///
+ /// Gets or sets the connection information.
+ ///
+ public string? Connection { get; set; }
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents a HAR request object.
+///
+public sealed class HarRequest
+{
+ ///
+ /// Gets or sets the request method.
+ ///
+ public string Method { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the absolute URL of the request.
+ ///
+ public string Url { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the request HTTP version.
+ ///
+ public string HttpVersion { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the list of cookie objects.
+ ///
+ public List Cookies { get; set; } = new List();
+
+ ///
+ /// Gets or sets the list of header objects.
+ ///
+ public List Headers { get; set; } = new List();
+
+ ///
+ /// Gets or sets the list of query parameter objects.
+ ///
+ public List QueryString { get; set; } = new List();
+
+ ///
+ /// Gets or sets the posted data information.
+ ///
+ public HarPostData? PostData { get; set; }
+
+ ///
+ /// Gets or sets the total number of bytes from the start of the HTTP request message.
+ ///
+ public long HeadersSize { get; set; } = -1;
+
+ ///
+ /// Gets or sets the size of the request body in bytes.
+ ///
+ public long BodySize { get; set; } = -1;
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents a HAR response object.
+///
+public sealed class HarResponse
+{
+ ///
+ /// Gets or sets the response status code.
+ ///
+ public int Status { get; set; }
+
+ ///
+ /// Gets or sets the response status description.
+ ///
+ public string StatusText { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the response HTTP version.
+ ///
+ public string HttpVersion { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the list of cookie objects.
+ ///
+ public List Cookies { get; set; } = new List();
+
+ ///
+ /// Gets or sets the list of header objects.
+ ///
+ public List Headers { get; set; } = new List();
+
+ ///
+ /// Gets or sets the response body content.
+ ///
+ public HarContent Content { get; set; } = new HarContent();
+
+ ///
+ /// Gets or sets the redirection target URL from the Location response header.
+ ///
+ public string RedirectURL { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the total number of bytes from the start of the HTTP response message.
+ ///
+ public long HeadersSize { get; set; } = -1;
+
+ ///
+ /// Gets or sets the size of the received response body in bytes.
+ ///
+ public long BodySize { get; set; } = -1;
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents a HAR cookie object.
+///
+public sealed class HarCookie
+{
+ ///
+ /// Gets or sets the cookie name.
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the cookie value.
+ ///
+ public string Value { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the path pertaining to the cookie.
+ ///
+ public string? Path { get; set; }
+
+ ///
+ /// Gets or sets the host of the cookie.
+ ///
+ public string? Domain { get; set; }
+
+ ///
+ /// Gets or sets the cookie expiration time.
+ ///
+ public string? Expires { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the cookie is HTTP only.
+ ///
+ public bool? HttpOnly { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the cookie is secure.
+ ///
+ public bool? Secure { get; set; }
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents a HAR header object.
+///
+public sealed class HarHeader
+{
+ ///
+ /// Gets or sets the header name.
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the header value.
+ ///
+ public string Value { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents a HAR query parameter object.
+///
+public sealed class HarQueryParam
+{
+ ///
+ /// Gets or sets the query parameter name.
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the query parameter value.
+ ///
+ public string Value { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents HAR post data information.
+///
+public sealed class HarPostData
+{
+ ///
+ /// Gets or sets the MIME type of the posted data.
+ ///
+ public string MimeType { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the list of posted parameters.
+ ///
+ public List? Params { get; set; }
+
+ ///
+ /// Gets or sets the plain text posted data.
+ ///
+ public string? Text { get; set; }
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents a HAR post parameter object.
+///
+public sealed class HarPostParam
+{
+ ///
+ /// Gets or sets the parameter name.
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the parameter value.
+ ///
+ public string? Value { get; set; }
+
+ ///
+ /// Gets or sets the name of the uploaded file.
+ ///
+ public string? FileName { get; set; }
+
+ ///
+ /// Gets or sets the content type of the uploaded file.
+ ///
+ public string? ContentType { get; set; }
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents HAR content information.
+///
+public sealed class HarContent
+{
+ ///
+ /// Gets or sets the length of the content in bytes.
+ ///
+ public long Size { get; set; }
+
+ ///
+ /// Gets or sets the length of the returned content in bytes.
+ ///
+ public long? Compression { get; set; }
+
+ ///
+ /// Gets or sets the MIME type of the response.
+ ///
+ public string MimeType { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the response body text.
+ ///
+ public string? Text { get; set; }
+
+ ///
+ /// Gets or sets the encoding used for the response text.
+ ///
+ public string? Encoding { get; set; }
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents HAR cache information.
+///
+public sealed class HarCache
+{
+ ///
+ /// Gets or sets the state of the cache entry before the request.
+ ///
+ public HarCacheEntry? BeforeRequest { get; set; }
+
+ ///
+ /// Gets or sets the state of the cache entry after the request.
+ ///
+ public HarCacheEntry? AfterRequest { get; set; }
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents a HAR cache entry object.
+///
+public sealed class HarCacheEntry
+{
+ ///
+ /// Gets or sets the expiration time of the cache entry.
+ ///
+ public string? Expires { get; set; }
+
+ ///
+ /// Gets or sets the last accessed time of the cache entry.
+ ///
+ public string LastAccess { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the ETag.
+ ///
+ public string ETag { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the number of times the entry has been opened.
+ ///
+ public int HitCount { get; set; }
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents HAR timing information.
+///
+public sealed class HarTimings
+{
+ ///
+ /// Gets or sets the time spent in a queue waiting for a network connection.
+ ///
+ public double Blocked { get; set; } = -1;
+
+ ///
+ /// Gets or sets the DNS resolution time.
+ ///
+ public double Dns { get; set; } = -1;
+
+ ///
+ /// Gets or sets the time required to create a TCP connection.
+ ///
+ public double Connect { get; set; } = -1;
+
+ ///
+ /// Gets or sets the time required for SSL/TLS negotiation.
+ ///
+ public double Ssl { get; set; } = -1;
+
+ ///
+ /// Gets or sets the time required to send the HTTP request to the server.
+ ///
+ public double Send { get; set; } = -1;
+
+ ///
+ /// Gets or sets the waiting for a response from the server.
+ ///
+ public double Wait { get; set; } = -1;
+
+ ///
+ /// Gets or sets the time required to read the entire response from the server.
+ ///
+ public double Receive { get; set; } = -1;
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
diff --git a/dotnet/src/webdriver/BiDi/Network/Har/HarLog.cs b/dotnet/src/webdriver/BiDi/Network/Har/HarLog.cs
new file mode 100644
index 0000000000000..213e75daf48dd
--- /dev/null
+++ b/dotnet/src/webdriver/BiDi/Network/Har/HarLog.cs
@@ -0,0 +1,163 @@
+//
+// Licensed to the Software Freedom Conservancy (SFC) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The SFC licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+
+namespace OpenQA.Selenium.BiDi.Network.Har;
+
+///
+/// Represents the root object of exported HAR data.
+///
+public sealed class HarFile
+{
+ ///
+ /// Gets or sets the log object.
+ ///
+ public HarLog Log { get; set; } = new HarLog();
+}
+
+///
+/// Represents a HAR log object.
+///
+public sealed class HarLog
+{
+ ///
+ /// Gets or sets the version of the HAR format.
+ ///
+ public string Version { get; set; } = "1.2";
+
+ ///
+ /// Gets or sets the creator information.
+ ///
+ public HarCreator Creator { get; set; } = new HarCreator();
+
+ ///
+ /// Gets or sets the browser information.
+ ///
+ public HarBrowser? Browser { get; set; }
+
+ ///
+ /// Gets or sets the list of page objects.
+ ///
+ public List Pages { get; set; } = new List();
+
+ ///
+ /// Gets or sets the list of entry objects.
+ ///
+ public List Entries { get; set; } = new List();
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents the creator information.
+///
+public sealed class HarCreator
+{
+ ///
+ /// Gets or sets the name of the creator.
+ ///
+ public string Name { get; set; } = "Selenium";
+
+ ///
+ /// Gets or sets the version of the creator.
+ ///
+ public string Version { get; set; } = "4.0";
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents the browser information.
+///
+public sealed class HarBrowser
+{
+ ///
+ /// Gets or sets the name of the browser.
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the version of the browser.
+ ///
+ public string Version { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents a page object.
+///
+public sealed class HarPage
+{
+ ///
+ /// Gets or sets the unique identifier for the page.
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the page title.
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the date and time stamp for the page.
+ ///
+ public string StartedDateTime { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the page timings.
+ ///
+ public HarPageTimings PageTimings { get; set; } = new HarPageTimings();
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
+
+///
+/// Represents page timing information.
+///
+public sealed class HarPageTimings
+{
+ ///
+ /// Gets or sets the time in milliseconds for the page to load.
+ ///
+ public double OnContentLoad { get; set; } = -1;
+
+ ///
+ /// Gets or sets the time in milliseconds for the page to finish loading.
+ ///
+ public double OnLoad { get; set; } = -1;
+
+ ///
+ /// Gets or sets an optional comment.
+ ///
+ public string? Comment { get; set; }
+}
diff --git a/dotnet/src/webdriver/BiDi/Network/Har/HarRecorder.cs b/dotnet/src/webdriver/BiDi/Network/Har/HarRecorder.cs
new file mode 100644
index 0000000000000..34e62d7b3a392
--- /dev/null
+++ b/dotnet/src/webdriver/BiDi/Network/Har/HarRecorder.cs
@@ -0,0 +1,410 @@
+//
+// Licensed to the Software Freedom Conservancy (SFC) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The SFC licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+namespace OpenQA.Selenium.BiDi.Network.Har;
+
+///
+/// Records network traffic and provides methods to save it as HAR format.
+///
+public sealed class HarRecorder : IHarRecorder
+{
+ private readonly BiDi _bidi;
+ private readonly HarRecordingOptions _options;
+ private readonly HarFile _harFile;
+ private readonly Dictionary _pendingRequests;
+ private readonly string _tempDirectoryPath;
+ private Subscription? _beforeRequestSubscription;
+ private Subscription? _responseStartedSubscription;
+ private Subscription? _responseCompletedSubscription;
+ private Collector? _dataCollector;
+
+ internal HarRecorder(BiDi bidi, HarRecordingOptions options)
+ {
+ _bidi = bidi ?? throw new ArgumentNullException(nameof(bidi));
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ _harFile = new HarFile();
+ _pendingRequests = new Dictionary();
+ _tempDirectoryPath = Path.Combine(Path.GetTempPath(), $"selenium-har-{Guid.NewGuid()}");
+ Directory.CreateDirectory(_tempDirectoryPath);
+
+ if (!string.IsNullOrEmpty(options.BrowserName))
+ {
+ _harFile.Log.Browser = new HarBrowser
+ {
+ Name = options.BrowserName,
+ Version = options.BrowserVersion ?? string.Empty
+ };
+ }
+ }
+
+ internal async Task StartAsync()
+ {
+ // Always create data collector for capturing request and response bodies
+ _dataCollector = await _bidi.Network.AddDataCollectorAsync([DataType.Request, DataType.Response], 200000000).ConfigureAwait(false);
+
+ _beforeRequestSubscription = await _bidi.Network.OnBeforeRequestSentAsync(OnBeforeRequestSent).ConfigureAwait(false);
+ _responseStartedSubscription = await _bidi.Network.OnResponseStartedAsync(OnResponseStarted).ConfigureAwait(false);
+ _responseCompletedSubscription = await _bidi.Network.OnResponseCompletedAsync(OnResponseCompleted).ConfigureAwait(false);
+ }
+
+ private void OnBeforeRequestSent(BeforeRequestSentEventArgs args)
+ {
+ var entry = new HarEntry
+ {
+ StartedDateTime = args.Timestamp.ToString("o"),
+ Request = ConvertRequest(args.Request),
+ Timings = ConvertTimings(args.Request.Timings),
+ Time = 0
+ };
+
+ if (args.Context != null)
+ {
+ entry.Pageref = args.Context.Id;
+ }
+
+ lock (_pendingRequests)
+ {
+ _pendingRequests[args.Request.Request.Id] = entry;
+ }
+ }
+
+ private void OnResponseStarted(ResponseStartedEventArgs args)
+ {
+ lock (_pendingRequests)
+ {
+ if (_pendingRequests.TryGetValue(args.Request.Request.Id, out var entry))
+ {
+ entry.Response = ConvertResponse(args.Response);
+ }
+ }
+ }
+
+ private async void OnResponseCompleted(ResponseCompletedEventArgs args)
+ {
+ HarEntry? entry = null;
+
+ lock (_pendingRequests)
+ {
+ if (_pendingRequests.TryGetValue(args.Request.Request.Id, out entry))
+ {
+ entry.Response = ConvertResponse(args.Response);
+
+ // Calculate total time
+ var timings = args.Request.Timings;
+ entry.Time = CalculateTotalTime(timings);
+ }
+ }
+
+ if (entry != null)
+ {
+ // Retrieve request and response bodies
+ if (_dataCollector != null)
+ {
+ try
+ {
+ // Get request body
+ var requestBody = await _bidi.Network.GetDataAsync(DataType.Request, args.Request.Request).ConfigureAwait(false);
+ if (requestBody != null)
+ {
+ entry.Request.PostData = new HarPostData
+ {
+ MimeType = GetContentType(entry.Request.Headers),
+ Text = (string)requestBody
+ };
+ }
+ }
+ catch
+ {
+ // Request body may not be available for all requests (e.g., GET requests)
+ }
+
+ try
+ {
+ // Get response body
+ var responseBody = await _bidi.Network.GetDataAsync(DataType.Response, args.Request.Request).ConfigureAwait(false);
+ if (responseBody != null)
+ {
+ var bodyText = (string)responseBody;
+ entry.Response.Content.Text = bodyText;
+ entry.Response.Content.Size = System.Text.Encoding.UTF8.GetByteCount(bodyText);
+ }
+ }
+ catch
+ {
+ // Response body may not be available for all responses
+ }
+ }
+
+ // Flush entry to dedicated temp file (outside of lock for better concurrency)
+ FlushEntryToTempFile(entry, args.Request.Request.Id);
+
+ lock (_pendingRequests)
+ {
+ _pendingRequests.Remove(args.Request.Request.Id);
+ }
+ }
+ }
+
+ private void FlushEntryToTempFile(HarEntry entry, string requestId)
+ {
+ // Use dedicated file per entry for better concurrency safety
+ var sanitizedId = string.Concat(requestId.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'));
+ var fileName = $"{sanitizedId}_{Guid.NewGuid()}.json";
+ var filePath = Path.Combine(_tempDirectoryPath, fileName);
+
+ var jsonOptions = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
+ };
+
+ var entryJson = JsonSerializer.Serialize(entry, jsonOptions);
+ File.WriteAllText(filePath, entryJson);
+ }
+
+ private string GetContentType(List headers)
+ {
+ var contentTypeHeader = headers.FirstOrDefault(h => h.Name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase));
+ return contentTypeHeader?.Value ?? "application/octet-stream";
+ }
+
+ private HarRequest ConvertRequest(RequestData request)
+ {
+ var harRequest = new HarRequest
+ {
+ Method = request.Method,
+ Url = request.Url,
+ HttpVersion = "HTTP/1.1",
+ HeadersSize = request.HeadersSize ?? -1,
+ BodySize = request.BodySize ?? -1
+ };
+
+ foreach (var header in request.Headers)
+ {
+ harRequest.Headers.Add(new HarHeader
+ {
+ Name = header.Name,
+ Value = (string)header.Value
+ });
+ }
+
+ foreach (var cookie in request.Cookies)
+ {
+ harRequest.Cookies.Add(new HarCookie
+ {
+ Name = cookie.Name,
+ Value = (string)cookie.Value,
+ Domain = cookie.Domain,
+ Path = cookie.Path,
+ HttpOnly = cookie.HttpOnly,
+ Secure = cookie.Secure,
+ Expires = cookie.Expiry?.ToString("o")
+ });
+ }
+
+ // Parse query string from URL
+ var uri = new Uri(request.Url);
+ if (!string.IsNullOrEmpty(uri.Query))
+ {
+ var queryString = uri.Query.TrimStart('?');
+ var queryParams = queryString.Split('&');
+ foreach (var param in queryParams)
+ {
+ var parts = param.Split('=', 2);
+ harRequest.QueryString.Add(new HarQueryParam
+ {
+ Name = Uri.UnescapeDataString(parts[0]),
+ Value = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty
+ });
+ }
+ }
+
+ return harRequest;
+ }
+
+ private HarResponse ConvertResponse(ResponseData response)
+ {
+ var harResponse = new HarResponse
+ {
+ Status = response.Status,
+ StatusText = response.StatusText,
+ HttpVersion = response.Protocol,
+ HeadersSize = response.HeadersSize ?? -1,
+ BodySize = response.BodySize ?? -1,
+ Content = new HarContent
+ {
+ Size = response.BodySize ?? 0,
+ MimeType = response.MimeType
+ }
+ };
+
+ foreach (var header in response.Headers)
+ {
+ var headerValue = (string)header.Value;
+ harResponse.Headers.Add(new HarHeader
+ {
+ Name = header.Name,
+ Value = headerValue
+ });
+
+ // Check for redirect URL
+ if (header.Name.Equals("Location", StringComparison.OrdinalIgnoreCase))
+ {
+ harResponse.RedirectURL = headerValue;
+ }
+ }
+
+ return harResponse;
+ }
+
+ private HarTimings ConvertTimings(FetchTimingInfo timings)
+ {
+ return new HarTimings
+ {
+ Blocked = -1,
+ Dns = CalculateDuration(timings.DnsStart, timings.DnsEnd),
+ Connect = CalculateDuration(timings.ConnectStart, timings.ConnectEnd),
+ Ssl = CalculateDuration(timings.TlsStart, timings.ConnectEnd),
+ Send = CalculateDuration(timings.RequestStart, timings.RequestStart),
+ Wait = CalculateDuration(timings.RequestStart, timings.ResponseStart),
+ Receive = CalculateDuration(timings.ResponseStart, timings.ResponseEnd)
+ };
+ }
+
+ private double CalculateDuration(double start, double end)
+ {
+ if (start < 0 || end < 0 || end < start)
+ {
+ return -1;
+ }
+ return end - start;
+ }
+
+ private double CalculateTotalTime(FetchTimingInfo timings)
+ {
+ if (timings.FetchStart >= 0 && timings.ResponseEnd >= 0 && timings.ResponseEnd >= timings.FetchStart)
+ {
+ return timings.ResponseEnd - timings.FetchStart;
+ }
+ return 0;
+ }
+
+ private void LoadEntriesFromTempFiles()
+ {
+ _harFile.Log.Entries.Clear();
+
+ if (Directory.Exists(_tempDirectoryPath))
+ {
+ var jsonOptions = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ var files = Directory.GetFiles(_tempDirectoryPath, "*.json");
+ foreach (var file in files)
+ {
+ try
+ {
+ var entryJson = File.ReadAllText(file);
+ var entry = JsonSerializer.Deserialize(entryJson, jsonOptions);
+ if (entry != null)
+ {
+ _harFile.Log.Entries.Add(entry);
+ }
+ }
+ catch
+ {
+ // Skip corrupted or incomplete files
+ }
+ }
+ }
+ }
+
+ ///
+ /// Saves the captured network traffic to a HAR file.
+ ///
+ /// The path where the HAR file should be saved.
+ /// A task that represents the asynchronous operation.
+ public async Task SaveAsync(string filePath)
+ {
+ if (string.IsNullOrEmpty(filePath))
+ {
+ throw new ArgumentException("File path cannot be null or empty.", nameof(filePath));
+ }
+
+ // Load entries from temp files before saving
+ LoadEntriesFromTempFiles();
+
+ var options = new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
+ };
+
+ var json = JsonSerializer.Serialize(_harFile, options);
+ await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false);
+ }
+
+ ///
+ /// Disposes the recorder and unsubscribes from network events.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (_beforeRequestSubscription != null)
+ {
+ await _beforeRequestSubscription.DisposeAsync().ConfigureAwait(false);
+ }
+
+ if (_responseStartedSubscription != null)
+ {
+ await _responseStartedSubscription.DisposeAsync().ConfigureAwait(false);
+ }
+
+ if (_responseCompletedSubscription != null)
+ {
+ await _responseCompletedSubscription.DisposeAsync().ConfigureAwait(false);
+ }
+
+ if (_dataCollector != null)
+ {
+ await _bidi.Network.RemoveDataCollectorAsync(_dataCollector).ConfigureAwait(false);
+ }
+
+ // Clean up temp directory
+ if (Directory.Exists(_tempDirectoryPath))
+ {
+ try
+ {
+ Directory.Delete(_tempDirectoryPath, recursive: true);
+ }
+ catch
+ {
+ // Ignore errors when deleting temp directory
+ }
+ }
+ }
+}
diff --git a/dotnet/src/webdriver/BiDi/Network/Har/README.md b/dotnet/src/webdriver/BiDi/Network/Har/README.md
new file mode 100644
index 0000000000000..8179a1b5c7d06
--- /dev/null
+++ b/dotnet/src/webdriver/BiDi/Network/Har/README.md
@@ -0,0 +1,73 @@
+# HAR Recording Extension for BiDi
+
+This extension provides the ability to record network traffic using the BiDi protocol and export it to HAR (HTTP Archive) format, including request and response body content.
+
+## Usage Example
+
+```csharp
+using OpenQA.Selenium;
+using OpenQA.Selenium.Chrome;
+using OpenQA.Selenium.BiDi;
+using OpenQA.Selenium.BiDi.Network.Har;
+
+// Create a WebDriver instance with BiDi enabled
+var options = new ChromeOptions();
+options.AddArgument("--remote-allow-origins=*");
+options.EnableBiDi();
+
+using var driver = new ChromeDriver(options);
+
+// Connect to BiDi
+await using var bidi = await driver.AsBiDiAsync();
+
+// Start recording network traffic (includes request/response bodies by default)
+await using var recorder = await bidi.RecordHarAsync(new HarRecordingOptions
+{
+ BrowserName = "Chrome",
+ BrowserVersion = "120.0"
+});
+
+// Navigate to a page
+driver.Navigate().GoToUrl("https://www.example.com");
+
+// Wait for some network activity
+await Task.Delay(2000);
+
+// Save the recorded traffic to a HAR file
+await recorder.SaveAsync("network-traffic.har");
+```
+
+## HAR Recording Options
+
+The `HarRecordingOptions` class allows you to configure the recording:
+
+- `BrowserName`: The browser name to include in the HAR metadata
+- `BrowserVersion`: The browser version to include in the HAR metadata
+
+## HAR File Format
+
+The generated HAR file follows the HAR 1.2 specification and includes:
+
+- Request details (method, URL, headers, cookies, query parameters)
+- Response details (status code, headers, content type)
+- Timing information (DNS, connect, SSL, send, wait, receive)
+- Request/response body content (automatically recorded)
+- Metadata (browser info, timestamps)
+
+## Body Content Recording
+
+By default, the HAR recorder records request and response bodies for all network traffic. A network data collector is automatically created when you start recording traffic. This provides complete visibility into all request and response payloads.
+
+**Memory Optimization:** To minimize memory usage, recorded network entries are written to a temporary file as they are completed. The entries are only loaded into memory when you call `SaveAsync()`. This allows for recording large amounts of network traffic without consuming excessive memory.
+
+**Note:** Recording request/response bodies may increase memory usage for large requests/responses.
+
+## Disposing the Recorder
+
+The `IHarRecorder` implements `IAsyncDisposable` and should be disposed properly to unsubscribe from network events and clean up the data collector:
+
+```csharp
+await using var recorder = await bidi.RecordHarAsync();
+// ... record network traffic ...
+// Dispose is called automatically when leaving the using block
+```
diff --git a/dotnet/test/common/BiDi/Network/HarRecordingTest.cs b/dotnet/test/common/BiDi/Network/HarRecordingTest.cs
new file mode 100644
index 0000000000000..9c0db50188be6
--- /dev/null
+++ b/dotnet/test/common/BiDi/Network/HarRecordingTest.cs
@@ -0,0 +1,190 @@
+//
+// Licensed to the Software Freedom Conservancy (SFC) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The SFC licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using NUnit.Framework;
+using OpenQA.Selenium.BiDi.BrowsingContext;
+using OpenQA.Selenium.BiDi.Network.Har;
+using System;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+namespace OpenQA.Selenium.BiDi.Network;
+
+class HarRecordingTest : BiDiTestFixture
+{
+ [Test]
+ public async Task CanRecordNetworkTrafficToHar()
+ {
+ var tempFile = Path.Combine(Path.GetTempPath(), $"selenium-har-{Guid.NewGuid()}.har");
+
+ try
+ {
+ await using var recorder = await bidi.RecordHarAsync(new HarRecordingOptions
+ {
+ BrowserName = "TestBrowser",
+ BrowserVersion = "1.0"
+ });
+
+ await context.NavigateAsync(UrlBuilder.WhereIs("bidi/logEntryAdded.html"), new() { Wait = ReadinessState.Complete });
+
+ await recorder.SaveAsync(tempFile);
+
+ Assert.That(File.Exists(tempFile), Is.True);
+
+ var jsonContent = await File.ReadAllTextAsync(tempFile);
+ Assert.That(jsonContent, Is.Not.Empty);
+
+ var harFile = JsonSerializer.Deserialize(jsonContent, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+
+ Assert.That(harFile, Is.Not.Null);
+ Assert.That(harFile.Log, Is.Not.Null);
+ Assert.That(harFile.Log.Version, Is.EqualTo("1.2"));
+ Assert.That(harFile.Log.Creator.Name, Is.EqualTo("Selenium"));
+ Assert.That(harFile.Log.Browser, Is.Not.Null);
+ Assert.That(harFile.Log.Browser.Name, Is.EqualTo("TestBrowser"));
+ Assert.That(harFile.Log.Browser.Version, Is.EqualTo("1.0"));
+ Assert.That(harFile.Log.Entries, Is.Not.Empty);
+
+ var entry = harFile.Log.Entries.FirstOrDefault(e => e.Request.Url.Contains("logEntryAdded.html"));
+ Assert.That(entry, Is.Not.Null);
+ Assert.That(entry.Request.Method, Is.EqualTo("GET"));
+ Assert.That(entry.Response.Status, Is.EqualTo(200));
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Test]
+ public async Task CanSaveHarToFile()
+ {
+ var tempFile = Path.Combine(Path.GetTempPath(), $"selenium-har-{Guid.NewGuid()}.har");
+
+ try
+ {
+ await using var recorder = await bidi.RecordHarAsync();
+
+ await context.NavigateAsync(UrlBuilder.WhereIs("bidi/logEntryAdded.html"), new() { Wait = ReadinessState.Complete });
+
+ await recorder.SaveAsync(tempFile);
+
+ Assert.That(File.Exists(tempFile), Is.True);
+
+ var jsonContent = await File.ReadAllTextAsync(tempFile);
+ Assert.That(jsonContent, Is.Not.Empty);
+
+ var harFile = JsonSerializer.Deserialize(jsonContent, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+
+ Assert.That(harFile, Is.Not.Null);
+ Assert.That(harFile.Log, Is.Not.Null);
+ Assert.That(harFile.Log.Entries, Is.Not.Empty);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Test]
+ public async Task HarEntriesContainRequestDetails()
+ {
+ var tempFile = Path.Combine(Path.GetTempPath(), $"selenium-har-{Guid.NewGuid()}.har");
+
+ try
+ {
+ await using var recorder = await bidi.RecordHarAsync();
+
+ await context.NavigateAsync(UrlBuilder.WhereIs("bidi/logEntryAdded.html"), new() { Wait = ReadinessState.Complete });
+
+ await recorder.SaveAsync(tempFile);
+
+ var jsonContent = await File.ReadAllTextAsync(tempFile);
+ var harFile = JsonSerializer.Deserialize(jsonContent, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+
+ var entry = harFile.Log.Entries.FirstOrDefault(e => e.Request.Url.Contains("logEntryAdded.html"));
+
+ Assert.That(entry, Is.Not.Null);
+ Assert.That(entry.StartedDateTime, Is.Not.Empty);
+ Assert.That(entry.Time, Is.GreaterThanOrEqualTo(0));
+ Assert.That(entry.Request.Headers, Is.Not.Empty);
+ Assert.That(entry.Response.Headers, Is.Not.Empty);
+ Assert.That(entry.Timings, Is.Not.Null);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Test]
+ public async Task CanRecordRequestAndResponseBodies()
+ {
+ var tempFile = Path.Combine(Path.GetTempPath(), $"selenium-har-{Guid.NewGuid()}.har");
+
+ try
+ {
+ await using var recorder = await bidi.RecordHarAsync();
+
+ await context.NavigateAsync(UrlBuilder.WhereIs("bidi/logEntryAdded.html"), new() { Wait = ReadinessState.Complete });
+
+ await recorder.SaveAsync(tempFile);
+
+ var jsonContent = await File.ReadAllTextAsync(tempFile);
+ var harFile = JsonSerializer.Deserialize(jsonContent, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+
+ var entry = harFile.Log.Entries.FirstOrDefault(e => e.Request.Url.Contains("logEntryAdded.html"));
+
+ Assert.That(entry, Is.Not.Null);
+ Assert.That(entry.Response.Content.Text, Is.Not.Null);
+ Assert.That(entry.Response.Content.Text, Is.Not.Empty);
+ Assert.That(entry.Response.Content.Size, Is.GreaterThan(0));
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+}