Skip to content
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ seqcli help apikey

Available commands:

- `alert`
- [`alert create`](#alert-create) — Create an alert.
- [`alert list`](#alert-list) — List alerts.
- [`alert remove`](#alert-remove) — Remove an alert from the server.
- [`alert update`](#alert-update) — Update an existing alert.
- `apikey`
- [`apikey create`](#apikey-create) — Create an API key for automation or ingestion.
- [`apikey list`](#apikey-list) — List available API keys.
Expand Down Expand Up @@ -221,6 +226,101 @@ Available commands:
- [`workspace remove`](#workspace-remove) — Remove a workspace from the server.
- [`workspace update`](#workspace-update) — Update an existing workspace.

### `alert create`

Create an alert.

Example:

```
seqcli alert create -t 'Too many errors' --signal signal-m33302 --where "@Level = 'Error'" --select "count(*) as errors" --window 5m --having "errors > 10" --level Error --suppression-time 10m
Comment thread
KodrAus marked this conversation as resolved.
Outdated
```

| Option | Description |
| ------ | ----------- |
| `-t`, `--title=VALUE` | A title for the alert |
| `--description=VALUE` | A description for the alert |
| `--signal=VALUE` | A signal expression limiting the alert's input, for example `signal-1` or `signal-1,signal-2` |
| `--where=VALUE` | A predicate that selects the events the alert will consider |
| `--select=VALUE` | A measurement the alert condition will test, for example `count(*) as errors`; this argument can be used multiple times |
| `--group-by=VALUE` | An expression to group measurements by, for example `ServiceName`; this argument can be used multiple times |
| `--window=VALUE` | The measurement window over which the alert condition is evaluated, as a duration, for example `1m` or `1h` |
| `--having=VALUE` | The alert condition; a predicate over the grouped measurements, for example `errors > 10` |
| `--level=VALUE` | The notification level of the alert, for example `Warning` or `Error` |
| `--suppression-time=VALUE` | A duration for which notifications are suppressed after the alert triggers, for example `10m` or `1h` |
| `--notification-app=VALUE` | The id of an app instance that will be notified when the alert triggers; this argument can be used multiple times |
| `--protected` | Specify that the alert is editable only by administrators |
| `--disabled` | Create the alert in a disabled state; disabled alerts are not processed and do not send notifications |
| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used |
| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used |
| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used |
| `--json` | Print output in newline-delimited JSON (the default is plain text) |
| `--no-color` | Don't colorize text output |
| `--force-color` | Force redirected output to have ANSI color (unless `--no-color` is also specified) |
| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory |

### `alert list`

List alerts.

Example:

```
seqcli alert list
```

| Option | Description |
| ------ | ----------- |
| `-t`, `--title=VALUE` | The title of the alert(s) to list |
| `-i`, `--id=VALUE` | The id of a single alert to list |
| `-o`, `--owner=VALUE` | The id of the user to list alerts for; by default, shared alerts are listed |
| `--json` | Print output in newline-delimited JSON (the default is plain text) |
| `--no-color` | Don't colorize text output |
| `--force-color` | Force redirected output to have ANSI color (unless `--no-color` is also specified) |
| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory |
| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used |
| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used |
| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used |

### `alert remove`

Remove an alert from the server.

Example:

```
seqcli alert remove -t 'Too many errors'
```

| Option | Description |
| ------ | ----------- |
| `-t`, `--title=VALUE` | The title of the alert(s) to remove |
| `-i`, `--id=VALUE` | The id of a single alert to remove |
| `-o`, `--owner=VALUE` | The id of the user to remove alerts for; by default, shared alerts are removed |
| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used |
| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used |
| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used |
| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory |

### `alert update`

Update an existing alert.

Example:

```
seqcli alert update --json '{...}'
```

| Option | Description |
| ------ | ----------- |
| `--json=VALUE` | The updated alert in JSON format; this can be produced using `seqcli alert list --json` |
| `--json-stdin` | Read the updated alert as JSON from `STDIN` |
| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used |
| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used |
| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used |
| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory |

### `apikey create`

Create an API key for automation or ingestion.
Expand Down
188 changes: 188 additions & 0 deletions src/SeqCli/Cli/Commands/Alert/CreateCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Copyright © Datalust and 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.Linq;
using System.Threading.Tasks;
using Seq.Api.Model.Alerting;
using Seq.Api.Model.LogEvents;
using Seq.Api.Model.Shared;
using SeqCli.Api;
using SeqCli.Cli.Features;
using SeqCli.Config;
using SeqCli.Mapping;
using SeqCli.Signals;
using SeqCli.Syntax;
using SeqCli.Util;

namespace SeqCli.Cli.Commands.Alert;

[Command("alert", "create", "Create an alert",
Example = "seqcli alert create -t 'Too many errors' --signal signal-m33302 --where \"@Level = 'Error'\" " +
"--select \"count(*) as errors\" --window 5m --having \"errors > 10\" --level Error --suppression-time 10m")]
class CreateCommand : Command
{
readonly ConnectionFeature _connection;
readonly OutputFormatFeature _output;
readonly StoragePathFeature _storagePath;

readonly List<string> _select = new();
readonly List<string> _groupBy = new();
readonly List<string> _notificationApps = new();

string? _title, _description, _signal, _where, _having, _window, _level, _suppressionTime;
bool _isProtected, _isDisabled;

public CreateCommand()
{
Options.Add(
"t=|title=",
"A title for the alert",
t => _title = ArgumentString.Normalize(t));

Options.Add(
"description=",
"A description for the alert",
d => _description = ArgumentString.Normalize(d));

Options.Add(
"signal=",
"A signal expression limiting the alert's input, for example `signal-1` or `signal-1,signal-2`",
s => _signal = ArgumentString.Normalize(s));

Options.Add(
"where=",
"A predicate that selects the events the alert will consider",
w => _where = ArgumentString.Normalize(w));

Options.Add(
"select=",
"A measurement the alert condition will test, for example `count(*) as errors`; this argument can be used multiple times",
s => _select.Add(ArgumentString.Normalize(s) ?? throw new ArgumentException("Measurements require a value.")));

Options.Add(
"group-by=",
"An expression to group measurements by, for example `ServiceName`; this argument can be used multiple times",
g => _groupBy.Add(ArgumentString.Normalize(g) ?? throw new ArgumentException("Groupings require a value.")));

Options.Add(
"window=",
"The measurement window over which the alert condition is evaluated, as a duration, for example `1m` or `1h`",
w => _window = ArgumentString.Normalize(w));

Options.Add(
"having=",
"The alert condition; a predicate over the grouped measurements, for example `errors > 10`",
h => _having = ArgumentString.Normalize(h));

Options.Add(
"level=",
Comment thread
KodrAus marked this conversation as resolved.
Outdated
"The notification level of the alert, for example `Warning` or `Error`",
l => _level = ArgumentString.Normalize(l));

Options.Add(
"suppression-time=",
"A duration for which notifications are suppressed after the alert triggers, for example `10m` or `1h`",
s => _suppressionTime = ArgumentString.Normalize(s));

Options.Add(
"notification-app=",
"The id of an app instance that will be notified when the alert triggers; this argument can be used multiple times",
a => _notificationApps.Add(ArgumentString.Normalize(a) ?? throw new ArgumentException("Notification apps require a value.")));

Options.Add(
"protected",
"Specify that the alert is editable only by administrators",
_ => _isProtected = true);

Options.Add(
"disabled",
"Create the alert in a disabled state; disabled alerts are not processed and do not send notifications",
_ => _isDisabled = true);

_connection = Enable<ConnectionFeature>();
_output = Enable<OutputFormatFeature>();
_storagePath = Enable<StoragePathFeature>();
}

protected override async Task<int> Run()
{
var config = RuntimeConfigurationLoader.Load(_storagePath);
var connection = SeqConnectionFactory.Connect(_connection, config);

var alert = await connection.Alerts.TemplateAsync();
alert.OwnerId = null;

alert.Title = _title;
alert.Description = _description;
alert.IsProtected = _isProtected;
alert.IsDisabled = _isDisabled;

if (_signal != null)
alert.SignalExpression = SignalExpressionParser.ParseExpression(_signal);

if (_where != null)
alert.Where = (await connection.Expressions.ToStrictAsync(_where)).StrictExpression;

if (_select.Any())
{
alert.Select.Clear();
foreach (var measurement in _select)
alert.Select.Add(ParseColumn(measurement));
}

if (_groupBy.Any())
{
alert.GroupBy.Clear();
foreach (var grouping in _groupBy)
alert.GroupBy.Add(new GroupingColumnPart { Value = grouping });
}

if (_window != null)
alert.TimeGrouping = DurationMoniker.ToTimeSpan(_window);

if (_having != null)
alert.Having = _having;

if (_level != null)
alert.NotificationLevel = Enum.Parse<LogEventLevel>(LevelMapping.ToFullLevelName(_level));

if (_suppressionTime != null)
alert.SuppressionTime = DurationMoniker.ToTimeSpan(_suppressionTime);

foreach (var appInstanceId in _notificationApps)
alert.NotificationChannels.Add(new NotificationChannelPart { NotificationAppInstanceId = appInstanceId });

alert = await connection.Alerts.AddAsync(alert);

_output.GetOutputFormat(config).WriteEntity(alert);

return 0;
}

static ColumnPart ParseColumn(string measurement)
Comment thread
KodrAus marked this conversation as resolved.
{
// Measurements are specified in the same `<expression> as <label>` form used
// in queries; split on the last ` as ` so expressions can contain the keyword.
var index = measurement.LastIndexOf(" as ", StringComparison.OrdinalIgnoreCase);
return index < 0
? new ColumnPart { Value = measurement }
: new ColumnPart
{
Value = measurement[..index].Trim(),
Label = measurement[(index + 4)..].Trim()
};
}
}
55 changes: 55 additions & 0 deletions src/SeqCli/Cli/Commands/Alert/ListCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright © Datalust and 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.Linq;
using System.Threading.Tasks;
using SeqCli.Api;
using SeqCli.Cli.Features;
using SeqCli.Config;

namespace SeqCli.Cli.Commands.Alert;

[Command("alert", "list", "List alerts", Example="seqcli alert list")]
class ListCommand : Command
{
readonly EntityIdentityFeature _entityIdentity;
readonly ConnectionFeature _connection;
readonly OutputFormatFeature _output;
readonly EntityOwnerFeature _entityOwner;
readonly StoragePathFeature _storagePath;

public ListCommand()
{
_entityIdentity = Enable(new EntityIdentityFeature("alert", "list"));
_entityOwner = Enable(new EntityOwnerFeature("alert", "list", "listed", _entityIdentity));
_output = Enable<OutputFormatFeature>();
_storagePath = Enable<StoragePathFeature>();
_connection = Enable<ConnectionFeature>();
}

protected override async Task<int> Run()
{
var config = RuntimeConfigurationLoader.Load(_storagePath);
var connection = SeqConnectionFactory.Connect(_connection, config);

var list = _entityIdentity.Id != null ? [await connection.Alerts.FindAsync(_entityIdentity.Id)]
:
(await connection.Alerts.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared))
.Where(alert => _entityIdentity.Title == null || _entityIdentity.Title == alert.Title);

_output.GetOutputFormat(config).ListEntities(list);

return 0;
}
}
Loading