From ee3b9ebc575ce147ca5e84cdad447c31f6acaade Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 9 May 2025 10:41:46 +1000 Subject: [PATCH] 2025.1 API changes to support WebSockets, query tracing, node connection state --- example/SeqEnableAAD/SeqEnableAAD.csproj | 2 +- example/SeqQuery/SeqQuery.csproj | 2 +- example/SeqTail/Program.cs | 30 ++- example/SeqTail/SeqTail.csproj | 2 +- example/SignalCopy/SignalCopy.csproj | 2 +- src/Seq.Api/Client/SeqApiClient.cs | 171 +++++++++---- src/Seq.Api/Client/SeqApiException.cs | 56 +++-- .../Model/Cluster/ClusterNodeEntity.cs | 9 +- .../ResourceGroups/DataResourceGroup.cs | 12 +- .../ResourceGroups/EventsResourceGroup.cs | 164 +++++++++++-- .../ResourceGroups/UsersResourceGroup.cs | 2 +- src/Seq.Api/Seq.Api.csproj | 14 +- src/Seq.Api/SeqConnection.cs | 28 --- .../Streams/ObservableStream.Unsubscriber.cs | 39 --- src/Seq.Api/Streams/ObservableStream.cs | 230 ------------------ .../Streams/WebSocketTaskExtensions.cs | 33 +++ test/Seq.Api.Tests/SeqConnectionTests.cs | 17 +- 17 files changed, 385 insertions(+), 428 deletions(-) delete mode 100644 src/Seq.Api/Streams/ObservableStream.Unsubscriber.cs delete mode 100644 src/Seq.Api/Streams/ObservableStream.cs create mode 100644 src/Seq.Api/Streams/WebSocketTaskExtensions.cs diff --git a/example/SeqEnableAAD/SeqEnableAAD.csproj b/example/SeqEnableAAD/SeqEnableAAD.csproj index 77cb863..c7dddb1 100644 --- a/example/SeqEnableAAD/SeqEnableAAD.csproj +++ b/example/SeqEnableAAD/SeqEnableAAD.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net9.0 seq-enable-aad enable diff --git a/example/SeqQuery/SeqQuery.csproj b/example/SeqQuery/SeqQuery.csproj index a63bcac..0b8eecc 100644 --- a/example/SeqQuery/SeqQuery.csproj +++ b/example/SeqQuery/SeqQuery.csproj @@ -1,7 +1,7 @@  - net6.0 + net9.0 Exe seq-query enable diff --git a/example/SeqTail/Program.cs b/example/SeqTail/Program.cs index 0122366..7aff26b 100644 --- a/example/SeqTail/Program.cs +++ b/example/SeqTail/Program.cs @@ -3,10 +3,10 @@ using DocoptNet; using Seq.Api; using Serilog; -using System.Reactive.Linq; using Serilog.Formatting.Compact.Reader; using System.Threading; -using Newtonsoft.Json.Linq; + +// ReSharper disable AccessToDisposedClosure const string usage = @"seq-tail: watch a Seq query from your console. @@ -29,17 +29,17 @@ try { - var arguments = new Docopt().Apply(usage, args, version: "Seq Tail 0.2", exit: true)!; + var arguments = new Docopt().Apply(usage, args, version: "Seq Tail 0.3", exit: true)!; var server = arguments[""].ToString(); var apiKey = Normalize(arguments["--apikey"]); var filter = Normalize(arguments["--filter"]); - var cancel = new CancellationTokenSource(); + using var cts = new CancellationTokenSource(); Console.WriteLine("Tailing, press Ctrl+C to exit."); - Console.CancelKeyPress += (_,_) => cancel.Cancel(); + Console.CancelKeyPress += (_,_) => cts.Cancel(); - var run = Task.Run(() => Run(server, apiKey, filter, cancel), cancel.Token); + var run = Task.Run(() => Run(server, apiKey, filter, cts.Token), cts.Token); run.GetAwaiter().GetResult(); } @@ -56,22 +56,20 @@ return string.IsNullOrWhiteSpace(s) ? null : s; } -static async Task Run(string server, string? apiKey, string? filter, CancellationTokenSource cancel) +static async Task Run(string server, string? apiKey, string? filter, CancellationToken cancel) { var connection = new SeqConnection(server, apiKey); - string? strict = null; + string? strictFilter = null; if (filter != null) { var converted = await connection.Expressions.ToStrictAsync(filter); - strict = converted.StrictExpression; + strictFilter = converted.StrictExpression; } - using var stream = await connection.Events.StreamAsync(filter: strict); - var subscription = stream - .Select(LogEventReader.ReadFromJObject) - .Subscribe(Log.Write, cancel.Cancel); - - cancel.Token.WaitHandle.WaitOne(); - subscription.Dispose(); + await foreach (var evt in connection.Events.StreamDocumentsAsync(filter: strictFilter, clef: true, cancellationToken: cancel)) + { + var logEvent = LogEventReader.ReadFromString(evt); + Log.Write(logEvent); + } } diff --git a/example/SeqTail/SeqTail.csproj b/example/SeqTail/SeqTail.csproj index 9bf2168..d84543d 100644 --- a/example/SeqTail/SeqTail.csproj +++ b/example/SeqTail/SeqTail.csproj @@ -1,7 +1,7 @@  - net6.0 + net9.0 seq-tail Exe enable diff --git a/example/SignalCopy/SignalCopy.csproj b/example/SignalCopy/SignalCopy.csproj index 019cb75..0f30840 100644 --- a/example/SignalCopy/SignalCopy.csproj +++ b/example/SignalCopy/SignalCopy.csproj @@ -1,7 +1,7 @@  - net6.0 + net9.0 signal-copy Exe enable diff --git a/src/Seq.Api/Client/SeqApiClient.cs b/src/Seq.Api/Client/SeqApiClient.cs index b2c4d1b..ac9a718 100644 --- a/src/Seq.Api/Client/SeqApiClient.cs +++ b/src/Seq.Api/Client/SeqApiClient.cs @@ -14,8 +14,8 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.IO; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -27,9 +27,10 @@ using Seq.Api.Model.Root; using Seq.Api.Serialization; using System.Threading; -using Seq.Api.Streams; using System.Net.WebSockets; +using System.Runtime.CompilerServices; using Seq.Api.Model.Shared; +using Seq.Api.Streams; namespace Seq.Api.Client { @@ -38,13 +39,13 @@ namespace Seq.Api.Client /// public sealed class SeqApiClient : IDisposable { - readonly string _apiKey; - // Future versions of Seq may not completely support vN-1 features, however // providing this as an Accept header will ensure what compatibility is available // can be utilized. const string SeqApiV11MediaType = "application/vnd.datalust.seq.v11+json"; + // ReSharper disable once NotAccessedField.Local + readonly bool _defaultMessageHandler; readonly CookieContainer _cookies = new(); readonly JsonSerializer _serializer = JsonSerializer.Create( new JsonSerializerSettings @@ -54,36 +55,6 @@ public sealed class SeqApiClient : IDisposable FloatParseHandling = FloatParseHandling.Decimal }); - /// - /// Construct a . - /// - /// The base URL of the Seq server. - /// An API key to use when making requests to the server, if required. - /// Whether default credentials will be sent with HTTP requests; the default is true. - [Obsolete("Prefer `SeqApiClient(serverUrl, apiKey, createHttpMessageHandler)` instead."), EditorBrowsable(EditorBrowsableState.Never)] - public SeqApiClient(string serverUrl, string apiKey, bool useDefaultCredentials) - : this(serverUrl, apiKey, handler => handler.UseDefaultCredentials = useDefaultCredentials) - { - } - - /// - /// Construct a . - /// - /// The base URL of the Seq server. - /// An API key to use when making requests to the server, if required. - /// An optional callback to configure the used when making HTTP requests - /// to the Seq API. - [Obsolete("Prefer `SeqApiClient(serverUrl, apiKey, createHttpMessageHandler)` instead."), EditorBrowsable(EditorBrowsableState.Never)] - public SeqApiClient(string serverUrl, string apiKey, Action configureHttpClientHandler) - : this(serverUrl, apiKey, cookies => - { - var handler = new HttpClientHandler { CookieContainer = cookies }; - configureHttpClientHandler?.Invoke(handler); - return handler; - }) - { - } - /// /// Construct a . /// @@ -93,6 +64,8 @@ public SeqApiClient(string serverUrl, string apiKey, Action c /// to the Seq API. The callback receives a that is shared with WebSocket requests made by the client. public SeqApiClient(string serverUrl, string apiKey = null, Func createHttpMessageHandler = null) { + _defaultMessageHandler = createHttpMessageHandler == null; + // This is required for compatibility with the obsolete constructor, which we can remove sometime in 2024. var httpMessageHandler = createHttpMessageHandler?.Invoke(_cookies) ?? #if SOCKETS_HTTP_HANDLER @@ -103,9 +76,6 @@ public SeqApiClient(string serverUrl, string apiKey = null, Func @@ -323,9 +293,27 @@ public async Task DeleteAsync(ILinked entity, str /// Named parameters to substitute into the link template, if required. /// A supporting cancellation. /// A stream of values from the websocket. - public async Task> StreamAsync(ILinked entity, string link, IDictionary parameters = null, CancellationToken cancellationToken = default) + public IAsyncEnumerable StreamAsync(ILinked entity, string link, IDictionary parameters = null, CancellationToken cancellationToken = default) + { + return WebSocketStreamAsync(entity, link, default, parameters, delegate { }, reader => _serializer.Deserialize(new JsonTextReader(reader)), cancellationToken); + } + + /// + /// Connect to a websocket at the address specified by following from . + /// When the WebSocket opens, a single message is sent, and then messages received on + /// socket are returned. + /// + /// The type of the values received over the websocket. + /// The type of message to send. + /// An entity previously retrieved from the API. + /// The name of the outbound link template present in 's collection. + /// The message to send at establishment of the WebSocket connection. + /// Named parameters to substitute into the link template, if required. + /// A supporting cancellation. + /// A stream of values from the websocket. + public IAsyncEnumerable StreamSendAsync(ILinked entity, string link, TMessage message, IDictionary parameters = null, CancellationToken cancellationToken = default) { - return await WebSocketStreamAsync(entity, link, parameters, reader => _serializer.Deserialize(new JsonTextReader(reader)), cancellationToken); + return WebSocketStreamAsync(entity, link, message, parameters, (writer, m) => _serializer.Serialize(writer, m), reader => _serializer.Deserialize(new JsonTextReader(reader)), cancellationToken); } /// @@ -336,23 +324,106 @@ public async Task> StreamAsync(ILinked entity /// Named parameters to substitute into the link template, if required. /// A supporting cancellation. /// A stream of raw messages from the websocket. - public async Task> StreamTextAsync(ILinked entity, string link, IDictionary parameters = null, CancellationToken cancellationToken = default) + public IAsyncEnumerable StreamTextAsync(ILinked entity, string link, IDictionary parameters = null, CancellationToken cancellationToken = default) + { + return WebSocketStreamAsync(entity, link, default, parameters, delegate { }, reader => reader.ReadToEnd(), cancellationToken); + } + + /// + /// Connect to a websocket at the address specified by following from . + /// When the WebSocket opens, a single message is sent, and then messages received on + /// socket are returned. + /// + /// The type of message to send. + /// An entity previously retrieved from the API. + /// The name of the outbound link template present in 's collection. + /// The message to send at establishment of the WebSocket connection. + /// Named parameters to substitute into the link template, if required. + /// A supporting cancellation. + /// A stream of raw messages from the websocket. + public IAsyncEnumerable StreamTextSendAsync(ILinked entity, string link, TMessage message, IDictionary parameters = null, CancellationToken cancellationToken = default) { - return await WebSocketStreamAsync(entity, link, parameters, reader => reader.ReadToEnd(), cancellationToken); + return WebSocketStreamAsync(entity, link, message, parameters, delegate { }, reader => reader.ReadToEnd(), cancellationToken); } - async Task> WebSocketStreamAsync(ILinked entity, string link, IDictionary parameters, Func deserialize, CancellationToken cancellationToken = default) + readonly struct NoMessage + { + // Type marker only. + } + + async IAsyncEnumerable WebSocketStreamAsync(ILinked entity, string link, TMessage message, IDictionary parameters, Action serialize, Func deserialize, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var linkUri = ResolveLink(entity, link, parameters); - var socket = new ClientWebSocket(); - socket.Options.Cookies = _cookies; - if (_apiKey != null) - socket.Options.SetRequestHeader("X-Seq-ApiKey", _apiKey); + using var socket = new ClientWebSocket(); + +#if WEBSOCKET_USE_HTTPCLIENT + if (_defaultMessageHandler) + { + await socket.ConnectAsync(new Uri(linkUri), HttpClient, cancellationToken).WithApiExceptions().ConfigureAwait(false); + } + else +#endif + { + socket.Options.Cookies = _cookies; - await socket.ConnectAsync(new Uri(linkUri), cancellationToken); + foreach (var header in HttpClient.DefaultRequestHeaders) + { + socket.Options.SetRequestHeader(header.Key, header.Value.FirstOrDefault()); + } - return new ObservableStream(socket, deserialize); + await socket.ConnectAsync(new Uri(linkUri), cancellationToken).WithApiExceptions() + .ConfigureAwait(false); + } + + var buffer = new byte[16 * 1024]; + var current = new MemoryStream(); + var encoding = new UTF8Encoding(false); + var reader = new StreamReader(current, encoding); + + if (message is not NoMessage) + { + var w = new StreamWriter(current, encoding); + serialize(w, message); + // ReSharper disable once MethodHasAsyncOverload + w.Flush(); + await socket.SendAsync(new ArraySegment(current.GetBuffer(), 0, (int)current.Length), + WebSocketMessageType.Text, true, cancellationToken).WithApiExceptions().ConfigureAwait(false); + current.Position = 0; + current.SetLength(0); + } + + while (socket.State == WebSocketState.Open) + { + cancellationToken.ThrowIfCancellationRequested(); + + var received = await socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken).WithApiExceptions().ConfigureAwait(false); + if (received.MessageType == WebSocketMessageType.Close) + { + if (socket.State != WebSocketState.Closed) + await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", cancellationToken).WithApiExceptions().ConfigureAwait(false); + + if (received.CloseStatus != WebSocketCloseStatus.NormalClosure) + { + throw new SeqApiException(received.CloseStatusDescription ?? received.CloseStatus.ToString()!); + } + } + else + { + current.Write(buffer, 0, received.Count); + + if (received.EndOfMessage) + { + current.Position = 0; + var value = deserialize(reader); + + current.SetLength(0); + reader.DiscardBufferedData(); + + yield return value; + } + } + } } async Task HttpGetAsync(string url, CancellationToken cancellationToken = default) diff --git a/src/Seq.Api/Client/SeqApiException.cs b/src/Seq.Api/Client/SeqApiException.cs index 853a1b3..12842b9 100644 --- a/src/Seq.Api/Client/SeqApiException.cs +++ b/src/Seq.Api/Client/SeqApiException.cs @@ -15,27 +15,47 @@ using System; using System.Net; -namespace Seq.Api.Client +#nullable enable + +namespace Seq.Api.Client; + +/// +/// Thrown when an action cannot be performed. +/// +public class SeqApiException : Exception { /// - /// Thrown when an action cannot be performed. + /// Construct a with the given message and status code. /// - public class SeqApiException : Exception + /// A message describing the error. + public SeqApiException(string message) + : base(message) { - /// - /// Construct a with the given message and status code. - /// - /// A message describing the error. - /// The corresponding status code returned from Seq, if available. - public SeqApiException(string message, HttpStatusCode? statusCode) - : base(message) - { - StatusCode = statusCode; - } - - /// - /// The status code returned from Seq, if available. - /// - public HttpStatusCode? StatusCode { get; } } + + /// + /// Construct a with the given message and status code. + /// + /// A message describing the error. + /// The corresponding status code returned from Seq, if available. + public SeqApiException(string message, HttpStatusCode? statusCode) + : this(message) + { + StatusCode = statusCode; + } + + /// + /// Construct a with the given message and status code. + /// + /// A message describing the error. + /// The cause of the API failure. + public SeqApiException(string message, Exception innerException) + : base(message, innerException) + { + } + + /// + /// The status code returned from Seq, if available. + /// + public HttpStatusCode? StatusCode { get; } } \ No newline at end of file diff --git a/src/Seq.Api/Model/Cluster/ClusterNodeEntity.cs b/src/Seq.Api/Model/Cluster/ClusterNodeEntity.cs index c31e8d5..4604dd3 100644 --- a/src/Seq.Api/Model/Cluster/ClusterNodeEntity.cs +++ b/src/Seq.Api/Model/Cluster/ClusterNodeEntity.cs @@ -65,14 +65,13 @@ public class ClusterNodeEntity : Entity public int? RemainingActiveOps { get; set; } /// - /// An informational description of the node's current state, or null if no additional - /// information about the node is available. + /// Whether the node is currently leading the cluster. /// - public string StateDescription { get; set; } + public bool IsLeading { get; set; } /// - /// Whether the node is currently leading the cluster. + /// Whether the node has connected to the cluster network. /// - public bool IsLeading { get; set; } + public bool IsConnected { get; set; } } } diff --git a/src/Seq.Api/ResourceGroups/DataResourceGroup.cs b/src/Seq.Api/ResourceGroups/DataResourceGroup.cs index a2a7a70..df10b64 100644 --- a/src/Seq.Api/ResourceGroups/DataResourceGroup.cs +++ b/src/Seq.Api/ResourceGroups/DataResourceGroup.cs @@ -43,6 +43,7 @@ internal DataResourceGroup(ILoadResourceGroup connection) /// created but not saved, a signal from another server, or the modified representation of an entity already persisted. /// The query timeout; if not specified, the query will run until completion. /// Values for any free variables that appear in . + /// Enable detailed (server-side) query tracing. /// Token through which the operation can be cancelled. /// A structured result set. public async Task QueryAsync( @@ -53,9 +54,10 @@ public async Task QueryAsync( SignalEntity unsavedSignal = null, TimeSpan? timeout = null, Dictionary variables = null, + bool trace = false, CancellationToken cancellationToken = default) { - MakeParameters(query, rangeStartUtc, rangeEndUtc, signal, unsavedSignal, timeout, variables, out var body, out var parameters); + MakeParameters(query, rangeStartUtc, rangeEndUtc, signal, unsavedSignal, timeout, variables, trace, out var body, out var parameters); return await GroupPostAsync("Query", body, parameters, cancellationToken).ConfigureAwait(false); } @@ -71,6 +73,7 @@ public async Task QueryAsync( /// The query timeout; if not specified, the query will run until completion. /// Values for any free variables that appear in . /// Token through which the operation can be cancelled. + /// Enable detailed (server-side) query tracing. /// A CSV result set. public async Task QueryCsvAsync( string query, @@ -80,9 +83,10 @@ public async Task QueryCsvAsync( SignalEntity unsavedSignal = null, TimeSpan? timeout = null, Dictionary variables = null, + bool trace = false, CancellationToken cancellationToken = default) { - MakeParameters(query, rangeStartUtc, rangeEndUtc, signal, unsavedSignal, timeout, variables, out var body, out var parameters); + MakeParameters(query, rangeStartUtc, rangeEndUtc, signal, unsavedSignal, timeout, variables, trace, out var body, out var parameters); parameters.Add("format", "text/csv"); return await GroupPostReadStringAsync("Query", body, parameters, cancellationToken).ConfigureAwait(false); } @@ -95,6 +99,7 @@ static void MakeParameters( SignalEntity unsavedSignal, TimeSpan? timeout, Dictionary variables, + bool trace, out EvaluationContextPart body, out Dictionary parameters) { @@ -114,6 +119,9 @@ static void MakeParameters( if (timeout != null) parameters.Add("timeoutMS", timeout.Value.TotalMilliseconds.ToString("0")); + + if (trace) + parameters.Add("trace", true); body = new EvaluationContextPart { Signal = unsavedSignal, Variables = variables }; } diff --git a/src/Seq.Api/ResourceGroups/EventsResourceGroup.cs b/src/Seq.Api/ResourceGroups/EventsResourceGroup.cs index 4f51004..514e552 100644 --- a/src/Seq.Api/ResourceGroups/EventsResourceGroup.cs +++ b/src/Seq.Api/ResourceGroups/EventsResourceGroup.cs @@ -20,7 +20,6 @@ using Seq.Api.Model.Events; using Seq.Api.Model.Shared; using Seq.Api.Model.Signals; -using Seq.Api.Streams; namespace Seq.Api.ResourceGroups { @@ -42,6 +41,7 @@ internal EventsResourceGroup(ILoadResourceGroup connection) /// with versions prior to 5.0, which did not mark permalinks explicitly. /// Run the search at a lower priority, using a lower proportion of available server /// resources (may take longer to complete). + /// Enable detailed (server-side) query tracing. /// Token through which the operation can be cancelled. /// The event. public async Task FindAsync( @@ -49,6 +49,7 @@ public async Task FindAsync( bool render = false, string permalinkId = null, bool background = false, + bool trace = false, CancellationToken cancellationToken = default) { if (id == null) throw new ArgumentNullException(nameof(id)); @@ -57,6 +58,7 @@ public async Task FindAsync( if (render) parameters.Add("render", true); if (permalinkId != null) parameters.Add("permalinkId", permalinkId); if (background) parameters.Add("background", true); + if (trace) parameters.Add("trace", true); return await GroupGetAsync("Item", parameters, cancellationToken).ConfigureAwait(false); } @@ -75,6 +77,77 @@ public async Task FindAsync( /// If specified, the event's message template and properties will be rendered into its RenderedMessage property. /// Earliest (inclusive) date/time from which to search. /// Latest (exclusive) date/time from which to search. + /// If the request is for a permalinked event, specifying the id of the permalink here will + /// allow events that have otherwise been deleted to be found. The special value `"unknown"` provides backwards compatibility + /// with versions prior to 5.0, which did not mark permalinks explicitly. + /// Values for any free variables that appear in . + /// Run the search at a lower priority, using a lower proportion of available server + /// resources (may take longer to complete). + /// Token through which the operation can be cancelled. + /// Enable detailed (server-side) query tracing. + /// The complete list of events, ordered from least to most recent. + public async IAsyncEnumerable EnumerateAsync( + SignalEntity unsavedSignal = null, + SignalExpressionPart signal = null, + string filter = null, + int count = 30, + string startAtId = null, + string afterId = null, + bool render = false, + DateTime? fromDateUtc = null, + DateTime? toDateUtc = null, + string permalinkId = null, + Dictionary variables = null, + bool background = false, + bool trace = false, + [EnumeratorCancellation] + CancellationToken cancellationToken = default) + { + var parameters = new Dictionary{{ "count", count }}; + if (signal != null) { parameters.Add("signal", signal.ToString()); } + if (filter != null) { parameters.Add("filter", filter); } + if (startAtId != null) { parameters.Add("startAtId", startAtId); } + if (afterId != null) { parameters.Add("afterId", afterId); } + if (render) { parameters.Add("render", true); } + if (fromDateUtc != null) { parameters.Add("fromDateUtc", fromDateUtc.Value); } + if (toDateUtc != null) { parameters.Add("toDateUtc", toDateUtc.Value); } + if (permalinkId != null) { parameters.Add("permalinkId", permalinkId); } + if (background) parameters.Add("background", true); + if (trace) parameters.Add("trace", true); + + var group = await LoadGroupAsync(cancellationToken).ConfigureAwait(false); + if (variables == null && unsavedSignal == null) + { + await foreach (var evt in Client.StreamAsync(group, "Scan", parameters, cancellationToken)) + { + yield return evt; + } + yield break; + } + + var body = new EvaluationContextPart { Signal = unsavedSignal, Variables = variables }; + parameters.Add("wait", true); + await foreach (var evt in Client.StreamSendAsync(group, "Scan", body, parameters, cancellationToken)) + { + yield return evt; + } + } + + /// + /// Retrieve all events that match a set of conditions, using the paged search API rather than WebSockets. Note that this method is less + /// efficient and requires more server resources than the . + /// + /// A constructed signal that may not appear on the server, for example, a that has been + /// created but not saved, a signal from another server, or the modified representation of an entity already persisted. + /// If provided, a signal expression describing the set of events that will be filtered for the result. + /// A strict Seq filter expression to match (text expressions must be in double quotes). To + /// convert a "fuzzy" filter into a strict one the way the Seq UI does, use connection.Expressions.ToStrictAsync(). + /// The number of events to retrieve. If not specified will default to 30. + /// An event id from which to start searching (inclusively). + /// An event id to search after (exclusively). + /// If specified, the event's message template and properties will be rendered into its RenderedMessage property. + /// Earliest (inclusive) date/time from which to search. + /// Latest (exclusive) date/time from which to search. /// If specified, the number of events after the first match to keep searching before a partial /// result set is returned. Used to improve responsiveness when the result is displayed in a user interface, not typically used in /// batch processing scenarios. @@ -84,9 +157,10 @@ public async Task FindAsync( /// Values for any free variables that appear in . /// Run the search at a lower priority, using a lower proportion of available server /// resources (may take longer to complete). + /// Enable detailed (server-side) query tracing. /// Token through which the operation can be cancelled. /// The complete list of events, ordered from least to most recent. - public async IAsyncEnumerable EnumerateAsync( + public async IAsyncEnumerable PagedEnumerateAsync( SignalEntity unsavedSignal = null, SignalExpressionPart signal = null, string filter = null, @@ -100,6 +174,7 @@ public async IAsyncEnumerable EnumerateAsync( string permalinkId = null, Dictionary variables = null, bool background = false, + bool trace = false, [EnumeratorCancellation] CancellationToken cancellationToken = default) { @@ -114,7 +189,7 @@ public async IAsyncEnumerable EnumerateAsync( while (true) { var resultSet = await PageAsync(unsavedSignal, signal, filter, nextCount, startAtId, nextAfterId, render, - fromDateUtc, toDateUtc, shortCircuitAfter, permalinkId, variables, background, cancellationToken).ConfigureAwait(false); + fromDateUtc, toDateUtc, shortCircuitAfter, permalinkId, variables, background, trace, cancellationToken).ConfigureAwait(false); foreach (var evt in resultSet.Events) { @@ -135,7 +210,6 @@ public async IAsyncEnumerable EnumerateAsync( nextCount = Math.Min(remaining, pageSize); } } - /// /// Retrieve all events that match a set of conditions. The complete result is buffered into memory, @@ -153,15 +227,13 @@ public async IAsyncEnumerable EnumerateAsync( /// If specified, the event's message template and properties will be rendered into its RenderedMessage property. /// Earliest (inclusive) date/time from which to search. /// Latest (exclusive) date/time from which to search. - /// If specified, the number of events after the first match to keep searching before a partial - /// result set is returned. Used to improve responsiveness when the result is displayed in a user interface, not typically used in - /// batch processing scenarios. /// If the request is for a permalinked event, specifying the id of the permalink here will /// allow events that have otherwise been deleted to be found. The special value `"unknown"` provides backwards compatibility /// with versions prior to 5.0, which did not mark permalinks explicitly. /// Values for any free variables that appear in . /// Run the search at a lower priority, using a lower proportion of available server /// resources (may take longer to complete). + /// Enable detailed (server-side) query tracing. /// Token through which the operation can be cancelled. /// The result set with a page of events. public async Task> ListAsync( @@ -174,16 +246,15 @@ public async Task> ListAsync( bool render = false, DateTime? fromDateUtc = null, DateTime? toDateUtc = null, - int? shortCircuitAfter = null, string permalinkId = null, Dictionary variables = null, bool background = false, + bool trace = false, CancellationToken cancellationToken = default) { var results = new List(); await foreach (var item in EnumerateAsync(unsavedSignal, signal, filter, count, startAtId, afterId, render, - fromDateUtc, toDateUtc, shortCircuitAfter, permalinkId, variables, background, cancellationToken) - .WithCancellation(cancellationToken) + fromDateUtc, toDateUtc, permalinkId, variables, background, trace, cancellationToken) .ConfigureAwait(false)) results.Add(item); return results; @@ -213,6 +284,7 @@ public async Task> ListAsync( /// Run the search at a lower priority, using a lower proportion of available server /// resources (may take longer to complete). /// Token through which the operation can be cancelled. + /// Enable detailed (server-side) query tracing. /// The result set with a page of events. public async Task PageAsync( SignalEntity unsavedSignal = null, @@ -228,6 +300,7 @@ public async Task PageAsync( string permalinkId = null, Dictionary variables = null, bool background = false, + bool trace = false, CancellationToken cancellationToken = default) { var parameters = new Dictionary{{ "count", count }}; @@ -241,6 +314,7 @@ public async Task PageAsync( if (shortCircuitAfter != null) { parameters.Add("shortCircuitAfter", shortCircuitAfter.Value); } if (permalinkId != null) { parameters.Add("permalinkId", permalinkId); } if (background) parameters.Add("background", true); + if (trace) parameters.Add("trace", true); var body = new EvaluationContextPart { Signal = unsavedSignal, Variables = variables }; return await GroupPostAsync("InSignal", body, parameters, cancellationToken).ConfigureAwait(false); @@ -279,52 +353,96 @@ public async Task DeleteAsync( } /// - /// Connect to the live event stream, read as strongly-typed objects. Dispose the resulting stream to disconnect. + /// Connect to the live event stream. Cancel or dispose the resulting iterator to disconnect. /// - /// The type into which events should be deserialized. + /// A constructed signal that may not appear on the server, for example, a that has been + /// created but not saved, a signal from another server, or the modified representation of an entity already persisted. /// If provided, a signal expression describing the set of events that will be filtered for the result. /// A strict Seq filter expression to match (text expressions must be in double quotes). To /// convert a "fuzzy" filter into a strict one the way the Seq UI does, use connection.Expressions.ToStrictAsync(). + /// If specified, the event's message template and properties will be rendered into its property. + /// Values for any free variables that appear in . /// Token through which the operation can be cancelled. - /// An observable that will stream events from the server to subscribers. Events will be buffered server-side until the first - /// subscriber connects, ensure at least one subscription is made in order to avoid event loss. /// See the Seq ingestion /// docs for event schema information. - public async Task> StreamAsync( + public async IAsyncEnumerable StreamAsync( + SignalEntity unsavedSignal = null, SignalExpressionPart signal = null, string filter = null, - CancellationToken cancellationToken = default) + Dictionary variables = null, + bool render = false, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { var parameters = new Dictionary(); if (signal != null) { parameters.Add("signal", signal.ToString()); } if (filter != null) { parameters.Add("filter", filter); } + if (render) { parameters.Add("render", true);} var group = await LoadGroupAsync(cancellationToken).ConfigureAwait(false); - return await Client.StreamAsync(group, "Stream", parameters, cancellationToken).ConfigureAwait(false); + + if (unsavedSignal != null) + { + parameters.Add("wait", true); + var body = new EvaluationContextPart { Signal = unsavedSignal, Variables = variables }; + await foreach (var evt in Client.StreamSendAsync(group, "Stream", body, parameters, + cancellationToken)) + { + yield return evt; + } + } + + await foreach (var evt in Client.StreamAsync(group, "Stream", parameters, cancellationToken)) + { + yield return evt; + } } /// - /// Connect to the live event stream, read as raw JSON documents. Dispose the resulting stream to disconnect. + /// Connect to the live event stream, returning events as raw JSON documents. Cancel or dispose the + /// resulting iterator to disconnect. /// + /// A constructed signal that may not appear on the server, for example, a that has been + /// created but not saved, a signal from another server, or the modified representation of an entity already persisted. /// If provided, a signal expression describing the set of events that will be filtered for the result. /// A strict Seq filter expression to match (text expressions must be in double quotes). To /// convert a "fuzzy" filter into a strict one the way the Seq UI does, use connection.Expressions.ToStrictAsync(). + /// If specified, the event's message template and properties will be rendered into its property. + /// If specified, compact JSON format will be requested instead of the default format. + /// Values for any free variables that appear in . /// Token through which the operation can be cancelled. - /// An observable that will stream events from the server to subscribers. Events will be buffered server-side until the first - /// subscriber connects, ensure at least one subscription is made in order to avoid event loss. /// See the Seq ingestion /// docs for event schema information. - public async Task> StreamDocumentsAsync( + public async IAsyncEnumerable StreamDocumentsAsync( + SignalEntity unsavedSignal = null, SignalExpressionPart signal = null, string filter = null, - CancellationToken cancellationToken = default) + Dictionary variables = null, + bool render = false, + bool clef = false, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { var parameters = new Dictionary(); if (signal != null) { parameters.Add("signal", signal.ToString()); } if (filter != null) { parameters.Add("filter", filter); } + if (render) { parameters.Add("render", true);} + if (clef) { parameters.Add("clef", true);} var group = await LoadGroupAsync(cancellationToken).ConfigureAwait(false); - return await Client.StreamTextAsync(group, "Stream", parameters, cancellationToken).ConfigureAwait(false); + + if (unsavedSignal != null) + { + parameters.Add("wait", true); + var body = new EvaluationContextPart { Signal = unsavedSignal, Variables = variables }; + await foreach (var evt in Client.StreamTextSendAsync(group, "Stream", body, parameters, cancellationToken)) + { + yield return evt; + } + } + + await foreach (var evt in Client.StreamTextAsync(group, "Stream", parameters, cancellationToken)) + { + yield return evt; + } } } } diff --git a/src/Seq.Api/ResourceGroups/UsersResourceGroup.cs b/src/Seq.Api/ResourceGroups/UsersResourceGroup.cs index 99b83a1..f555f3a 100644 --- a/src/Seq.Api/ResourceGroups/UsersResourceGroup.cs +++ b/src/Seq.Api/ResourceGroups/UsersResourceGroup.cs @@ -169,7 +169,7 @@ public async Task LoginWindowsIntegratedAsync(CancellationToken canc var providers = await GroupGetAsync("AuthenticationProviders", cancellationToken: cancellationToken).ConfigureAwait(false); var provider = providers.Providers.SingleOrDefault(p => p.Name == "Integrated Windows Authentication"); if (provider == null) - throw new SeqApiException("The Integrated Windows Authentication provider is not available.", null); + throw new SeqApiException("The Integrated Windows Authentication provider is not available."); var response = await Client.HttpClient.GetAsync(provider.Url, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await FindCurrentAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Seq.Api/Seq.Api.csproj b/src/Seq.Api/Seq.Api.csproj index a0a4021..2fca167 100644 --- a/src/Seq.Api/Seq.Api.csproj +++ b/src/Seq.Api/Seq.Api.csproj @@ -3,7 +3,7 @@ Client library for the Seq HTTP API. 2025.1.0 Datalust;Contributors - netstandard2.0;net6.0 + netstandard2.0;net6.0;net8.0 true true seq @@ -11,20 +11,24 @@ seq-api-icon.png https://github.com/datalust/seq-api Apache-2.0 - 9 + latest - + $(DefineConstants);SOCKETS_HTTP_HANDLER - + + + $(DefineConstants);SOCKETS_HTTP_HANDLER;WEBSOCKET_USE_HTTPCLIENT + + - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Seq.Api/SeqConnection.cs b/src/Seq.Api/SeqConnection.cs index de64a6a..9fc90f7 100644 --- a/src/Seq.Api/SeqConnection.cs +++ b/src/Seq.Api/SeqConnection.cs @@ -14,7 +14,6 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.Net; using System.Net.Http; using System.Threading; @@ -37,33 +36,6 @@ public class SeqConnection : ILoadResourceGroup, IDisposable readonly Dictionary> _resourceGroups = new(); Task _root; - /// - /// Construct a . - /// - /// The base URL of the Seq server. - /// An API key to use when making requests to the server, if required. - /// Whether default credentials will be sent with HTTP requests; the default is true. - [Obsolete("Prefer `SeqConnection(serverUrl, apiKey, createHttpMessageHandler)` instead."), EditorBrowsable(EditorBrowsableState.Never)] - public SeqConnection(string serverUrl, string apiKey, bool useDefaultCredentials) - { - if (serverUrl == null) throw new ArgumentNullException(nameof(serverUrl)); - Client = new SeqApiClient(serverUrl, apiKey, useDefaultCredentials); - } - - /// - /// Construct a . - /// - /// The base URL of the Seq server. - /// An API key to use when making requests to the server, if required. - /// An optional callback to configure the used when making HTTP requests - /// to the Seq API. - [Obsolete("Prefer `SeqConnection(serverUrl, apiKey, createHttpMessageHandler)` instead."), EditorBrowsable(EditorBrowsableState.Never)] - public SeqConnection(string serverUrl, string apiKey, Action configureHttpClientHandler) - { - if (serverUrl == null) throw new ArgumentNullException(nameof(serverUrl)); - Client = new SeqApiClient(serverUrl, apiKey, configureHttpClientHandler); - } - /// /// Construct a . /// diff --git a/src/Seq.Api/Streams/ObservableStream.Unsubscriber.cs b/src/Seq.Api/Streams/ObservableStream.Unsubscriber.cs deleted file mode 100644 index ffded68..0000000 --- a/src/Seq.Api/Streams/ObservableStream.Unsubscriber.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright © Datalust; based on code from -// Serilog.Sinks.Observable, Copyright 2013-2016 Serilog Contributors -// -// Licensed 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; - -namespace Seq.Api.Streams -{ - public partial class ObservableStream - { - sealed class Unsubscriber : IDisposable - { - readonly ObservableStream _stream; - readonly IObserver _observer; - - public Unsubscriber(ObservableStream sink, IObserver observer) - { - _stream = sink ?? throw new ArgumentNullException(nameof(sink)); - _observer = observer ?? throw new ArgumentNullException(nameof(observer)); - } - - public void Dispose() - { - _stream.Unsubscribe(_observer); - } - } - } -} diff --git a/src/Seq.Api/Streams/ObservableStream.cs b/src/Seq.Api/Streams/ObservableStream.cs deleted file mode 100644 index 00a735e..0000000 --- a/src/Seq.Api/Streams/ObservableStream.cs +++ /dev/null @@ -1,230 +0,0 @@ -// Copyright © Datalust; based on code from -// Serilog.Sinks.Observable, Copyright 2013-2016 Serilog Contributors -// -// Licensed 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.Threading; -using System.Net.WebSockets; -using System.Threading.Tasks; -using System.IO; -using System.Text; -using System.Linq; - -namespace Seq.Api.Streams -{ - /// - /// A stream of values read from a . - /// - /// The type of the values. - public sealed partial class ObservableStream : IObservable, IDisposable - { - readonly object _syncRoot = new object(); - IList> _observers = new List>(); - bool _ended, _disposed; - Task _run; - - readonly ClientWebSocket _socket; - readonly Func _deserialize; - - internal ObservableStream(ClientWebSocket socket, Func deserialize) - { - _deserialize = deserialize ?? throw new ArgumentNullException(nameof(deserialize)); - _socket = socket ?? throw new ArgumentNullException(nameof(socket)); - } - - /// - public IDisposable Subscribe(IObserver observer) - { - if (observer == null) throw new ArgumentNullException(nameof(observer)); - - lock (_syncRoot) - { - if (_disposed) - throw new ObjectDisposedException("The observable stream is disposed."); - - if (_ended) - { - observer.OnCompleted(); - return new Unsubscriber(this, observer); - } - - _observers = _observers.Concat(new[] { observer }).ToList(); - - if (_run == null) - _run = Task.Run(Receive); - } - - return new Unsubscriber(this, observer); - } - - void Unsubscribe(IObserver observer) - { - if (observer == null) throw new ArgumentNullException(nameof(observer)); - - lock (_syncRoot) - { - if (_disposed) - throw new ObjectDisposedException("The observable stream is disposed."); - - _observers = _observers.Except(new[] { observer }).ToList(); - } - } - - async Task Receive() - { - var buffer = new byte[16 * 1024]; - var current = new MemoryStream(); - var reader = new StreamReader(current, new UTF8Encoding(false)); - - while (_socket.State == WebSocketState.Open) - { - var received = await _socket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - if (received.MessageType == WebSocketMessageType.Close) - { - // Managed web sockets are self-closing - if (_socket.State != WebSocketState.Closed) - { - await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); - } - } - else - { - current.Write(buffer, 0, received.Count); - - if (received.EndOfMessage) - { - current.Position = 0; - var value = _deserialize(reader); - - current.SetLength(0); - reader.DiscardBufferedData(); - - Emit(value); - } - } - } - - End(); - } - - void Emit(T value) - { - if (value == null) throw new ArgumentNullException(nameof(value)); - - IList> observers; - lock (_syncRoot) - { - if (_ended || _disposed) - return; - - observers = _observers; - } - - IList exceptions = null; - foreach (var observer in observers) - { - try - { - observer.OnNext(value); - } - catch (Exception ex) - { - if (exceptions == null) - exceptions = new List(); - exceptions.Add(ex); - } - } - - if (exceptions != null) - OnError(exceptions); - } - - void End() - { - IList> observers; - lock (_syncRoot) - { - if (_ended) - return; - - observers = _observers; - _observers = new List>(); - _ended = true; - } - - IList exceptions = null; - foreach (var observer in observers) - { - try - { - observer.OnCompleted(); - } - catch (Exception ex) - { - if (exceptions == null) - exceptions = new List(); - exceptions.Add(ex); - } - } - - if (exceptions != null) - OnError(exceptions); - } - - static void OnError(IEnumerable exceptions) - { - // This will hit TaskScheduler.UnobservedTaskException - throw new AggregateException("At least one observer failed to accept the event", exceptions); - } - - /// - public void Dispose() - { - lock (_syncRoot) - { - if (_disposed) return; - _disposed = true; - } - - try - { - if (_socket.State == WebSocketState.Open) - { - using var timeout = new CancellationTokenSource(); - timeout.CancelAfter(30000); - _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Close requested", timeout.Token) - .ConfigureAwait(false).GetAwaiter().GetResult(); - } - } - catch - { - // Don't mask exceptions by throwing here during unwinding - } - - if (_run != null) - { - try - { - _run.ConfigureAwait(false).GetAwaiter().GetResult(); - } - catch (TaskCanceledException) - { - } - } - - _socket.Dispose(); - } - } -} diff --git a/src/Seq.Api/Streams/WebSocketTaskExtensions.cs b/src/Seq.Api/Streams/WebSocketTaskExtensions.cs new file mode 100644 index 0000000..605e415 --- /dev/null +++ b/src/Seq.Api/Streams/WebSocketTaskExtensions.cs @@ -0,0 +1,33 @@ +using System; +using System.Net.WebSockets; +using System.Threading.Tasks; +using Seq.Api.Client; + +namespace Seq.Api.Streams; + +// Async enumerators and try/catch blocks don't play nicely together. These extensions help maintain the +// previous exception contract around API calls. +static class WebSocketTaskExtensions +{ + public static async Task WithApiExceptions(this Task task) + { + await ((Task)task).WithApiExceptions(); + return task.Result; + } + + public static async Task WithApiExceptions(this Task task) + { + try + { + await task; + } + catch (WebSocketException ex) + { + throw new SeqApiException($"The WebSocket API call failed ({ex.ErrorCode}/{ex.WebSocketErrorCode}).", ex); + } + catch (Exception ex) + { + throw new SeqApiException("The API call failed.", ex); + } + } +} \ No newline at end of file diff --git a/test/Seq.Api.Tests/SeqConnectionTests.cs b/test/Seq.Api.Tests/SeqConnectionTests.cs index cbaa9bf..5e42597 100644 --- a/test/Seq.Api.Tests/SeqConnectionTests.cs +++ b/test/Seq.Api.Tests/SeqConnectionTests.cs @@ -1,4 +1,5 @@ -using Xunit; +using System.Net.Http; +using Xunit; namespace Seq.Api.Tests { @@ -9,12 +10,14 @@ public void WhenConstructedTheHandlerConfigurationCallbackIsCalled() { var callCount = 0; -#pragma warning disable CS0618 // Type or member is obsolete - using var _ = new SeqConnection("https://test.example.com", null, handler => { - Assert.NotNull(handler); - ++callCount; - }); -#pragma warning restore CS0618 // Type or member is obsolete + using var _ = new SeqConnection( + "https://test.example.com", + apiKey: null, + createHttpMessageHandler: cookies => + { + ++callCount; + return new HttpClientHandler { CookieContainer = cookies }; + }); Assert.Equal(1, callCount); }