/// <summary> /// Creates a <see cref="JObject"/> used as the <see cref="HttpRequestMessage"/> entity body for a <see cref="WebHook"/>. /// </summary> /// <param name="workItem">The <see cref="WebHookWorkItem"/> representing the data to be sent.</param> /// <returns>An initialized <see cref="JObject"/>.</returns> protected virtual JObject CreateWebHookRequestBody(WebHookWorkItem workItem) { if (workItem == null) { throw new ArgumentNullException(nameof(workItem)); } // Set notifications var webhookBody = new WebhookBody { Id = workItem.Id, Attempt = workItem.Offset + 1, }; var properties = workItem.WebHook.Properties; if (properties != null) { webhookBody.Properties = new Dictionary <string, object>(properties); } webhookBody.Notifications = workItem.Notifications.ToArray(); var serializer = _settings.Settings != null?JsonSerializer.Create(_settings.Settings) : JsonSerializer.CreateDefault(); serializer.Converters.Add(new NotificationDictionarySerializer()); return(JObject.FromObject(webhookBody, serializer)); }
/// <summary> /// Adds a SHA 256 signature to the <paramref name="body"/> and adds it to the <paramref name="request"/> as an /// HTTP header to the <see cref="HttpRequestMessage"/> along with the entity body. /// </summary> /// <param name="workItem">The current <see cref="WebHookWorkItem"/>.</param> /// <param name="request">The request to add the signature to.</param> /// <param name="body">The body to sign and add to the request.</param> protected virtual void SignWebHookRequest(WebHookWorkItem workItem, HttpRequestMessage request, JObject body) { if (workItem == null) { throw new ArgumentNullException("workItem"); } if (workItem.WebHook == null) { string msg = string.Format(CustomResource.Sender_BadWorkItem, this.GetType().Name, "WebHook"); throw new ArgumentException(msg, "workItem"); } if (request == null) { throw new ArgumentNullException("request"); } if (body == null) { throw new ArgumentNullException("body"); } byte[] secret = Encoding.UTF8.GetBytes(workItem.WebHook.Secret); using (var hasher = new HMACSHA256(secret)) { string serializedBody = body.ToString(); request.Content = new StringContent(serializedBody, Encoding.UTF8, "application/json"); byte[] data = Encoding.UTF8.GetBytes(serializedBody); byte[] sha256 = hasher.ComputeHash(data); string headerValue = string.Format(CultureInfo.InvariantCulture, SignatureHeaderValueTemplate, EncodingUtilities.ToHex(sha256)); request.Headers.Add(SignatureHeaderName, headerValue); } }
/// <summary> /// Creates a <see cref="JObject"/> used as the <see cref="HttpRequestMessage"/> entity body for a <see cref="WebHook"/>. /// </summary> /// <param name="workItem">The <see cref="WebHookWorkItem"/> representing the data to be sent.</param> /// <returns>An initialized <see cref="JObject"/>.</returns> protected virtual JObject CreateWebHookRequestBody(WebHookWorkItem workItem) { if (workItem == null) { throw new ArgumentNullException(nameof(workItem)); } var body = new Dictionary <string, object> { // Set properties from work item [BodyIdKey] = workItem.Id, [BodyAttemptKey] = workItem.Offset + 1 }; // Set properties from WebHook var properties = workItem.WebHook.Properties; if (properties != null) { body[BodyPropertiesKey] = new Dictionary <string, object>(properties); } // Set notifications body[BodyNotificationsKey] = workItem.Notifications; return(JObject.FromObject(body)); }
protected virtual HttpRequestMessage CreateWebHookRequest(WebHookWorkItem workItem) { if (workItem == null) { throw new ArgumentNullException(nameof(workItem)); } var hook = workItem.WebHook; // Create WebHook request var request = new HttpRequestMessage(HttpMethod.Post, hook.WebHookUri); // Fill in request body based on WebHook and work item data var body = CreateWebHookRequestBody(workItem); SignWebHookRequest(workItem, request, body); // Add extra request or entity headers foreach (var kvp in hook.Headers) { if (!request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value)) { if (!request.Content.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value)) { var message = string.Format(CultureInfo.CurrentCulture, CustomResources.Manager_InvalidHeader, kvp.Key, hook.Id); _logger.LogError(message); } } } return(request); }
/// <summary> /// Adds a SHA 256 signature to the <paramref name="body"/> and adds it to the <paramref name="request"/> as an /// HTTP header to the <see cref="HttpRequestMessage"/> along with the entity body. /// </summary> /// <param name="workItem">The current <see cref="WebHookWorkItem"/>.</param> /// <param name="request">The request to add the signature to.</param> /// <param name="body">The body to sign and add to the request.</param> protected virtual void SignWebHookRequest(WebHookWorkItem workItem, HttpRequestMessage request, JObject body) { if (workItem == null) { throw new ArgumentNullException(nameof(workItem)); } if (workItem.WebHook == null) { var message = $"Invalid '{GetType().Name}' instance: '{"WebHook"}' cannot be null."; throw new ArgumentException(message, "workItem"); } if (request == null) { throw new ArgumentNullException(nameof(request)); } if (body == null) { throw new ArgumentNullException(nameof(body)); } var secret = Encoding.UTF8.GetBytes(workItem.WebHook.Secret); using (var hasher = new HMACSHA256(secret)) { var serializedBody = body.ToString(); request.Content = new StringContent(serializedBody, Encoding.UTF8, "application/json"); var data = Encoding.UTF8.GetBytes(serializedBody); var sha256 = hasher.ComputeHash(data); var headerValue = string.Format(CultureInfo.InvariantCulture, SignatureHeaderValueTemplate, EncodingUtilities.ToHex(sha256)); request.Headers.Add(SignatureHeaderName, headerValue); } }
/// <summary> /// Launch a <see cref="WebHook"/>. /// </summary> /// <remarks>We don't let exceptions propagate out from this method as it is used by the launchers /// and if they see an exception they shut down.</remarks> private async Task LaunchWebHook(WebHookWorkItem workItem, CancellationToken cancellationToken) { await OnWebHookAttempt(workItem); workItem.Offset++; var request = CreateWebHookRequest(workItem); var response = await _httpClient.SendAsync(request, cancellationToken); var message = string.Format(CultureInfo.CurrentCulture, CustomResources.Manager_Result, workItem.WebHook.Id, response.StatusCode, workItem.Offset); Logger.LogInformation(message); if (response.IsSuccessStatusCode) { // If we get a successful response then we are done. await OnWebHookSuccess(workItem); return; } else if (response.StatusCode == HttpStatusCode.Gone) { // If we get a 410 Gone then we are also done. await OnWebHookGone(workItem); return; } else { response.EnsureSuccessStatusCode(); // throw exception to handle via Polly } }
protected virtual HttpRequestMessage CreateWebHookRequest(WebHookWorkItem workItem) { if (workItem == null) { throw new ArgumentNullException(nameof(workItem)); } var hook = workItem.WebHook; // Create WebHook request var request = new HttpRequestMessage(HttpMethod.Post, hook.WebHookUri); // Fill in request body based on WebHook and work item data var body = CreateWebHookRequestBody(workItem); SignWebHookRequest(workItem, request, body); AddWebHookMetadata(workItem, request); // Add extra request or entity headers foreach (var kvp in hook.Headers) { if (!request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value)) { if (!request.Content.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value)) { var message = string.Format("Could not add header field \'{0}\' to the WebHook request for WebHook ID \'{1}\'.", kvp.Key, hook.Id); _logger.LogError(message); } } } return(request); }
/// <summary> /// Dequeues available WebHooks and sends them out to each WebHook recipient. /// </summary> protected virtual async Task DequeueAndSendWebHooks(CancellationToken cancellationToken) { bool isEmpty = false; while (true) { try { do { if (cancellationToken.IsCancellationRequested) { return; } // Dequeue messages from Azure queue CloudQueue _queue = await _storageManager.GetCloudQueueAsync(_options.ConnectionString, AzureWebHookSender.WebHookQueue); IEnumerable <CloudQueueMessage> messages = await _storageManager.GetMessagesAsync(_queue, MaxDequeuedMessages, _options.MessageTimeout); // Extract the work items ICollection <WebHookWorkItem> workItems = messages.Select(m => { WebHookWorkItem workItem = JsonConvert.DeserializeObject <WebHookWorkItem>(m.AsString, _serializerSettings); workItem.Properties[QueueMessageKey] = m; return(workItem); }).ToArray(); if (cancellationToken.IsCancellationRequested) { return; } // Submit work items to be sent to WebHook receivers if (workItems.Count > 0) { await _sender.SendWebHookWorkItemsAsync(workItems); } isEmpty = workItems.Count == 0; }while (!isEmpty); } catch (Exception ex) { CloudQueue _queue = await _storageManager.GetCloudQueueAsync(_options.ConnectionString, AzureWebHookSender.WebHookQueue); string msg = string.Format(AzureStorageResource.DequeueManager_ErrorDequeueing, _queue.Name, ex.Message); _logger.LogError(msg, ex); } try { await Task.Delay(_options.Frequency, cancellationToken); } catch (OperationCanceledException oex) { _logger.LogError(oex.Message, oex); return; } } }
/// <summary> /// TBD /// </summary> /// <param name="webHooks"></param> /// <param name="notifications"></param> /// <returns></returns> public static IEnumerable <WebHookWorkItem> GetWorkItems(ICollection <WebHook> webHooks, ICollection <Notification> notifications) { var workItems = new List <WebHookWorkItem>(); foreach (var webHook in webHooks) { ICollection <Notification> webHookNotifications; // Pick the notifications that apply for this particular WebHook. If we only got one notification // then we know that it applies to all WebHooks. Otherwise each notification may apply only to a subset. if (notifications.Count == 1) { webHookNotifications = notifications; } else { webHookNotifications = notifications.Where(n => webHook.MatchesAction(n.Action)).ToArray(); if (webHookNotifications.Count == 0) { continue; } } var workItem = new WebHookWorkItem(webHook, webHookNotifications.First()); workItems.Add(workItem); } return(workItems); }
/// <inheritdoc /> public override async Task SendWebHookWorkItemsAsync(IEnumerable <WebHookWorkItem> workItems) { if (workItems == null) { throw new ArgumentNullException("workItems"); } // Keep track of which queued messages should be deleted because processing has completed. List <CloudQueueMessage> deleteMessages = new List <CloudQueueMessage>(); // Submit WebHook requests in parallel List <Task <HttpResponseMessage> > requestTasks = new List <Task <HttpResponseMessage> >(); foreach (var workItem in workItems) { HttpRequestMessage request = CreateWebHookRequest(workItem); request.Properties[AzureWebHookDequeueManager.WorkItemKey] = workItem; try { Task <HttpResponseMessage> requestTask = _parent._httpClient.SendAsync(request); requestTasks.Add(requestTask); } catch (Exception ex) { string msg = string.Format(AzureStorageResource.DequeueManager_SendFailure, request.RequestUri, ex.Message); Logger.LogInformation(msg); CloudQueueMessage message = GetMessage(workItem); if (DiscardMessage(workItem, message)) { deleteMessages.Add(message); } } } // Wait for all responses and see which messages should be deleted from the queue based on the response statuses. HttpResponseMessage[] responses = await Task.WhenAll(requestTasks); foreach (HttpResponseMessage response in responses) { WebHookWorkItem workItem = response.RequestMessage.Properties[AzureWebHookDequeueManager.WorkItemKey] as WebHookWorkItem; string msg = string.Format(AzureStorageResource.DequeueManager_WebHookStatus, workItem.WebHook.Id, response.StatusCode, workItem.Offset); Logger.LogInformation(msg); // If success or 'gone' HTTP status code then we remove the message from the Azure queue. // If error then we leave it in the queue to be consumed once it becomes visible again or we give up CloudQueueMessage message = GetMessage(workItem); if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.Gone || DiscardMessage(workItem, message)) { deleteMessages.Add(message); } } // Delete successfully delivered messages and messages that have been attempted delivered too many times. CloudQueue _queue = await _parent._storageManager.GetCloudQueueAsync(_parent._options.ConnectionString, AzureWebHookSender.WebHookQueue); await _parent._storageManager.DeleteMessagesAsync(_queue, deleteMessages); }
/// <summary> /// Creates a <see cref="JObject"/> used as the <see cref="HttpRequestMessage"/> entity body for a <see cref="WebHook"/>. /// </summary> /// <param name="workItem">The <see cref="WebHookWorkItem"/> representing the data to be sent.</param> /// <returns>An initialized <see cref="JObject"/>.</returns> protected virtual JObject CreateWebHookRequestBody(WebHookWorkItem workItem) { if (workItem == null) { throw new ArgumentNullException(nameof(workItem)); } return(JObject.FromObject(workItem.Notification.Payload, JsonSerializer.Create(_options.Value.SerializerSettings))); }
/// <summary> /// Launch a <see cref="WebHook"/>. /// </summary> /// <remarks>We don't let exceptions propagate out from this method as it is used by the launchers /// and if they see an exception they shut down.</remarks> private async Task LaunchWebHook(WebHookWorkItem workItem) { try { // Setting up and send WebHook request HttpRequestMessage request = CreateWebHookRequest(workItem); HttpResponseMessage response = await _httpClient.SendAsync(request); string msg = string.Format(CustomResource.Manager_Result, workItem.WebHook.Id, response.StatusCode, workItem.Offset); Logger.LogInformation(msg); if (response.IsSuccessStatusCode) { // If we get a successful response then we are done. await OnWebHookSuccess(workItem); return; } else if (response.StatusCode == HttpStatusCode.Gone) { // If we get a 410 Gone then we are also done. await OnWebHookGone(workItem); return; } } catch (Exception ex) { string msg = string.Format(CustomResource.Manager_WebHookFailure, workItem.Offset, workItem.WebHook.Id, ex.Message); Logger.LogError(msg, ex); } try { // See if we should retry the request with delay or give up workItem.Offset++; if (workItem.Offset < _launchers.Length) { // If we are to retry then we submit the request again after a delay. await OnWebHookRetry(workItem); _launchers[workItem.Offset].Post(workItem); } else { string msg = string.Format(CustomResource.Manager_GivingUp, workItem.WebHook.Id, workItem.Offset); Logger.LogError(msg); await OnWebHookFailure(workItem); } } catch (Exception ex) { string msg = string.Format(CustomResource.Manager_WebHookFailure, workItem.Offset, workItem.WebHook.Id, ex.Message); Logger.LogError(msg, ex); } }
private bool DiscardMessage(WebHookWorkItem workItem, CloudQueueMessage message) { if (message.DequeueCount >= _parent._options.MaxDeQueueCount) { string error = string.Format(AzureStorageResource.DequeueManager_GivingUp, workItem.WebHook.Id, message.DequeueCount); Logger.LogError(error); return(true); } return(false); }
/// <summary> /// Launch a <see cref="WebHook"/>. /// </summary> /// <remarks>We don't let exceptions propagate out from this method as it is used by the launchers /// and if they see an exception they shut down.</remarks> private async Task LaunchWebHook(WebHookWorkItem workItem) { try { // Setting up and send WebHook request var request = CreateWebHookRequest(workItem); var response = await _httpClient.SendAsync(request); var message = $"WebHook '{workItem.WebHook.Id}' resulted in status code '{response.StatusCode}' on attempt '{workItem.Offset}'."; Logger.LogInformation(message); if (response.IsSuccessStatusCode) { // If we get a successful response then we are done. await OnWebHookSuccess(workItem); return; } else if (response.StatusCode == HttpStatusCode.Gone) { // If we get a 410 Gone then we are also done. await OnWebHookGone(workItem); return; } } catch (Exception ex) { var message = $"Failed to submit attempt {workItem.Offset} of WebHook {workItem.WebHook.Id} due to failure: {ex.Message}"; Logger.LogError(message, ex); } try { // See if we should retry the request with delay or give up workItem.Offset++; if (workItem.Offset < _launchers.Length) { // If we are to retry then we submit the request again after a delay. await OnWebHookRetry(workItem); _launchers[workItem.Offset].Post(workItem); } else { var message = $"Giving up sending WebHook '{workItem.WebHook.Id}' after '{workItem.Offset}' attempts."; Logger.LogError(message); await OnWebHookFailure(workItem); } } catch (Exception ex) { var message = $"Failed to submit attempt {workItem.Offset} of WebHook {workItem.WebHook.Id} due to failure: {ex.Message}"; Logger.LogError(message, ex); } }
private CloudQueueMessage GetMessage(WebHookWorkItem workItem) { CloudQueueMessage message = workItem != null ? workItem.Properties[AzureWebHookDequeueManager.QueueMessageKey] as CloudQueueMessage : null; if (message == null) { string msg = string.Format(AzureStorageResource.DequeueManager_NoProperty, AzureWebHookDequeueManager.QueueMessageKey, workItem.Id); Logger.LogError(msg); throw new InvalidOperationException(msg); } return(message); }
private async Task SendWithPolly(WebHookWorkItem workitem) { try { var policy = _policies.GetOrAdd(workitem.WebHook.WebHookUri.Host, CreatePolicy); await policy.ExecuteAsync((CancellationToken cancellationToken) => LaunchWebHook(workitem, cancellationToken), CancellationToken.None); } catch (Exception) { await OnWebHookFailure(workitem); } }
/// <summary> /// Creates a <see cref="JObject"/> used as the <see cref="HttpRequestMessage"/> entity body for a <see cref="WebHook"/>. /// </summary> /// <param name="workItem">The <see cref="WebHookWorkItem"/> representing the data to be sent.</param> /// <returns>An initialized <see cref="JObject"/>.</returns> protected virtual void CreateWebHookRequestBody(WebHookWorkItem workItem, StreamWriter writer) { if (workItem == null) { throw new ArgumentNullException(nameof(workItem)); } // Set notifications var webhookBody = new WebhookBody { Id = workItem.Id, Attempt = workItem.Offset + 1, }; var properties = workItem.WebHook.Properties; if (properties != null) { webhookBody.Properties = new Dictionary <string, object>(properties); } webhookBody.Notifications = workItem.Notifications.ToArray(); _serializer.Serialize(writer, webhookBody); }
/// <summary> /// Adds a SHA 256 signature to the <paramref name="body"/> and adds it to the <paramref name="request"/> as an /// HTTP header to the <see cref="HttpRequestMessage"/> along with the entity body. /// </summary> /// <param name="workItem">The current <see cref="WebHookWorkItem"/>.</param> /// <param name="request">The request to add the signature to.</param> /// <param name="body">The body to sign and add to the request.</param> protected virtual void SignWebHookRequest(WebHookWorkItem workItem, HttpRequestMessage request, Stream body) { if (workItem == null) { throw new ArgumentNullException(nameof(workItem)); } if (workItem.WebHook == null) { var message = string.Format(CultureInfo.CurrentCulture, CustomResources.Sender_BadWorkItem, GetType().Name, "WebHook"); throw new ArgumentException(message, "workItem"); } if (request == null) { throw new ArgumentNullException(nameof(request)); } if (body == null) { throw new ArgumentNullException(nameof(body)); } var secret = Encoding.UTF8.GetBytes(workItem.WebHook.Secret); using var hasher = new HMACSHA256(secret); var sha256 = hasher.ComputeHash(body); var headerValue = string.Format(CultureInfo.InvariantCulture, SignatureHeaderValueTemplate, EncodingUtilities.ToHex(sha256)); request.Headers.Add(SignatureHeaderName, headerValue); body.Seek(0, SeekOrigin.Begin); var content = new StreamContent(body); content.Headers.ContentType = ApplicationJson; request.Content = content; }
protected virtual async Task <HttpRequestMessage> CreateWebHookRequestAsync(WebHookWorkItem workItem) { if (workItem == null) { throw new ArgumentNullException(nameof(workItem)); } var hook = workItem.WebHook; // Create WebHook request var request = new HttpRequestMessage(HttpMethod.Post, hook.WebHookUri); var ms = new MemoryStream(); var writer = new StreamWriter(ms); CreateWebHookRequestBody(workItem, writer); await writer.FlushAsync(); ms.Seek(0, SeekOrigin.Begin); SignWebHookRequest(workItem, request, ms); // Add extra request or entity headers foreach (var kvp in hook.Headers) { if (!request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value)) { if (!request.Content.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value)) { var message = string.Format(CultureInfo.CurrentCulture, CustomResources.Manager_InvalidHeader, kvp.Key, hook.Id); _logger.LogError(message); } } } return(request); }
private async Task DelayedLaunchWebHook(WebHookWorkItem item, TimeSpan delay) { await Task.Delay(delay); await LaunchWebHook(item); }
/// <summary> /// If delivery of a WebHook results in a 410 Gone HTTP status code, then <see cref="OnWebHookGone"/> /// is called enabling additional post-processing. /// </summary> /// <param name="workItem">The current <see cref="WebHookWorkItem"/>.</param> protected virtual Task OnWebHookGone(WebHookWorkItem workItem) { return(Task.FromResult(true)); }
protected virtual Task OnWebHookAttempt(WebHookWorkItem workitem) { return(Task.CompletedTask); }
private void AddWebHookMetadata(WebHookWorkItem workItem, HttpRequestMessage request) { request.Headers.Add(HeaderIdKey, workItem.Id); request.Headers.Add(HeaderAttemptKey, (workItem.Offset + 1).ToString()); request.Headers.Add(HeaderNotificationKey, workItem.Notification.Action); }
/// <summary> /// If delivery of a WebHook results in a 410 Gone HTTP status code, then <see cref="OnWebHookGone"/> /// is called enabling additional post-processing. /// </summary> /// <param name="workItem">The current <see cref="WebHookWorkItem"/>.</param> protected virtual Task OnWebHookGone(WebHookWorkItem workItem) { return(Task.CompletedTask); }
/// <summary> /// If delivery of a WebHook is successful, i.e. a 2xx HTTP status code is received, /// then <see cref="OnWebHookSuccess"/> is called enabling additional post-processing. /// </summary> /// <param name="workItem">The current <see cref="WebHookWorkItem"/>.</param> protected virtual Task OnWebHookSuccess(WebHookWorkItem workItem) => Task.CompletedTask;
/// <summary> /// If delivery of a WebHook results in a 410 Gone HTTP status code, then <see cref="OnWebHookGone"/> /// is called enabling additional post-processing. /// </summary> /// <param name="workItem">The current <see cref="WebHookWorkItem"/>.</param> protected virtual Task OnWebHookGone(WebHookWorkItem workItem) => Task.CompletedTask;
/// <summary> /// Launch a <see cref="WebHook"/>. /// </summary> /// <remarks>We don't let exceptions propagate out from this method as it is used by the launchers /// and if they see an exception they shut down.</remarks> private async Task LaunchWebHook(WebHookWorkItem workItem) { var policy = _policyContainer.GetPolicyFor(workItem.WebHook); try { policy.AcquireUse(); // Setting up and send WebHook request using var request = await CreateWebHookRequestAsync(workItem); using var response = await _httpClient.SendAsync(request); var message = string.Format(CultureInfo.CurrentCulture, CustomResources.Manager_Result, workItem.WebHook.Id, response.StatusCode, workItem.Offset); Logger.LogInformation(message); if (response.IsSuccessStatusCode) { policy.Success(); // If we get a successful response then we are done. await OnWebHookSuccess(workItem); return; } else if (response.StatusCode == HttpStatusCode.Gone) { // If we get a 410 Gone then we are also done. await OnWebHookGone(workItem); return; } } catch (CircuitBreakerException) { var message = string.Format(CultureInfo.CurrentCulture, CustomResources.Manager_GivingUp, workItem.WebHook.Id, workItem.Offset); Logger.LogInformation(message); await OnWebHookFailure(workItem); return; } catch (Exception ex) { var message = string.Format(CultureInfo.CurrentCulture, CustomResources.Manager_WebHookFailure, workItem.Offset, workItem.WebHook.Id, ex.Message); Logger.LogInformation(message, ex); } try { // See if we should retry the request with delay or give up workItem.Offset++; policy.Failure(); if (workItem.Offset < _launchers.Length) { // If we are to retry then we submit the request again after a delay. await OnWebHookRetry(workItem); _launchers[workItem.Offset].Post(workItem); } else { var message = string.Format(CultureInfo.CurrentCulture, CustomResources.Manager_GivingUp, workItem.WebHook.Id, workItem.Offset); Logger.LogInformation(message); await OnWebHookFailure(workItem); } } catch (Exception ex) { var message = string.Format(CultureInfo.CurrentCulture, CustomResources.Manager_WebHookFailure, workItem.Offset, workItem.WebHook.Id, ex.Message); Logger.LogInformation(message, ex); } }