Custom Event Monitoring

Looking for the complete monitoring solution? Check out the Monitoring Dashboard for a ready-to-use web dashboard with REST API, embedded React UI, and real-time monitoring. This page covers custom event-based monitoring and DIY integrations.

EverTask gives you visibility into your background tasks through an event system that tracks execution, failures, and performance. This page covers the event-based monitoring system and custom integrations. For a complete monitoring solution with web dashboard, see the Monitoring Dashboard guide.

Table of Contents

Task Events

EverTask publishes events for the task lifecycle through the TaskEventOccurredAsync event on IEverTaskWorkerExecutor. Subscribe to these events to build monitoring dashboards, send alerts, or integrate with external systems.

Event Types

  • Task Started: When a task begins execution
  • Task Completed: When a task finishes successfully
  • Task Failed: When a task fails after all retry attempts
  • Task Cancelled: When a task is cancelled
  • Task Timeout: When a task exceeds its timeout
  • Recurring Task Scheduled: When a recurring task is scheduled for next execution
  • Rate Limit Deferred (v3.7+): When the rate-limit gate parks a task waiting for its key’s budget. Severity Information, machine-parseable message:

    Rate limit deferred task {id}: key={key} slotUtc={slot:O} policy={taskType} deferredCount={n}
    

    Deferral events are aggregated at the source (the first deferral of a (task type, key) window emits immediately, further deferrals surface as one summary per window with deferredCount), so sustained throttling never floods subscribers. Per-deferral details are logged at Debug. Disable via SetRateLimiterOptions(o => o.EmitDeferralEvents = false).

  • Rate Limit Fail-Open (v3.7+): Severity Warning, published when the limiter exceeds MaxTrackedKeys and starts executing tasks for new keys unthrottled (message contains fail OPEN and the running totalFailOpenCount). Mandatory signal: a silent fail-open under key-cardinality pressure would be invisible.
  • Rate Limit Rejected (v3.7+): Severity Error (one-shot tasks marked Failed with a typed RateLimitRejectedException) or Warning (recurring occurrence skipped, series alive). See terminal outcomes.

Severity Levels

public enum SeverityLevel
{
    Information,    // Normal operation (started, completed, scheduled)
    Warning,        // Non-critical issues (cancelled, timeout)
    Error           // Failures and exceptions
}

Durable Occurrence and Schedule Events

Schedules that materialize their slots as rows (durable occurrences) and schedules changed at runtime through ITaskScheduleManager publish through the same TaskEventOccurredAsync channel and carry the schedule context described above.

Grouping them by schedule takes both halves of that context, because the events are about two different rows. An event about an occurrence carries ParentTaskId, the schedule it belongs to. An event about the schedule itself (a halt, a reschedule, a provider that could not answer) is published on the schedule row, so its own TaskId is the schedule id and ParentTaskId is null, as it is for every row that is nobody’s occurrence. ScheduleVersion is set on both.

Messages are stable text, matched by consumers; the table quotes the shape, with {} for the values.

Event Severity Message
Occurrence materialized Information Materialized occurrence {id} of schedule {scheduleId} for slot {slot} (run {n})
Occurrence skipped Warning Schedule {scheduleId} skipped {n} due slot(s) from {from}: {cause}
Slot already served Warning Slot {slot} of schedule {scheduleId} already has an occurrence: the cursor was carried past it
Stale occurrence reconciled Warning Stale occurrence {id} of schedule {scheduleId} was stranded in {status} and has been requeued
Occurrence requeued Information Occurrence {id} of schedule {scheduleId} was requeued from {status}
Catch-up started Information Catch-up of schedule {scheduleId} started from slot {from}: {n} slot(s) are due
Catch-up completed Information Catch-up of schedule {scheduleId} completed: {n} occurrence(s) materialized since {start}
Catch-up halted Error Catch-up of schedule {scheduleId} halted at cursor {cursor}: {n} slots are due, more than the configured cap
Schedule unusable Error Schedule {scheduleId} cannot be rebuilt from its row and materializes nothing: {reason}
Occurrence unusable Error Occurrence {id} of schedule {scheduleId} cannot be rebuilt from its row and was marked Failed: {reason}
Occurrence unusable, still not ended Error Occurrence {id} of schedule {scheduleId} cannot be rebuilt from its row and could not be marked Failed (it is still {status}): {reason}
Occurrence rebuild exhausted Error Occurrence {id} of schedule {scheduleId} could not be rebuilt in {n} consecutive process start(s) and was marked Failed: {reason}
Occurrence rebuild exhausted, still not ended Error Occurrence {id} of schedule {scheduleId} could not be rebuilt in {n} consecutive process start(s) and could not be marked Failed (it is still {status}): {reason}
Provider re-park failed Error Schedule {scheduleId} could not be parked to ask the occurrence provider '{key}' again: nothing was written and the series stays where it is until the next startup recovery
Schedule revival failed Error Schedule {scheduleId} was dispatched again under the task key '{taskKey}' but could not be taken out of Cancelled: the series is not restarted, and no recovery brings back a cancelled row
Schedule rescheduled Information / Warning Schedule {id} rescheduled from version {a} to version {b} ({mode}): cursor {from} -> {to}
Re-park failed (reschedule) Error Schedule {id} is at version {v} but could not be handed back to the scheduler: {reason}
Re-park failed (materialization) Error Schedule {id} could not be re-parked after a failed materialization: it is parked nowhere and only the next startup recovery brings it back
Provider evaluation failed Warning Occurrence provider '{key}' could not answer for schedule {id} ({n} consecutive failure(s)): …parked to ask again at {retryAt}

Four of them repay a closer look.

Catch-up started and catch-up completed are the two ends of one replay. Everything between them is reported per occurrence, and per occurrence there is no way to tell where a backlog began, how big it was, or that it has drained: a serial catch-up materializes its slots one delivery at a time, over as many materializer runs as it takes. The pair is per process: a host that restarts in the middle of a replay opens a new episode, which is what the schedule itself does with the backlog that is left. A replay that HALTS gets no completion event (the halt below is the event for that), and neither does one whose series is cancelled under it.

Occurrence skipped is the only ordinary way a durable schedule loses a slot, and it always names the rule that dropped it, because the three have different fixes:

  • outside the misfire window: older than now - MaxAge. Widen the window, or accept the loss.
  • older than the most recent slots the catch-up cap keeps: the SkipOldest overflow policy kept the newest MaxOccurrences and dropped the rest. Raise the cap, or accept it.
  • the skip policy does not replay a slot that is no longer current: the Skip policy, working as configured.

The count reads at least {n} instead of a bare number when the grid was counted under a cap: a lower bound is said to be one, never dressed up as a total.

Schedule rescheduled is Warning instead of Information when the new definition discarded a backlog, and the message then ends with ; {n} due slot(s) were discarded, plus ; the catch-up halt was released when the call cleared a halt. The change is committed before the series is handed back to the scheduler, so this event is published even when the re-park then fails and the Error above follows it.

Schedule unusable, occurrence unusable and provider re-park failed are three events an alert rule tends to miss, and they are worth waking someone for. The first two mean this build cannot rebuild a row out of what is persisted (a payload that stopped deserializing, a zone id that has left the tz database, a handler nobody registers any more), so the schedule materializes nothing at all, or the occurrence is ended as Failed instead of holding a slot of the concurrency budget for ever. An occurrence whose registered handler repeatedly fails to build uses could not be rebuilt in {n} consecutive process start(s) after its recovery allowance is exhausted; other unusable rows use cannot be rebuilt from its row. Each cause also has a second wording for the case where ending the row did not take. SetStatus is best-effort on every relational provider, so the row is read back and the event says which outcome really happened. could not be marked Failed means the occurrence is still alive in the status the message names, still holding its slot of the budget, and the schedule materializes nothing more until a later run ends it for real. Same alert, one more thing wrong. The third means an occurrence provider could not answer AND the series could not be parked to ask it again. Nothing polls behind that one: the schedule stays where it is until the next startup recovery.

Catch-up halted is rate-limited to one event per schedule every five minutes: the halt is a standing condition, not a stream of incidents. It is re-emitted when the marker is found again, on every kick and once per restart, until an operator resumes or reschedules the series.

Event Data Structure

Each event includes everything you need to track what happened:

public record EverTaskEventData(
    Guid TaskId,
    DateTimeOffset EventDateUtc,
    string Severity,
    string TaskType,
    string TaskHandlerType,
    string TaskParameters,
    string Message,
    string? Exception = null,
    IReadOnlyList<TaskExecutionLog>? ExecutionLogs = null)
{
    public Guid? ParentTaskId { get; init; }
    public DateTimeOffset? ScheduledAtUtc { get; init; }
    public int? ScheduleVersion { get; init; }
}

ExecutionLogs carries the captured handler logs of the delivery, when log capture is on and the event is one that reports a finished execution.

The three schedule properties are init members in the record body, never appended constructor parameters: the primary constructor and Deconstruct signatures are what a subscriber compiled against an earlier version calls, so they stay exactly what they were.

Property Set on
ParentTaskId An occurrence of a durable schedule: the schedule row it belongs to
ScheduledAtUtc Any scheduled delivery: the nominal slot it stands for, not the moment it fired
ScheduleVersion A recurring schedule row and every occurrence of one; null for a task that belongs to no schedule

Example Event Data

{
  "TaskId": "dc49351d-476d-49f0-a1e8-3e2a39182d22",
  "EventDateUtc": "2024-10-19T16:10:20+00:00",
  "Severity": "Information",
  "TaskType": "MyApp.Tasks.SendEmailTask",
  "TaskHandlerType": "MyApp.Handlers.SendEmailHandler",
  "TaskParameters": "{\"Email\":\"user@example.com\",\"Subject\":\"Welcome\"}",
  "Message": "Task with id dc49351d-476d-49f0-a1e8-3e2a39182d22 was completed in 12.5 ms",
  "Exception": null,
  "ExecutionLogs": null,
  "ParentTaskId": null,
  "ScheduledAtUtc": null,
  "ScheduleVersion": null
}

Basic Event Monitoring

The simplest way to monitor tasks is to subscribe to events in your services:

Simple Event Subscription

public class TaskMonitoringService
{
    private readonly ILogger<TaskMonitoringService> _logger;

    public TaskMonitoringService(
        IEverTaskWorkerExecutor executor,
        ILogger<TaskMonitoringService> logger)
    {
        _logger = logger;

        executor.TaskEventOccurredAsync += OnTaskEventAsync;
    }

    private Task OnTaskEventAsync(EverTaskEventData eventData)
    {
        _logger.LogInformation(
            "Event from EverTask: [{Severity}] {Message}",
            eventData.Severity,
            eventData.Message);

        return Task.CompletedTask;
    }
}

// Register in Program.cs
builder.Services.AddSingleton<TaskMonitoringService>();

Filtering by Severity

private Task OnTaskEventAsync(EverTaskEventData eventData)
{
    switch (eventData.Severity)
    {
        case nameof(SeverityLevel.Error):
            _logger.LogError(
                eventData.Exception,
                "Task {TaskId} of type {TaskType} failed: {Message}",
                eventData.TaskId,
                eventData.TaskType,
                eventData.Message);
            break;

        case nameof(SeverityLevel.Warning):
            _logger.LogWarning(
                "Task {TaskId} warning: {Message}",
                eventData.TaskId,
                eventData.Message);
            break;

        case nameof(SeverityLevel.Information):
            _logger.LogInformation(
                "Task {TaskId}: {Message}",
                eventData.TaskId,
                eventData.Message);
            break;
    }

    return Task.CompletedTask;
}

Tracking Specific Tasks

Sometimes you only care about certain types of tasks. Here’s how to filter for specific task types:

private Task OnTaskEventAsync(EverTaskEventData eventData)
{
    // Only monitor critical tasks
    if (eventData.TaskType.Contains("Payment") ||
        eventData.TaskType.Contains("Order"))
    {
        _logger.LogInformation(
            "Critical task event: {@EventData}",
            eventData);

        // Send to external monitoring
        _telemetry.TrackEvent("CriticalTaskEvent", new Dictionary<string, string>
        {
            ["TaskId"] = eventData.TaskId.ToString(),
            ["TaskType"] = eventData.TaskType,
            ["Severity"] = eventData.Severity,
            ["Message"] = eventData.Message
        });
    }

    return Task.CompletedTask;
}

SignalR Real-Time Monitoring

If you’re building an ASP.NET Core application, you can watch tasks execute in real-time using the SignalR integration. This is especially useful for admin dashboards or debugging during development.

Note: The Monitoring Dashboard automatically includes SignalR integration with a React UI. Use the approach below only if you’re building a custom monitoring interface.

Installation

dotnet add package EverTask.Monitor.AspnetCore.SignalR

Configuration

using EverTask.Monitor.AspnetCore.SignalR;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEverTask(opt =>
{
    opt.RegisterTasksFromAssembly(typeof(Program).Assembly);
})
.AddSqlServerStorage(connectionString)
.AddSignalRMonitoring(); // Add SignalR monitoring

var app = builder.Build();

// Map the hub so the monitor subscribes to task events.
// Without this call the SignalR monitor is registered but never receives events.
// Default hub path: /evertask-monitoring/hub
app.MapEverTaskMonitorHub();

app.Run();

JavaScript Client

<!DOCTYPE html>
<html>
<head>
    <title>EverTask Monitor</title>
    <script src="https://cdn.jsdelivr.net/npm/@microsoft/signalr@latest/dist/browser/signalr.min.js"></script>
</head>
<body>
    <h1>EverTask Real-Time Monitor</h1>
    <div id="events"></div>

    <script>
        const connection = new signalR.HubConnectionBuilder()
            .withUrl("/evertask-monitoring/hub")
            .withAutomaticReconnect()
            .build();

        connection.on("TaskEvent", (eventData) => {
            console.log("Task event received:", eventData);

            const eventDiv = document.createElement("div");
            eventDiv.className = `event event-${eventData.severity.toLowerCase()}`;
            eventDiv.innerHTML = `
                <strong>${eventData.severity}</strong>:
                ${eventData.taskType} -
                ${eventData.message}
                <small>(${new Date(eventData.eventDateUtc).toLocaleString()})</small>
            `;

            document.getElementById("events").prepend(eventDiv);
        });

        connection.start()
            .then(() => console.log("Connected to EverTask monitoring"))
            .catch(err => console.error("Connection error:", err));
    </script>

    <style>
        .event { padding: 10px; margin: 5px 0; border-left: 4px solid #ccc; }
        .event-information { border-left-color: #007bff; }
        .event-warning { border-left-color: #ffc107; }
        .event-error { border-left-color: #dc3545; }
    </style>
</body>
</html>

.NET Client

using Microsoft.AspNetCore.SignalR.Client;

public class EverTaskMonitorClient
{
    private readonly HubConnection _connection;
    private readonly ILogger<EverTaskMonitorClient> _logger;

    public EverTaskMonitorClient(string hubUrl, ILogger<EverTaskMonitorClient> logger)
    {
        _logger = logger;

        _connection = new HubConnectionBuilder()
            .WithUrl(hubUrl)
            .WithAutomaticReconnect()
            .Build();

        _connection.On<EverTaskEventData>("TaskEvent", OnTaskEvent);
    }

    public async Task StartAsync()
    {
        await _connection.StartAsync();
        _logger.LogInformation("Connected to EverTask monitoring hub");
    }

    private void OnTaskEvent(EverTaskEventData eventData)
    {
        _logger.LogInformation(
            "Received task event: [{Severity}] {TaskType} - {Message}",
            eventData.Severity,
            eventData.TaskType,
            eventData.Message);
    }

    public async Task StopAsync()
    {
        await _connection.StopAsync();
    }
}

// Usage
var client = new EverTaskMonitorClient("https://localhost:5001/evertask-monitoring/hub", logger);
await client.StartAsync();

Filtering Events on Client

You can filter events client-side to avoid cluttering your UI with informational messages:

connection.on("TaskEvent", (eventData) => {
    // Only show errors and warnings
    if (eventData.severity === "Error" || eventData.severity === "Warning") {
        displayEvent(eventData);

        // Play alert sound for errors
        if (eventData.severity === "Error") {
            playAlertSound();
        }
    }

    // Track metrics
    updateMetrics(eventData);
});

function updateMetrics(eventData) {
    // Update dashboard metrics
    if (eventData.severity === "Error") {
        incrementErrorCount();
    }
    if (eventData.message.includes("completed")) {
        incrementCompletedCount();
    }
}

Custom Monitoring Integrations

Want to integrate with your existing monitoring tools? Here are examples for popular platforms.

Application Insights

public class ApplicationInsightsMonitor
{
    private readonly TelemetryClient _telemetry;

    public ApplicationInsightsMonitor(
        IEverTaskWorkerExecutor executor,
        TelemetryClient telemetry)
    {
        _telemetry = telemetry;
        executor.TaskEventOccurredAsync += OnTaskEventAsync;
    }

    private Task OnTaskEventAsync(EverTaskEventData eventData)
    {
        var properties = new Dictionary<string, string>
        {
            ["TaskId"] = eventData.TaskId.ToString(),
            ["TaskType"] = eventData.TaskType,
            ["TaskHandlerType"] = eventData.TaskHandlerType,
            ["Severity"] = eventData.Severity
        };

        switch (eventData.Severity)
        {
            case nameof(SeverityLevel.Error):
                _telemetry.TrackException(
                    new Exception(eventData.Exception ?? eventData.Message),
                    properties);
                break;

            case nameof(SeverityLevel.Warning):
            case nameof(SeverityLevel.Information):
                _telemetry.TrackEvent(
                    $"EverTask.{eventData.Severity}",
                    properties);
                break;
        }

        return Task.CompletedTask;
    }
}

Prometheus Metrics

using Prometheus;

public class PrometheusMonitor
{
    private static readonly Counter TasksStarted = Metrics
        .CreateCounter("evertask_tasks_started_total", "Total tasks started");

    private static readonly Counter TasksCompleted = Metrics
        .CreateCounter("evertask_tasks_completed_total", "Total tasks completed");

    private static readonly Counter TasksFailed = Metrics
        .CreateCounter("evertask_tasks_failed_total", "Total tasks failed",
            new CounterConfiguration { LabelNames = new[] { "task_type" } });

    private static readonly Histogram TaskDuration = Metrics
        .CreateHistogram("evertask_task_duration_seconds", "Task execution duration");

    public PrometheusMonitor(IEverTaskWorkerExecutor executor)
    {
        executor.TaskEventOccurredAsync += OnTaskEventAsync;
    }

    private Task OnTaskEventAsync(EverTaskEventData eventData)
    {
        if (eventData.Message.Contains("started"))
        {
            TasksStarted.Inc();
        }
        else if (eventData.Message.Contains("completed"))
        {
            TasksCompleted.Inc();
        }
        else if (eventData.Severity == nameof(SeverityLevel.Error))
        {
            TasksFailed.WithLabels(eventData.TaskType).Inc();
        }

        return Task.CompletedTask;
    }
}

Email Alerts

public class EmailAlertMonitor
{
    private readonly IEmailService _emailService;
    private readonly ILogger<EmailAlertMonitor> _logger;

    public EmailAlertMonitor(
        IEverTaskWorkerExecutor executor,
        IEmailService emailService,
        ILogger<EmailAlertMonitor> logger)
    {
        _emailService = emailService;
        _logger = logger;
        executor.TaskEventOccurredAsync += OnTaskEventAsync;
    }

    private async Task OnTaskEventAsync(EverTaskEventData eventData)
    {
        // Only alert on critical task failures
        if (eventData.Severity == nameof(SeverityLevel.Error) &&
            IsCriticalTask(eventData.TaskType))
        {
            try
            {
                await _emailService.SendAlertAsync(
                    to: "ops@example.com",
                    subject: $"Critical Task Failure: {eventData.TaskType}",
                    body: $@"
                        Task ID: {eventData.TaskId}
                        Task Type: {eventData.TaskType}
                        Time: {eventData.EventDateUtc}
                        Message: {eventData.Message}
                        Exception: {eventData.Exception}
                    ");

                _logger.LogInformation(
                    "Alert sent for failed task {TaskId}",
                    eventData.TaskId);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to send alert email");
            }
        }
    }

    private bool IsCriticalTask(string taskType)
    {
        return taskType.Contains("Payment") ||
               taskType.Contains("Order") ||
               taskType.Contains("Billing");
    }
}

Slack Notifications

public class SlackMonitor
{
    private readonly HttpClient _httpClient;
    private readonly string _webhookUrl;

    public SlackMonitor(
        IEverTaskWorkerExecutor executor,
        IConfiguration configuration)
    {
        _httpClient = new HttpClient();
        _webhookUrl = configuration["Slack:WebhookUrl"];
        executor.TaskEventOccurredAsync += OnTaskEventAsync;
    }

    private async Task OnTaskEventAsync(EverTaskEventData eventData)
    {
        if (eventData.Severity == nameof(SeverityLevel.Error))
        {
            var payload = new
            {
                text = $"❌ Task Failed: {eventData.TaskType}",
                attachments = new[]
                {
                    new
                    {
                        color = "danger",
                        fields = new[]
                        {
                            new { title = "Task ID", value = eventData.TaskId.ToString(), @short = true },
                            new { title = "Time", value = eventData.EventDateUtc.ToString(), @short = true },
                            new { title = "Message", value = eventData.Message },
                            new { title = "Exception", value = eventData.Exception ?? "N/A" }
                        }
                    }
                }
            };

            await _httpClient.PostAsJsonAsync(_webhookUrl, payload);
        }
    }
}

Logging Integration

EverTask uses standard .NET logging, so it works with whatever logging setup you already have:

Basic Logging

// EverTask automatically logs to ILogger
builder.Services.AddEverTask(opt =>
{
    opt.RegisterTasksFromAssembly(typeof(Program).Assembly);
});

// Logs appear in your configured sinks (Console, File, etc.)

Serilog Integration

dotnet add package EverTask.Logging.Serilog
builder.Services.AddEverTask(opt =>
{
    opt.RegisterTasksFromAssembly(typeof(Program).Assembly);
})
.AddSqlServerStorage(connectionString)
.AddSerilog(opt =>
    opt.ReadFrom.Configuration(
        builder.Configuration,
        new ConfigurationReaderOptions { SectionName = "EverTaskSerilog" }));

appsettings.json:

{
  "EverTaskSerilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning"
      }
    },
    "WriteTo": [
      {
        "Name": "Console"
      },
      {
        "Name": "File",
        "Args": {
          "path": "Logs/evertask-.txt",
          "rollingInterval": "Day"
        }
      },
      {
        "Name": "Seq",
        "Args": {
          "serverUrl": "http://localhost:5341"
        }
      }
    ]
  }
}

Best Practices

1. Monitor Critical Tasks

You don’t need to alert on everything. Focus on tasks that matter most to your business:

private Task OnTaskEventAsync(EverTaskEventData eventData)
{
    if (IsCriticalTask(eventData.TaskType) &&
        eventData.Severity == nameof(SeverityLevel.Error))
    {
        // Immediate alerting for critical failures
        SendImmediateAlert(eventData);
    }

    return Task.CompletedTask;
}

2. Aggregate Metrics

Instead of just logging individual events, track aggregate metrics to spot trends and performance issues:

public class MetricsAggregator
{
    private int _completedCount;
    private int _failedCount;
    private readonly ConcurrentDictionary<string, int> _failuresByType = new();

    private Task OnTaskEventAsync(EverTaskEventData eventData)
    {
        if (eventData.Message.Contains("completed"))
        {
            Interlocked.Increment(ref _completedCount);
        }
        else if (eventData.Severity == nameof(SeverityLevel.Error))
        {
            Interlocked.Increment(ref _failedCount);
            _failuresByType.AddOrUpdate(eventData.TaskType, 1, (_, count) => count + 1);
        }

        return Task.CompletedTask;
    }

    public (int Completed, int Failed, Dictionary<string, int> FailuresByType) GetMetrics()
    {
        return (_completedCount, _failedCount, new Dictionary<string, int>(_failuresByType));
    }
}

3. Avoid Blocking Operations

Keep event handlers fast. If you need to do something slow (like sending an alert), fire and forget it:

// Good: fire-and-forget alerting
private Task OnTaskEventAsync(EverTaskEventData eventData)
{
    if (eventData.Severity == nameof(SeverityLevel.Error))
    {
        _ = Task.Run(() => SendAlertAsync(eventData)); // Fire-and-forget
    }

    return Task.CompletedTask;
}

// Bad: blocking the event pipeline
private async Task OnTaskEventAsync(EverTaskEventData eventData)
{
    if (eventData.Severity == nameof(SeverityLevel.Error))
    {
        await SendAlertAsync(eventData); // This blocks other events from processing
    }
}

4. Handle Event Handler Failures

Wrap your event handlers in try-catch blocks so a failing monitor doesn’t bring down your entire application:

private async Task OnTaskEventAsync(EverTaskEventData eventData)
{
    try
    {
        await ProcessEventAsync(eventData);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Error in event handler");
        // Don't let event handler failures crash the application
    }
}

5. Use Structured Logging

Always use structured logging with named parameters rather than string interpolation. This makes your logs queryable and easier to analyze:

private Task OnTaskEventAsync(EverTaskEventData eventData)
{
    _logger.LogInformation(
        "Task event: {TaskId} {TaskType} {Severity} {Message}",
        eventData.TaskId,
        eventData.TaskType,
        eventData.Severity,
        eventData.Message);

    return Task.CompletedTask;
}

Web Dashboard

For a complete monitoring solution with web UI and REST API, see the Monitoring Dashboard guide.

The monitoring dashboard provides:

  • REST API for task querying and analytics
  • Embedded React dashboard
  • Real-time updates via SignalR
  • Task filtering, sorting, and detailed views
  • Statistics and performance analytics
  • Queue metrics and health monitoring

Quick setup:

builder.Services.AddEverTask(opt =>
{
    opt.RegisterTasksFromAssembly(typeof(Program).Assembly);
})
.AddSqlServerStorage(connectionString)
.AddMonitoringApi(options =>
{
    options.EnableUI = true;
    options.Username = "admin";
    options.Password = "admin";
});

var app = builder.Build();
app.MapEverTaskApi();

// Dashboard: http://localhost:5000/evertask-monitoring

Future Monitoring Options

We’re planning to add:

  • Sentry Crons - Automatic cron monitoring for recurring tasks
  • OpenTelemetry - Distributed tracing and metrics
  • Health Checks - Built-in health check endpoints

Next Steps


Copyright © 2026 Giampaolo Gabba. Distributed under the MIT License.

This site uses Just the Docs, a documentation theme for Jekyll.