A step-by-step guide to building a new log-sink for Herald. Aimed at a programmer who knows basic C# but has never worked on Herald before. Every concept is explained before it's used; every code sample is complete enough to paste and adapt.
By the end you'll have a working sink that ships events to a destination of your choice, with a configuration form that appears automatically in the Herald Dashboard.
A sink is the thing that takes a log event and sends it somewhere — a file, a web service, a database, a Kafka topic, whatever. Your job is to write that "whatever."
Herald does all the hard work leading up to the sink. By the time an event reaches you, it's been filtered for level, enriched with metadata, queued for async delivery, maybe batched with other events. Your sink just has to answer one question:
"I've been handed a
LogEvent. What should I do with it?"
For most sinks, the answer is "serialize it and send it somewhere."
graph TD
App[Application code<br/>logger.Info(...)]
Pipeline[Herald pipeline<br/>filters, enrichers, async queue,<br/>batching, rendering, routing]
Fanout[Fan-out<br/>deliver to each sink]
Sink1[Your new sink]
Sink2[Console sink]
Sink3[File sink]
App --> Pipeline
Pipeline --> Fanout
Fanout --> Sink1
Fanout --> Sink2
Fanout --> Sink3
Your sink is at the end of the pipeline. It sees the event last. Other decorators have already done the filtering, enrichment, batching, and rendering. The event you receive is final — you just need to deliver it.
Think of Herald as a post office:
- The application writes a letter (a log event)
- The pipeline sorts, stamps, and bundles the letter (filters, enrichers, batching)
- The sink is the mailbox where the letter actually goes out — one mailbox might be a file on disk, another might be an HTTP endpoint, another might be Slack
Your job is to build one mailbox. You don't care about sorting or stamping. You just take what's handed to you and make sure it leaves the building.
Every sink is exactly two classes working together:
graph LR
Config[JSON config or fluent builder:<br/>"kind": "acme",<br/>"endpoint": "..."]
Provider[AcmeLogSinkProvider<br/>the factory]
Sink[AcmeLogSink<br/>the worker]
Event[LogEvent]
Config -->|bootstrap reads config| Provider
Provider -->|creates| Sink
Event -->|logger.Info(...) eventually reaches| Sink
The provider (AcmeLogSinkProvider) is a factory. Herald's bootstrap code sees "kind": "acme" in your config, asks the provider to build a sink, and the provider returns one.
The sink (AcmeLogSink) is the worker. It receives events one by one and ships them to the destination.
That's it. Nothing else. You don't need to know about async queues, filters, decorators, or any other Herald machinery. The provider creates the worker, the worker ships events.
Every sink implements one interface:
// This is already defined in Herald.Core — you just implement it.
public interface ILogger
{
// Called once per event (or once per batch if you implement IBatchedLogSink).
void Log(LogEvent logEvent);
// Optional — only override if your destination supports real async I/O.
// Default forwards to Log() which is fine for most HTTP sinks.
ValueTask LogAsync(LogEvent logEvent, CancellationToken cancellationToken = default);
}That's the whole contract. Everything your sink does happens inside Log or LogAsync.
public interface ILogSinkProvider
{
string SinkKind { get; } // "acme" — matches the JSON config
HeraldEdition MinimumEdition { get; } // Community | Pro | Enterprise
ILogger CreateSink(
LoggingRuntimeSinkDefinition definition,
ILogLevelRegistry levelRegistry,
ILogOutputTransformerRegistry transformerRegistry);
}The provider's only job is to read definition (which carries the config values the user set) and return a new sink instance.
A sink ships as five files in a specific layout:
src/Herald.Sinks.Acme/
├── Herald.Sinks.Acme.csproj # the .NET project file
├── CAPABILITY.yaml # the manifest (form fields, capabilities, etc.)
├── AcmeLogSink.cs # the worker
├── Providers/
│ └── AcmeLogSinkProvider.cs # the factory
└── README.md # optional — only if there's operator nuance
# CAPABILITY.yaml doesn't cover
tests/Herald.Sinks.Acme.Tests/
├── Herald.Sinks.Acme.Tests.csproj # the test project
└── AcmeLogSinkTests.cs # your tests
Five files. Three of them (csproj, the provider, the test project) are boilerplate you copy from another sink. The two that carry your logic are the sink class and the CAPABILITY.yaml.
The manifest is the contract between your sink and everything that consumes it — the Dashboard, the product-sheet generator, the NuGet package, the release pipeline. Every field has a purpose; none of them are optional without a stated default.
Here's every property, grouped by section.
| Property | Example | Purpose |
|---|---|---|
name |
Herald.Sinks.Acme |
Must match the csproj filename exactly. The build validates this. |
package_id |
Herald.Sinks.Acme |
The NuGet package id published to nuget.org. Must start with Herald.Sinks. (not MMP.). |
version |
1.0.0 |
Must match <Version> in the csproj. Release scripts use this to decide whether to publish. |
kind |
sink |
Always sink in this repo. Reserved for future categories. |
category |
observability |
One of: observability, cloud-archive, alerting, analytics, community. Drives the product-sheet grouping. |
| Property | Example | Purpose |
|---|---|---|
purpose |
"Posts log events to Acme's intake." | One to three sentences. Flows into the NuGet package description. Keep under 300 characters. |
vendor.name |
Acme Observability |
The company or product behind the destination. |
vendor.url |
https://acme.example.com |
Link to the vendor's docs. |
| Property | Example | Purpose |
|---|---|---|
ships |
[AcmeLogSink, AcmeLogSinkProvider] |
Every public type the package exports. Readers use this to know what they can using without opening the repo. |
| Property | Example | Purpose |
|---|---|---|
requires.core_version |
">=1.0.0" |
Minimum Herald.Core version your sink was tested against. Bump when you start using a new Core contract. |
requires.nuget |
see example | External NuGet packages beyond Core. Omit for BCL-only sinks. |
requires.external |
["Acme API key"] |
Things the operator must have outside code — accounts, credentials, reachable endpoints. |
This block describes how a handwritten JSON config file maps to your sink. Operators who edit config files by hand read this; the Dashboard uses dashboard_config (below) instead.
| Property | Example | Purpose |
|---|---|---|
config.kind |
acme |
The string operators put in "kind": "..." to pick your sink. |
config.uri |
"https://ingest.acme.com" |
What the uri JSON field means. null if the sink doesn't read it. |
config.host |
"service name" |
What the host JSON field means. null if unused. |
config.alias |
"API key" |
What the alias JSON field means. null if unused. |
config.notes |
multi-line text | Anything operators need to know — defaults, override patterns, auth shape. |
This is where your sink says "here are the fields I want a user to set." One entry per form field.
Five fields are required per entry:
| Property | Example | Purpose |
|---|---|---|
property |
api_key |
The key name your provider reads from the config at bootstrap. |
name |
API key |
The human label shown above the field. |
help |
multi-line text | Longer guidance shown below the field. |
tooltip |
"Datadog DD-API-KEY — masked after save." |
Short hover hint. One sentence, no line breaks. |
width |
s, m, or l |
Layout hint: s ≈ 1/4 row, m ≈ 1/2 row, l = full row. |
Plus a control (which widget to render) and control-specific options.
| Control | What it renders | Options |
|---|---|---|
text |
Plain text input | min_length, max_length |
patterned-text |
Text + regex validation | pattern (named-capture regex), min_length, max_length |
secret |
Masked password-style input; Dashboard never re-emits after save | min_length |
number |
Number input with spinner | min, max, step, integer (bool), unit (text beside field) |
checkbox |
Boolean toggle | — |
select |
Single-select dropdown | values OR values_source (see below) |
multiselect |
Multi-select dropdown | values OR values_source, min_entries, max_entries |
combobox |
Text input + suggestion dropdown (custom values allowed) | values OR values_source, pattern |
url |
URL input with URL validation | schemes (whitelist) |
duration |
ISO 8601 duration input (PT10S) |
min, max |
key-value-list |
Add/remove key=value pairs | key_label, value_label, key_pattern, value_pattern, min_entries, max_entries |
tag-list |
Add/remove string entries | value_label, pattern, min_entries, max_entries |
Static plain strings (when value equals display text):
values:
- us-east-1
- us-west-2
- eu-west-1Static value/text pairs (when you need distinct stored value and label):
values:
- value: trace
text: "Trace (verbose)"
description: "Every pipeline step logs. Dev only."
- value: info
text: "Info (recommended)"
- value: deprecated-mode
text: "Legacy"
disabled: true # visible but unselectableMixing the two forms in one list is legal.
When the list depends on live state (e.g., "available regions" from AWS, "buckets I can see" from Azure), declare a method the Dashboard calls server-side:
values_source:
method: GetAvailableRegions # static method on your sink type
refresh: on-focus # on-load | on-focus | manual
depends_on: [api_key] # re-fetch when these fields change
timeout_seconds: 5
error_action: fallback # fallback | disable | error-banner
fallback: # list shown if the method fails
- value: us-east-1
text: "US East 1"The method you declare must match this signature exactly:
public static async Task<IReadOnlyList<SelectOption>> GetAvailableRegions(
IReadOnlyDictionary<string, string> currentConfig,
CancellationToken cancellationToken)
{
// currentConfig carries the other fields' current values — you can
// read the API key, region, whatever you need to make the call.
var apiKey = currentConfig.GetValueOrDefault("api_key");
// Make the actual discovery call here — HTTP, SDK call, whatever.
// Return the list as SelectOption values.
return new[]
{
new SelectOption("us-east-1", "US East 1 (N. Virginia)"),
new SelectOption("us-west-2", "US West 2 (Oregon)"),
};
}The method must be public static on a type inside your sink's assembly. The management API resolves it by name at runtime and calls it when the Dashboard needs a fresh list.
sequenceDiagram
participant Operator
participant Dashboard
participant Server as Herald.Server
participant Sink as Your sink's static method
Operator->>Dashboard: focuses "Region" field
Dashboard->>Server: POST /api/sinks/acme/options/region<br/>with current form values
Server->>Sink: reflection call:<br/>GetAvailableRegions(currentConfig, ct)
Sink-->>Server: list of SelectOption values
Server-->>Dashboard: JSON array of options
Dashboard->>Operator: dropdown populates with live list
Every control has standard error codes. The Dashboard shows a default message for each; you override when destination-specific language helps.
| Code | When it fires |
|---|---|
required |
Field is empty but required |
pattern |
Value fails the supplied regex |
min / max |
Number or duration outside range |
min-length / max-length |
String shorter/longer than bounds |
min-entries / max-entries |
List or map has wrong number of entries |
out-of-set |
select/multiselect value not in values |
key-pattern / value-pattern |
Key or value in list/map fails its regex |
url-malformed |
URL control input is not a valid URL |
url-scheme-not-allowed |
URL scheme not in the allowed list |
duration-malformed |
Duration input is not ISO 8601 |
Override a code's message per field:
- property: api_key
control: secret
required: true
min_length: 32
errors:
required: "Acme API key is required before events can be sent."
min-length: "Acme API keys are at least 32 characters."For destination-specific validation (e.g., "API key must match Acme's format"), define your own codes. Prefix with your sink's short name to prevent collisions:
errors:
acme-api-key-format: "Acme keys are 40 hex characters starting with 'ak_'."If your sink can verify its config without sending a real event (a ping endpoint, an auth-only call), declare the probe so the Dashboard renders a "Test connection" button:
connectivity_probe:
method: ProbeAsync # static method on your sink
timeout_seconds: 10
success_message: "Connected to Acme."
failure_message: "Could not reach Acme with these credentials."The method signature:
public static async Task<bool> ProbeAsync(
IReadOnlyDictionary<string, string> currentConfig,
CancellationToken cancellationToken)
{
// Try to reach the destination with the supplied config.
// Return true on success, false otherwise.
// Exceptions count as failure.
}Omit the block entirely and the Dashboard hides the button.
Two parallel lists. Readers scanning the product sheet read these first.
capabilities:
- Batched delivery via IBatchedLogSink
- Level mapping to Acme's severity vocabulary
- TLS transport by default
limitations:
- No compression today (gzip support is future work)
- No SASL authentication — API-key only| Property | Values | Purpose |
|---|---|---|
minimum_edition |
Community, Pro, Enterprise |
Matches ILogSinkProvider.MinimumEdition. JSON-config validation uses this. |
aot_compatible |
true or false |
Whether your sink's dependency graph is AOT-clean. Must reflect reality. |
thread_safety |
one-line text | Threading contract. E.g., "Thread-safe: shared HttpClient, synchronous Send." |
test_coverage |
text | Path to your test file + test count. |
Metapackages are zero-code NuGet packages that bundle curated sink sets. Declare which metapackages include your sink:
product_pack:
- Herald.Business
- Herald.Game.ProEmpty list is legal — community-only sinks not in any official bundle.
maintenance:
level: active # active | maintained | deprecated | archived
owner: <GitHub handle>
last_audit: 2026-04-24| Level | Meaning |
|---|---|
active |
Owner responding to issues; features still landing |
maintained |
Bug fixes only; no new features |
deprecated |
Replaced by another sink; start migrating |
archived |
No longer tested; install at your own risk |
One line per version bump. Detailed bug history stays in git log; this list is for release-notes generation.
changelog:
- version: 1.0.0
date: 2026-04-24
summary: Initial releaseLet's walk through writing a hypothetical Herald.Sinks.Acme that POSTs events to a made-up Acme log-ingest endpoint. Copy each file, adapt, go.
// src/Herald.Sinks.Acme/AcmeLogSink.cs
// Copyright (c) 2026 MMPWorks LLC
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
#nullable enable
using System;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using MMP.Herald.Events;
using MMP.Herald.Pipeline;
using MMP.Herald.Services;
namespace Herald.Sinks.Acme;
/// <summary>
/// Ships log events to Acme's HTTP log intake.
/// </summary>
public sealed class AcmeLogSink : ILogger, IDisposable
{
private readonly Uri _endpoint;
private readonly string _apiKey;
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
public AcmeLogSink(string endpoint, string apiKey, HttpClient? httpClient = null)
{
// Fail fast on missing config. Operators see the error at
// pipeline-build time, not on the first log call.
ArgumentException.ThrowIfNullOrWhiteSpace(endpoint);
ArgumentException.ThrowIfNullOrWhiteSpace(apiKey);
_endpoint = new Uri(endpoint, UriKind.Absolute);
_apiKey = apiKey;
// Share the HttpClient if one is supplied (tests do this); otherwise
// own a new one with a sensible timeout.
_ownsHttpClient = httpClient is null;
_httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
}
public void Log(LogEvent logEvent)
{
ArgumentNullException.ThrowIfNull(logEvent);
// Serialize the event to JSON.
var payload = BuildPayload(logEvent);
// Build the HTTP request.
using var request = new HttpRequestMessage(HttpMethod.Post, _endpoint)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
request.Headers.Add("X-Acme-Key", _apiKey);
// Send synchronously. The pipeline's AsyncLogger puts this on a
// background thread if the operator configured async logging, so
// we don't need to worry about blocking here.
using var response = _httpClient.Send(request, CancellationToken.None);
response.EnsureSuccessStatusCode();
}
public void Dispose()
{
if (_ownsHttpClient) _httpClient.Dispose();
}
// -- JSON payload --
private static string BuildPayload(LogEvent evt)
{
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
writer.WriteStartObject();
// Acme-specific envelope fields.
writer.WriteString("timestamp", evt.TimeUtc.ToString("O"));
writer.WriteString("level", evt.Level.Key);
writer.WriteString("message", evt.Message);
writer.WriteString("category", evt.Category.Value);
// Optional: write properties.
foreach (var property in evt.Properties)
{
WriteValue(writer, property.Name, property.ResolvedValue);
}
writer.WriteEndObject();
writer.Flush();
}
return Encoding.UTF8.GetString(stream.ToArray());
}
// Handles the common property value types without boxing or reflection.
private static void WriteValue(Utf8JsonWriter writer, string name, object? value)
{
switch (value)
{
case null: writer.WriteNull(name); break;
case string s: writer.WriteString(name, s); break;
case bool b: writer.WriteBoolean(name, b); break;
case int i: writer.WriteNumber(name, i); break;
case long l: writer.WriteNumber(name, l); break;
case double d: writer.WriteNumber(name, d); break;
default: writer.WriteString(name, value.ToString() ?? ""); break;
}
}
}// src/Herald.Sinks.Acme/Providers/AcmeLogSinkProvider.cs
// Copyright (c) 2026 MMPWorks LLC
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
#nullable enable
using System;
using MMP.Herald.Configuration.Runtime;
using MMP.Herald.Levels;
using MMP.Herald.Output.Rendering;
using MMP.Herald.Pipeline;
using MMP.Herald.Routing;
using MMP.Herald.Services;
namespace Herald.Sinks.Acme.Providers;
public sealed class AcmeLogSinkProvider : ILogSinkProvider
{
// Match the "kind" string your users put in JSON config.
public string SinkKind => "acme";
// Minimum edition of Herald.Core that this sink requires.
public HeraldEdition MinimumEdition => HeraldEdition.Enterprise;
public ILogger CreateSink(
LoggingRuntimeSinkDefinition definition,
ILogLevelRegistry levelRegistry,
ILogOutputTransformerRegistry transformerRegistry)
{
// definition.Uri, definition.Host, definition.Alias carry the
// config values the user set. Read what you need.
ArgumentNullException.ThrowIfNull(definition);
ArgumentException.ThrowIfNullOrWhiteSpace(definition.Uri);
ArgumentException.ThrowIfNullOrWhiteSpace(definition.Alias);
return new AcmeLogSink(
endpoint: definition.Uri,
apiKey: definition.Alias);
}
}<!-- src/Herald.Sinks.Acme/Herald.Sinks.Acme.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Herald.Sinks.Acme</RootNamespace>
<AssemblyName>Herald.Sinks.Acme</AssemblyName>
<PackageId>Herald.Sinks.Acme</PackageId>
<Version>1.0.0</Version>
<Description>Posts log events to Acme's HTTP log intake.</Description>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<!-- No <ProjectReference> to Herald.Core here. Directory.Build.props
at the repo root injects the Core reference conditionally — a
Herald.OSS PackageReference for standalone clones, a
ProjectReference for the monorepo. The NamingContractTests fail
the build if a per-sink csproj redeclares it. -->
<ItemGroup>
<!-- Ship the CAPABILITY.yaml inside the NuGet package so the
Dashboard can read it at runtime. -->
<None Include="CAPABILITY.yaml" Pack="true" PackagePath="\" />
</ItemGroup>
</Project># src/Herald.Sinks.Acme/CAPABILITY.yaml
name: Herald.Sinks.Acme
package_id: Herald.Sinks.Acme
version: 1.0.0
kind: sink
category: observability
purpose: >
Posts log events to Acme's HTTP log intake. Supports per-event level
mapping and basic auth via API key.
vendor:
name: Acme Observability
url: https://acme.example.com
ships:
- AcmeLogSink
- AcmeLogSinkProvider
requires:
core_version: ">=1.0.0"
external:
- Acme API key
- Reachable HTTP endpoint
config:
kind: acme
uri: "Acme log-intake URL"
host: null
alias: "Acme API key (X-Acme-Key header)"
notes: >
Uri is required. Alias carries the X-Acme-Key header value.
dashboard_config:
- property: endpoint
name: Ingest endpoint
help: >
The full URL of Acme's HTTP log intake. Acme documents this per
region at https://acme.example.com/docs/ingest-urls.
tooltip: Full Acme log intake URL.
width: l
control: url
required: true
placeholder: https://ingest.acme.com/v1/logs
schemes: [https]
errors:
url-malformed: "Endpoint must be a full URL."
url-scheme-not-allowed: "HTTPS required for Acme."
- property: api_key
name: API key
help: >
The X-Acme-Key header value. Stored encrypted; rotation clears
the stored value.
tooltip: Acme API key — masked after save.
width: m
control: secret
required: true
min_length: 40
errors:
required: "Acme API key is required before events can be sent."
min-length: "Acme keys are 40 characters."
capabilities:
- Level mapping to Acme's severity vocabulary
- TLS transport by default
limitations:
- No batching today — one HTTP call per event
- No compression
minimum_edition: Enterprise
aot_compatible: true
thread_safety: Thread-safe — shared HttpClient, synchronous Send.
test_coverage: tests/Herald.Sinks.Acme.Tests/AcmeLogSinkTests.cs (6 tests)
product_pack: []
maintenance:
level: active
owner: yourname
last_audit: 2026-04-24
changelog:
- version: 1.0.0
date: 2026-04-24
summary: Initial releaseEvery sink ships unit tests. You don't run against a real Acme — you use a fake HTTP handler that captures the outbound request.
// tests/Herald.Sinks.Acme.Tests/AcmeLogSinkTests.cs
// Copyright (c) 2026 MMPWorks LLC
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
#nullable enable
using System;
using System.Net.Http;
using System.Text.Json;
using FluentAssertions;
using Herald.Sinks.Acme;
using MMP.Herald.Events;
using MMP.Herald.Levels;
using MMP.Herald.Tests.Helpers;
using Xunit;
public sealed class AcmeLogSinkTests
{
private const string Endpoint = "https://ingest.acme.example.com/v1/logs";
private const string ApiKey = "acme-test-40-chars-long-xxxxxxxxxxxxxx";
[Fact]
public void Log_posts_to_the_configured_endpoint()
{
// The TestHttpMessageHandler captures every request the sink sends
// so we can assert on it without actually reaching Acme.
var handler = new TestHttpMessageHandler();
var client = new HttpClient(handler);
using var sink = new AcmeLogSink(Endpoint, ApiKey, client);
var evt = LogEventBuilder.Create()
.WithLevel(KnownLogLevels.Warn)
.WithMessage("Low disk on {host}", "Low disk on nodeA")
.Build();
sink.Log(evt);
handler.RequestCount.Should().Be(1);
handler.Requests[0].RequestUri!.ToString().Should().Be(Endpoint);
}
[Fact]
public void Log_sets_the_acme_key_header()
{
var handler = new TestHttpMessageHandler();
var client = new HttpClient(handler);
using var sink = new AcmeLogSink(Endpoint, ApiKey, client);
sink.Log(LogEventBuilder.Create().Build());
handler.Requests[0].Headers.GetValues("X-Acme-Key")
.Should().ContainSingle().Which.Should().Be(ApiKey);
}
[Fact]
public void Log_body_is_valid_json_with_core_fields()
{
var handler = new TestHttpMessageHandler();
var client = new HttpClient(handler);
using var sink = new AcmeLogSink(Endpoint, ApiKey, client);
var evt = LogEventBuilder.Create()
.WithLevel(KnownLogLevels.Error)
.WithMessage("boom", "boom")
.Build();
sink.Log(evt);
var doc = JsonDocument.Parse(handler.LastRequestBodyString!);
doc.RootElement.GetProperty("level").GetString().Should().Be("error");
doc.RootElement.GetProperty("message").GetString().Should().Be("boom");
}
[Fact]
public void Missing_endpoint_throws()
{
var act = () => new AcmeLogSink(endpoint: "", apiKey: ApiKey);
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Missing_api_key_throws()
{
var act = () => new AcmeLogSink(endpoint: Endpoint, apiKey: "");
act.Should().Throw<ArgumentException>();
}
}Minimum coverage we expect per sink:
- Happy path — one event, right endpoint, right headers, right body
- Auth header — your auth header is set correctly
- Level mapping — every Herald level maps to the right destination value (use
[Theory]with[InlineData]) - Exception path — an event carrying an exception produces the right payload
- Config guards — missing required config throws at construction
Aim for 6–12 tests per sink. More if your destination has real complexity.
When the Dashboard shows operators your sink's configuration screen, this is what happens:
sequenceDiagram
participant Operator
participant Dashboard
participant Server as Herald.Server
participant Manifest as Your CAPABILITY.yaml
Operator->>Dashboard: clicks "Add sink" → picks "Acme"
Dashboard->>Server: GET /api/sinks/catalog
Server->>Manifest: reads every installed sink's CAPABILITY.yaml
Manifest-->>Server: parsed manifest with dashboard_config
Server-->>Dashboard: JSON catalog
Dashboard->>Operator: renders form from dashboard_config entries
Operator->>Dashboard: fills fields (endpoint, API key)
Dashboard->>Server: POST /api/sinks/validate with values
Server-->>Dashboard: validation errors (if any)
Dashboard->>Operator: shows errors or confirms save
The Dashboard never knows anything specific about Acme. It just reads the YAML and renders. That's why the manifest is the whole contract — miss a field and the Dashboard renders wrong.
If your destination prefers bulk ingestion, implement IBatchedLogSink:
public sealed class AcmeLogSink : ILogger, IBatchedLogSink, IDisposable
{
public void Log(LogEvent logEvent) => LogBatch(new[] { logEvent });
public void LogBatch(IReadOnlyList<LogEvent> events)
{
if (events.Count == 0) return;
// build one HTTP call with all events in the body
}
}The pipeline's BatchingLogger automatically feeds your sink batches instead of individual events when batching is configured.
Your destination probably has its own severity scale. Map Herald's levels to it in a small method:
private static string MapStatus(LogLevel level) => level.Key.ToLowerInvariant() switch
{
"trace" => "debug",
"debug" => "debug",
"info" => "info",
"warn" => "warning",
"error" => "error",
"critical" or "fatal" => "critical",
_ => "info",
};If the event carries an exception, surface it in whatever shape the destination expects:
if (evt.Context.TryGetValue(LogContextKeys.Exception, out var value)
&& value is Exception ex)
{
writer.WriteString("error.message", ex.Message);
writer.WriteString("error.stack", ex.ToString());
}Always accept an optional HttpClient in the constructor — tests inject a fake handler through it, production shares one across sinks.
- Don't spin up threads. The pipeline's AsyncLogger already offloads delivery to a background thread when the operator configures async. Your sink stays synchronous.
- Don't throw on transient failures. The
CircuitBreakerLoggerandRetryingLoggerdecorators handle retry/backoff. YourLogmethod surfaces failures as exceptions for them to catch; it does not implement its own retry loop. - Don't block on I/O without a timeout. Your HttpClient or socket client should always have a bounded timeout. A wedged destination must not pin the pipeline's drain thread.
- Don't share mutable state across events. Every
Logcall is independent. If your sink needs per-call state, put it on the stack or in a local variable. - Don't log from your sink. Calling
logger.Infoinside your sink'sLogmethod creates a feedback loop. Write to stderr for transient warnings; let the rest of the pipeline handle "something went wrong." - Don't rely on event field order.
LogEvent.Propertiesis a list, not a map. Read by name. - Don't duplicate redaction.
CompiledRedactionProcessoralready runs before your sink sees the event. If a property was marked for redaction, you receive the masked value.
-
CAPABILITY.yamlis complete; all required fields filled. - The sink class implements
ILogger. - The provider class implements
ILogSinkProvider. -
csprojhas<PackageId>Herald.Sinks.{Name}</PackageId>(noMMP.prefix — the contract test enforces this). -
csprojdoes NOT redeclare a<ProjectReference>to Herald.Core —Directory.Build.propsowns it (the contract test enforces this). -
csprojpacksCAPABILITY.yamlinto the NuGet. - Tests cover the 6 minimum scenarios (see Part 6).
-
bash build.sh --testpasses. - If your sink has a dynamic select list, the
values_source.methodexists with the right signature. - If your sink supports connection testing, the
connectivity_probe.methodexists with the right signature. -
capabilitiesandlimitationsare honest and specific.
- This repo (
mmpworks/Herald.Sinks) — open an issue. - This repo's
CONTRIBUTING.md— the short-form reference. CAPABILITY-SCHEMA.md— the schema-level reference with every field documented.- Existing sinks in
src/— every one is a valid working template.Herald.Sinks.Seqis the simplest;Herald.Sinks.Lokishows the add/remove-label pattern;Herald.Sinks.Datadogis the fullest example of a dashboard-rich manifest.
You write two classes (sink + provider), one manifest, one test file, one csproj. You use a control vocabulary the Dashboard already understands. You declare a few capabilities and limitations. The rest — delivery, filtering, batching, retry, metrics — is Herald's job.
A sink is a mailbox. You just hand us the address.