TL;DR — This post walks through building a complete notification service in .NET 8 that delivers messages across Email, SMS, and Push channels using a message queue, Scriban template engine, per-channel rate limiting, and parallel fan-out dispatch. Full source code on GitHub at the end.
Why Build a Notification Service?
Every production application eventually needs to notify its users — a welcome email, a password reset SMS, a push alert when their order ships. The naive approach is to call SendGrid inline in your controller. That works until:
- Your SMTP provider is slow and your API response time balloons
- You hit a Twilio rate limit and lose messages silently
- You need to add a new channel and it requires touching 12 files
- A downstream outage takes your whole API down with it
A proper notification service decouples delivery from your application logic. Requests are queued instantly, workers deliver asynchronously, and channels are isolated — one failing provider never blocks another.
Architecture Overview
Producers (API / Scheduled Jobs / Webhooks)
│
▼
┌─────────────────┐
│ Message Queue │ RabbitMQ (topic exchange)
└────────┬────────┘
│
┌─────────────▼──────────────────┐
│ Dispatcher │
│ ┌──────────────────────────┐ │
│ │ Template Service │ │ Scriban engine + cache
│ └──────────────────────────┘ │
│ ┌──────────────────────────┐ │
│ │ Rate Limiter │ │ Sliding window per recipient
│ └──────────────────────────┘ │
└──────┬──────────┬──────────┬───┘
│ │ │
┌──────▼──┐ ┌────▼───┐ ┌──▼──────┐
│ Email │ │ SMS │ │ Push │ Channel Workers
│ Worker │ │ Worker │ │ Worker │ (IHostedService)
└──────┬──┘ └────┬───┘ └──┬──────┘
│ │ │
┌──────▼──┐ ┌────▼───┐ ┌──▼──────┐
│ SMTP │ │Twilio │ │Firebase │ External Providers
│ MailKit │ │ API │ │ FCM │
└─────────┘ └────────┘ └─────────┘
│
┌──────────▼──────────┐
│ Observability │ OpenTelemetry + Serilog + Prometheus
└─────────────────────┘
Data flow in plain English
- Your API receives
POST /api/notificationsand immediately publishes to RabbitMQ — the HTTP response is instant -
RabbitMqConsumer(aBackgroundService) picks up the message - The Dispatcher resolves the recipient, checks the rate limiter, renders the template, and fans out to all requested channels in parallel
- Each channel worker calls its external provider (MailKit, Twilio, FCM)
- Failures nack the message — RabbitMQ retries up to 3 times, then routes to a dead-letter queue
Project Structure
NotificationService/
├── src/
│ ├── NotificationService.Api/ # ASP.NET Core Web API
│ ├── NotificationService.Core/ # Domain models & interfaces
│ ├── NotificationService.Templates/ # Scriban template engine
│ ├── NotificationService.Dispatcher/ # Rate limiter + orchestration
│ ├── NotificationService.Channels/ # Email / SMS / Push workers
│ └── NotificationService.Infrastructure/ # RabbitMQ, persistence, DI
├── tests/
│ ├── NotificationService.UnitTests/
│ └── NotificationService.IntegrationTests/
├── docker-compose.yml
└── NotificationService.sln
The key design decision: every layer depends only on Core interfaces. Swapping RabbitMQ for Azure Service Bus, or Twilio for Vonage, touches exactly one file.
Part 1 — Core Domain Models
Everything starts with the models. Keeping them in a separate project enforces that no business logic leaks into infrastructure.
// Core/Models/NotificationModels.cs
public record NotificationRequest
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string RecipientId { get; init; }
public required string[] Channels { get; init; } // "email" | "sms" | "push"
public required string TemplateName { get; init; }
public Dictionary<string, object> Data { get; init; } = new();
public NotificationPriority Priority { get; init; } = NotificationPriority.Normal;
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
public record RenderedNotification
{
public required string Recipient { get; init; }
public required string Subject { get; init; }
public required string Body { get; init; }
public string? HtmlBody { get; init; }
public Dictionary<string, string> Metadata { get; init; } = new();
}
public record DeliveryResult
{
public bool IsSuccess { get; init; }
public DeliveryStatus Status { get; init; }
public string? MessageId { get; init; }
public string? ErrorMessage { get; init; }
public static DeliveryResult Success(string? messageId = null) => new()
{ IsSuccess = true, Status = DeliveryStatus.Delivered, MessageId = messageId };
public static DeliveryResult Failure(string error) => new()
{ IsSuccess = false, Status = DeliveryStatus.Failed, ErrorMessage = error };
}
The core interfaces define the contracts every implementation must honour:
// Core/Interfaces/INotificationInterfaces.cs
public interface INotificationChannel
{
string ChannelName { get; }
Task<DeliveryResult> SendAsync(RenderedNotification notification, CancellationToken ct = default);
}
public interface ITemplateService
{
Task<RenderedNotification> RenderAsync(
string templateName,
Dictionary<string, object> data,
string channel,
string recipient);
}
public interface IDispatcher
{
Task DispatchAsync(NotificationRequest request, CancellationToken ct = default);
}
public interface IRateLimiter
{
bool TryAcquire(string recipientId, string channel);
Task<bool> TryAcquireAsync(string recipientId, string channel, CancellationToken ct = default);
}
Part 2 — Message Queue with RabbitMQ
Publisher
The publisher converts a NotificationRequest to bytes and publishes to a topic exchange. Priority is encoded as the AMQP message priority so high-priority messages jump the queue.
// Infrastructure/Queue/RabbitMqPublisher.cs
public class RabbitMqPublisher : INotificationPublisher, IDisposable
{
public Task PublishAsync(NotificationRequest request, CancellationToken ct = default)
{
EnsureConnected();
var body = JsonSerializer.SerializeToUtf8Bytes(request);
var props = _channel!.CreateBasicProperties();
props.Persistent = true;
props.Priority = (byte)request.Priority;
props.MessageId = request.Id.ToString();
var routingKey = $"notification.{request.Priority.ToString().ToLower()}";
_channel.BasicPublish(
exchange: _opts.ExchangeName,
routingKey: routingKey,
basicProperties: props,
body: body);
return Task.CompletedTask;
}
}
Consumer (BackgroundService)
The consumer runs as a hosted service. Failed messages are nacked — RabbitMQ routes them to the dead-letter queue after max retries.
// Infrastructure/Queue/RabbitMqConsumer.cs
public class RabbitMqConsumer : BackgroundService
{
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
Connect();
var consumer = new AsyncEventingBasicConsumer(_channel!);
consumer.Received += async (_, ea) =>
{
try
{
var request = JsonSerializer.Deserialize<NotificationRequest>(ea.Body.Span)!;
using var scope = _scopeFactory.CreateScope();
var dispatcher = scope.ServiceProvider.GetRequiredService<IDispatcher>();
await dispatcher.DispatchAsync(request, stoppingToken);
_channel!.BasicAck(ea.DeliveryTag, multiple: false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing notification");
_channel!.BasicNack(ea.DeliveryTag, multiple: false, requeue: false);
}
};
_channel!.BasicConsume(_opts.QueueName, autoAck: false, consumer);
return Task.CompletedTask;
}
}
Queue topology
Exchange: notifications (topic)
│
├── notification.critical ──► Queue: notifications.main
├── notification.high ──► Queue: notifications.main
├── notification.normal ──► Queue: notifications.main
└── notification.low ──► Queue: notifications.main
│
(nack, no requeue)
│
▼
Queue: notifications.dlq
Part 3 — Template Service (Scriban)
Scriban is a fast, sandboxed templating engine for .NET. Templates are stored per (name, channel, part) — so welcome:email:body and welcome:sms:body can have completely different content. Compiled templates are cached in a ConcurrentDictionary — parsing happens once per unique key, never again.
// Templates/Services/ScribanTemplateService.cs
public class ScribanTemplateService(
ITemplateRepository repository,
ILogger<ScribanTemplateService> logger) : ITemplateService
{
private static readonly ConcurrentDictionary<string, Template> _cache = new();
public async Task<RenderedNotification> RenderAsync(
string templateName, Dictionary<string, object> data,
string channel, string recipient)
{
var subject = await RenderPartAsync(templateName, channel, "subject", data);
var body = await RenderPartAsync(templateName, channel, "body", data);
return new RenderedNotification
{
Recipient = recipient,
Subject = subject,
Body = body
};
}
private async Task<string> RenderPartAsync(
string name, string channel, string part,
Dictionary<string, object> data)
{
var key = $"{name}:{channel}:{part}";
var compiled = _cache.GetOrAdd(key, _ =>
{
var source = repository.GetTemplateAsync(name, channel, part)
.GetAwaiter().GetResult()
?? throw new InvalidOperationException($"Template not found: {key}");
return Template.Parse(source);
});
var ctx = new TemplateContext { StrictVariables = false };
var model = new ScriptObject();
foreach (var (k, v) in data) model.Add(k, v);
ctx.PushGlobal(model);
return await compiled.RenderAsync(ctx);
}
}
Seeded templates (ready out of the box)
| Template | Channel | What it sends |
|---|---|---|
welcome |
Full welcome with verification link | |
welcome |
sms | Short text with verification link |
welcome |
push | Tap-to-verify push notification |
password-reset |
email / sms / push | Reset link + expiry notice |
order-confirmation |
all | Order ID, total, delivery date, tracking link |
Part 4 — Rate Limiter (Sliding Window)
The rate limiter sits inside the Dispatcher — not at the API layer — so limits apply regardless of how notifications enter the system.
// Dispatcher/RateLimiting/SlidingWindowRateLimiter.cs
public class SlidingWindowRateLimiter(
IMemoryCache cache,
IOptions<RateLimitOptions> options) : IRateLimiter
{
public bool TryAcquire(string recipientId, string channel)
{
var key = $"rl:{channel}:{recipientId}";
var window = cache.GetOrCreate(key, entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_opts.WindowSeconds);
return new WindowCounter(_opts.MaxPerWindow);
})!;
return window.TryConsume();
}
private sealed class WindowCounter(int max)
{
private int _count;
public bool TryConsume() => Interlocked.Increment(ref _count) <= max;
}
}
Each (recipientId, channel) pair has its own independent window. Configure via appsettings.json:
"RateLimit": {
"MaxPerWindow": 10,
"WindowSeconds": 3600
}
For distributed deployments: replace
IMemoryCachewith a Redis-backed implementation usingINCR+EXPIREper key.
Part 5 — Dispatcher (Orchestration)
The Dispatcher wires together template rendering, rate limiting, and channel delivery — all in parallel.
// Dispatcher/Services/NotificationDispatcher.cs
public class NotificationDispatcher(
ITemplateService templateService,
IEnumerable<INotificationChannel> channels,
IRateLimiter rateLimiter,
IRecipientResolver recipientResolver,
ILogger<NotificationDispatcher> logger) : IDispatcher
{
public async Task DispatchAsync(NotificationRequest request, CancellationToken ct = default)
{
var recipient = await recipientResolver.ResolveAsync(request.RecipientId, ct);
if (recipient is null)
{
logger.LogWarning("Recipient {Id} not found", request.RecipientId);
return;
}
var targets = channels
.Where(c => request.Channels.Contains(c.ChannelName, StringComparer.OrdinalIgnoreCase))
.ToList();
// Fan-out to all channels IN PARALLEL
var tasks = targets.Select(ch => SendToChannelAsync(ch, request, recipient, ct));
await Task.WhenAll(tasks);
}
private async Task SendToChannelAsync(
INotificationChannel channel, NotificationRequest request,
RecipientInfo recipient, CancellationToken ct)
{
if (!await rateLimiter.TryAcquireAsync(request.RecipientId, channel.ChannelName, ct))
{
logger.LogWarning("Rate limited: {Recipient} on {Channel}",
request.RecipientId, channel.ChannelName);
return;
}
var address = channel.ChannelName.ToLower() switch
{
"email" => recipient.Email,
"sms" => recipient.PhoneNumber,
"push" => recipient.DevicePushToken,
_ => null
};
if (address is null) return;
var rendered = await templateService.RenderAsync(
request.TemplateName, request.Data, channel.ChannelName, address);
var result = await channel.SendAsync(rendered, ct);
if (result.IsSuccess)
logger.LogInformation("✓ {Channel} → {Recipient}", channel.ChannelName, request.RecipientId);
else
logger.LogError("✗ {Channel} failed → {Error}", channel.ChannelName, result.ErrorMessage);
}
}
Task.WhenAll means an ["email", "sms", "push"] request sends all three concurrently. One channel throwing never blocks the others.
Part 6 — Channel Workers
Email — MailKit
public class EmailChannel(IOptions<SmtpOptions> opts, ILogger<EmailChannel> logger) : INotificationChannel
{
public string ChannelName => "email";
public async Task<DeliveryResult> SendAsync(RenderedNotification n, CancellationToken ct = default)
{
try
{
var message = new MimeMessage();
message.From.Add(new MailboxAddress(opts.Value.FromName, opts.Value.FromAddress));
message.To.Add(MailboxAddress.Parse(n.Recipient));
message.Subject = n.Subject;
var builder = new BodyBuilder();
if (n.HtmlBody is not null) { builder.HtmlBody = n.HtmlBody; builder.TextBody = n.Body; }
else builder.TextBody = n.Body;
message.Body = builder.ToMessageBody();
using var client = new SmtpClient();
await client.ConnectAsync(opts.Value.Host, opts.Value.Port,
SecureSocketOptions.StartTlsWhenAvailable, ct);
await client.AuthenticateAsync(opts.Value.User, opts.Value.Password, ct);
await client.SendAsync(message, ct);
await client.DisconnectAsync(quit: true, ct);
return DeliveryResult.Success(message.MessageId);
}
catch (Exception ex)
{
logger.LogError(ex, "Email failed to {Recipient}", n.Recipient);
return DeliveryResult.Failure(ex.Message);
}
}
}
SMS — Twilio
public class SmsChannel(IOptions<TwilioOptions> opts, ILogger<SmsChannel> logger) : INotificationChannel
{
public string ChannelName => "sms";
public async Task<DeliveryResult> SendAsync(RenderedNotification n, CancellationToken ct = default)
{
EnsureInitialized();
try
{
var message = await MessageResource.CreateAsync(
to: new PhoneNumber(n.Recipient),
from: new PhoneNumber(opts.Value.FromNumber),
body: n.Body);
return message.ErrorCode is null
? DeliveryResult.Success(message.Sid)
: DeliveryResult.Failure($"[{message.ErrorCode}] {message.ErrorMessage}");
}
catch (Exception ex)
{
logger.LogError(ex, "SMS failed to {Recipient}", n.Recipient);
return DeliveryResult.Failure(ex.Message);
}
}
}
Push — Firebase FCM
public class PushChannel(IOptions<FirebaseOptions> opts, ILogger<PushChannel> logger) : INotificationChannel
{
public string ChannelName => "push";
public async Task<DeliveryResult> SendAsync(RenderedNotification n, CancellationToken ct = default)
{
await EnsureInitializedAsync();
try
{
var message = new Message
{
Token = n.Recipient,
Notification = new FirebaseAdmin.Messaging.Notification
{ Title = n.Subject, Body = n.Body },
Android = new AndroidConfig { Priority = Priority.High },
Apns = new ApnsConfig { Aps = new Aps { Sound = "default" } },
Data = n.Metadata
};
var messageId = await FirebaseMessaging.GetMessaging(_app!).SendAsync(message, ct);
return DeliveryResult.Success(messageId);
}
catch (FirebaseMessagingException ex)
when (ex.MessagingErrorCode == MessagingErrorCode.Unregistered)
{
return DeliveryResult.Failure("FCM token is no longer valid");
}
catch (Exception ex)
{
logger.LogError(ex, "Push failed to {Recipient}", n.Recipient);
return DeliveryResult.Failure(ex.Message);
}
}
}
Part 7 — API Layer
The controller is intentionally thin — validate, publish, return 202 Accepted. No business logic.
[ApiController]
[Route("api/[controller]")]
public class NotificationsController(
INotificationPublisher publisher,
ILogger<NotificationsController> logger) : ControllerBase
{
[HttpPost]
[ProducesResponseType(typeof(NotificationResponse), StatusCodes.Status202Accepted)]
public async Task<IActionResult> Send(
[FromBody] SendNotificationRequest request, CancellationToken ct)
{
if (request.Channels.Length == 0)
return BadRequest(new { error = "At least one channel is required" });
var notification = new NotificationRequest
{
RecipientId = request.RecipientId,
Channels = request.Channels,
TemplateName = request.TemplateName,
Data = request.Data,
Priority = request.Priority
};
await publisher.PublishAsync(notification, ct);
return Accepted(new NotificationResponse(notification.Id, "Queued", DateTimeOffset.UtcNow));
}
}
Example request
POST /api/notifications
Content-Type: application/json
{
"recipientId": "user-123",
"channels": ["email", "sms", "push"],
"templateName": "welcome",
"data": {
"firstName": "Alice",
"verificationLink": "https://app.example.com/verify?token=abc123"
},
"priority": "High"
}
Response
{
"notificationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "Queued",
"queuedAt": "2024-01-15T10:30:00Z"
}
Part 8 — DI Wiring (Program.cs)
builder.Services.AddSingleton<ITemplateRepository, InMemoryTemplateRepository>();
builder.Services.AddScoped<ITemplateService, ScribanTemplateService>();
builder.Services.AddMemoryCache();
builder.Services.Configure<RateLimitOptions>(builder.Configuration.GetSection("RateLimit"));
builder.Services.AddSingleton<IRateLimiter, SlidingWindowRateLimiter>();
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
builder.Services.Configure<TwilioOptions>(builder.Configuration.GetSection("Twilio"));
builder.Services.Configure<FirebaseOptions>(builder.Configuration.GetSection("Firebase"));
builder.Services.AddScoped<INotificationChannel, EmailChannel>();
builder.Services.AddScoped<INotificationChannel, SmsChannel>();
builder.Services.AddScoped<INotificationChannel, PushChannel>();
builder.Services.AddScoped<IDispatcher, NotificationDispatcher>();
builder.Services.AddRabbitMq(builder.Configuration);
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddAspNetCoreInstrumentation().AddConsoleExporter());
builder.Services.AddHealthChecks()
.AddRabbitMQ(rabbitConnectionString: "amqp://guest:guest@localhost/");
app.MapHealthChecks("/health");
Part 9 — Unit Tests
[Fact]
public void TryAcquire_AtLimit_ReturnsFalse()
{
var limiter = CreateLimiter(max: 2);
limiter.TryAcquire("user-1", "sms").Should().BeTrue();
limiter.TryAcquire("user-1", "sms").Should().BeTrue();
limiter.TryAcquire("user-1", "sms").Should().BeFalse(); // blocked
}
[Fact]
public void TryAcquire_DifferentChannels_TrackSeparately()
{
var limiter = CreateLimiter(max: 1);
limiter.TryAcquire("user-2", "email").Should().BeTrue();
limiter.TryAcquire("user-2", "email").Should().BeFalse();
limiter.TryAcquire("user-2", "sms").Should().BeTrue(); // sms is independent
}
[Fact]
public async Task DispatchAsync_WhenRateLimited_SkipsChannel()
{
_rateLimiter.Setup(r => r.TryAcquireAsync("u1", "email", default))
.ReturnsAsync(false);
await dispatcher.DispatchAsync(request);
_emailChannel.Verify(
c => c.SendAsync(It.IsAny<RenderedNotification>(), default), Times.Never);
}
[Fact]
public async Task RenderAsync_InterpolatesVariables()
{
var service = CreateService("Hello, {{ name }}!");
var result = await service.RenderAsync("tpl", new() { ["name"] = "Alice" }, "email", "a@b.com");
result.Body.Should().Be("Hello, Alice!");
}
Run the full suite:
dotnet test
Part 10 — Docker Compose
services:
api:
build:
context: .
dockerfile: src/NotificationService.Api/Dockerfile
ports:
- "5000:8080"
environment:
- RabbitMq__Host=rabbitmq
depends_on:
rabbitmq:
condition: service_healthy
rabbitmq:
image: rabbitmq:3.13-management-alpine
ports:
- "5672:5672"
- "15672:15672"
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "ping"]
interval: 10s
retries: 5
redis:
image: redis:7.4-alpine
ports:
- "6379:6379"
Start everything:
docker-compose up -d
API → http://localhost:5000 · Swagger → http://localhost:5000/swagger · RabbitMQ UI → http://localhost:15672
Part 11 — GitHub Actions CI
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
build-and-test:
runs-on: ubuntu-latest
services:
rabbitmq:
image: rabbitmq:3.13-alpine
ports: [ 5672:5672 ]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- run: dotnet restore
- run: dotnet build --no-restore -c Release
- run: dotnet test tests/NotificationService.UnitTests -c Release --logger trx
docker:
runs-on: ubuntu-latest
needs: build-and-test
steps:
- uses: actions/checkout@v4
- run: docker build -f src/NotificationService.Api/Dockerfile -t notification-service:${{ github.sha }} .
Extending the Service
Add a new channel (e.g. Slack) — 3 steps
Step 1 — Implement INotificationChannel:
public class SlackChannel(IOptions<SlackOptions> opts) : INotificationChannel
{
public string ChannelName => "slack";
public async Task<DeliveryResult> SendAsync(RenderedNotification n, CancellationToken ct)
{
var payload = new { text = $"*{n.Subject}*\n{n.Body}" };
var response = await _httpClient.PostAsJsonAsync(opts.Value.WebhookUrl, payload, ct);
return response.IsSuccessStatusCode
? DeliveryResult.Success()
: DeliveryResult.Failure(response.ReasonPhrase!);
}
}
Step 2 — Register it:
builder.Services.AddScoped<INotificationChannel, SlackChannel>();
Step 3 — Add templates for slack:subject and slack:body. The Dispatcher discovers it automatically. Zero changes to existing code.
Distributed rate limiting (Redis)
public async Task<bool> TryAcquireAsync(string recipientId, string channel, CancellationToken ct)
{
var key = $"rl:{channel}:{recipientId}";
var count = await _db.StringIncrementAsync(key);
if (count == 1)
await _db.KeyExpireAsync(key, TimeSpan.FromSeconds(_opts.WindowSeconds));
return count <= _opts.MaxPerWindow;
}
Key Design Decisions
Message queue over direct delivery — The API never waits on SMTP, Twilio, or FCM. Response time is constant regardless of provider latency. Dead-letter queuing means no silent message loss during outages.
Scriban over Razor — Scriban compiles to a sandboxed AST. It cannot execute arbitrary C# code — important when templates might be user-editable. It also benchmarks significantly faster for pure string interpolation at scale.
Rate limiting in the Dispatcher, not the API — Notifications can enter from multiple sources. Placing rate limiting in the Dispatcher guarantees it applies everywhere, not just the HTTP path.
Task.WhenAll fan-out — One slow provider doesn't delay others. Email taking 800ms doesn't block push from delivering in 50ms. Failures are isolated — one channel exception never affects the others.
IEnumerable<INotificationChannel> in the Dispatcher — ASP.NET Core's DI resolves all registered implementations automatically. Adding a channel is one AddScoped call with zero changes to the Dispatcher.
What's Next
- Delivery receipts — webhook callbacks from Twilio/FCM to track delivery status in your DB
- User preferences — let recipients opt out of channels or specific notification types
- Scheduled notifications — Hangfire or Quartz.NET for delayed delivery
- Template management API — CRUD endpoints to edit templates without redeploying
- Redis rate limiter — for multi-instance deployments
- End-to-end OpenTelemetry traces — trace a notification from API call through queue through dispatcher through provider
Source Code
The complete, production-ready source code is on GitHub — includes all the code shown in this post, Docker Compose setup, GitHub Actions CI pipeline, full test suite (xUnit + Moq + FluentAssertions), and architecture documentation.
🔗 github.com/naimulkarim/NotificationService
If this was helpful, drop a ⭐ on the repo and feel free to open issues or PRs for improvements.
Built with .NET 8 · RabbitMQ · MailKit · Twilio · Firebase FCM · Scriban · OpenTelemetry
Top comments (0)