Logit.io
Send .NET Serilog logs to Hosted OpenTelemetry alongside Logstash
← Back to blog
11/3/2026 · 20 min read

Send .NET Serilog logs to Hosted OpenTelemetry alongside Logstash

Logit.io Team
Logit.io Team
Technical Content Team

Last updated 11/3/2026

Log ManagementHow To GuidesLogstash

Keep your existing Serilog-to-Logstash pipeline while adding an OpenTelemetry copy of the same .NET logs. This tested walkthrough covers OTLP over HTTPS, Basic authentication, service naming and graceful flushing, then shows how to compare both copies in OpenSearch Dashboards and correlate logs with traces before cutting over.

Contents

Start Free Trial

Unlock complete visibility with hosted ELK, Grafana, and Prometheus-backed Observability

Start Free Trial

The short answer

If your .NET application already sends Serilog logs to a Logit.io Logs stack through Logstash, keep that sink running while you add Hosted OpenTelemetry. Configure Serilog.Sinks.OpenTelemetry alongside TCPSink, label each copy and compare them in OpenSearch Dashboards. Move the searches and alerts your team relies on before removing the old sink. This gives you a staged cutover with a clear route back if the new copy is missing operational context.

Why run both sinks?

Your Logstash route probably does more than move bytes. A filter might normalise customer identifiers, rename fields, remove unwanted payloads or classify exceptions. Dashboards and alerts then depend on those decisions. Replacing the transport and all that behaviour in one deployment makes an ordinary logging change surprisingly hard to assess.

Keep the existing route while adding an OTLP copy. The application still emits one structured log event. Serilog sends it to each configured sink, with each sink handling its own representation and delivery. You can inspect the same request through both routes, confirm that the fields your team searches are present, and move dashboards over in manageable steps.

The OTLP copy does not inherit your existing Logstash filters merely because both destinations belong to the same stack. If a filter currently strips personal data, move that protection into shared application logging before enabling the second sink, or arrange equivalent processing for the new route. Check enrichment too. A familiar field supplied only by Logstash will otherwise disappear from the OTLP copy.

Treat this as a migration window. Keep a date for reviewing the comparison and deciding which route to retire. There may be a good reason to retain Logstash for selected events, but indefinite duplication should be a deliberate choice with an owner and a volume budget. Our existing Serilog to Logstash integration post covers the original route; this article starts with that connection already in use and adds the overlap, comparison and cutover steps.

A .NET application sends Serilog logs through TCP over TLS to Logstash and through OTLP over HTTPS to Hosted OpenTelemetry, with both routes reaching the same Logs stack and OpenSearch Dashboards

What you see in OpenSearch Dashboards

Start with the logs. OpenSearch Dashboards gives you a place to search recent messages, filter a service and follow a failure across requests. The OpenTelemetry route carries a resource describing the application as well as individual log records. That makes a stable service name useful long before you enable tracing.

InformationWhat to checkWhy it helps
service.nameThe OTLP resource says orders-apiSeparate this application from other services in the stack.
SeverityInformation, Warning and Error survive as meaningful levelsFind failures without depending on words embedded in the message.
Structured propertiesYour order ID or deployment identifier is searchableFilter an incident without parsing rendered sentences.
trace_id and span_idNative OTLP identifiers are present when an activity is activeMatch a log to a stored trace when tracing and the APM add-on are enabled.
delivery_routeThis example adds logstash or otelCompare the two copies without guessing which transport delivered them.

Inspect an expanded document before saving queries. Native OTLP names, resource attributes and the final indexed field paths are different layers. An ingest mapping may expose a resource under a prefix, and the TCP formatter can change property casing. For example, a TCP document can expose eventId while the OTLP attribute retains EventId. Field names are case-sensitive, so carry the actual indexed spelling into each search; identical messages do not guarantee identical documents.

Prerequisites and connection details

Hosted OpenTelemetry is on by default for new Logs stacks and is available on every plan. For an existing stack, open the stack's Endpoints page and select Switch on to enable it. Copy the Hosted OpenTelemetry endpoint, username and API key from that page for the sink configuration below. These are the Hosted OpenTelemetry credentials, separate from your OpenSearch credentials.

The Hosted OpenTelemetry guide specifies OTLP over HTTPS on port 443. Public gRPC on 4317 and plain HTTP on 4318 are not exposed. Allow outbound HTTPS from the application, plus access to the existing Logstash TLS input on the host and port shown for your stack. Copy the Logstash values independently; its port is not the OTLP port.

  • Install the .NET 8 SDK and choose a stable application name, such as orders-api.
  • Keep the existing Logstash input and filters available during the comparison.
  • Provide credentials through development user-secrets or deployment environment variables.
  • Add the APM add-on if you want exported traces stored and available for investigation. Logs do not require adding tracing code.

Install the packages

The example targets .NET 8 and pins the sink packages so their configuration behaviour is explicit. Add the OpenTelemetry sink to your existing application; the project commands below also provide a small standalone example of the two routes.

We tested against local OTLP and Logstash stand-ins that enforce the same auth and paths.

dotnet new web --framework net8.0 --name OrdersLogging
cd OrdersLogging

dotnet add package Serilog.AspNetCore --version 8.0.3
dotnet add package Serilog.Sinks.Network --version 3.0.0
dotnet add package Serilog.Sinks.OpenTelemetry --version 4.2.0

dotnet user-secrets init

Retain your application's existing dependency policy when adapting the example. The endpoint guidance below describes Serilog.Sinks.OpenTelemetry 4.2.0; other exporters can have different path handling.

Supply the sink settings

For general configuration and credential handling, use our 8 October post on OpenTelemetry .NET appsettings.json and Basic header setup. This example needs only the following settings for its shared Serilog helper; merge them into appsettings.json using your own stack endpoint and existing Logstash URI.

{
  "Logit": {
    "ServiceName": "orders-api",
    "LogstashUri": "tls://{logstash-host}:{tls-port}",
    "Otel": {
      "Endpoint": "https://{stackId}-otel.logit.io",
      "Username": "",
      "ApiKey": ""
    }
  }
}

Supply Logit:Otel:Username and Logit:Otel:ApiKey through development user-secrets, or inject Logit__Otel__Username and Logit__Otel__ApiKey from your deployment's secret store. Leave credentials out of the committed JSON. The helper skips an unconfigured route, so require the OTLP settings in your deployment checks before starting the overlap.

Configure both loggers in Program.cs

Replace Program.cs with the following. The early bootstrap logger and the host logger both call Shipping.Configure, so startup messages and application messages get the same pair of sinks. Each route receives its own delivery_route property. It is a temporary comparison aid, not an instruction to the receiving service.

using System.Text;
using Serilog;
using Serilog.Events;
using Serilog.Sinks.OpenTelemetry;
using Serilog.Sinks.Network;

var earlyConfig = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json", optional: true)
    .AddUserSecrets<Program>(optional: true)
    .AddEnvironmentVariables()
    .AddCommandLine(args)
    .Build();

using var bootstrapLogger = Shipping.Configure(new LoggerConfiguration(), earlyConfig)
    .CreateLogger();
Log.Logger = bootstrapLogger;

try
{
    Log.Information("Starting orders-api");
    var builder = WebApplication.CreateBuilder(args);
    builder.Host.UseSerilog((context, services, logger) =>
        Shipping.Configure(logger, context.Configuration));

    var app = builder.Build();
    app.UseSerilogRequestLogging();
    app.MapGet("/migration-log/{eventId}", (string eventId) =>
    {
        Log.Information("Dual delivery check {EventId}", eventId);
        return Results.Ok(new { eventId });
    });
    app.Run();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Application stopped unexpectedly");
    throw;
}
finally
{
    Log.CloseAndFlush();
}

static class Shipping
{
    public static LoggerConfiguration Configure(
        LoggerConfiguration logger, IConfiguration config)
    {
        var service = config["Logit:ServiceName"] ?? "orders-api";
        logger.MinimumLevel.Information()
            .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
            .Enrich.FromLogContext()
            .Enrich.WithProperty("service.name", service)
            .WriteTo.Console();

        var tcpUri = config["Logit:LogstashUri"];
        if (Ready(tcpUri))
            logger.WriteTo.Logger(copy => copy
                .Enrich.WithProperty("delivery_route", "logstash")
                .WriteTo.TCPSink(tcpUri!));

        var endpoint = config["Logit:Otel:Endpoint"];
        var username = config["Logit:Otel:Username"];
        var apiKey = config["Logit:Otel:ApiKey"];
        if (Ready(endpoint) && Ready(username) && Ready(apiKey))
        {
            var token = Convert.ToBase64String(
                Encoding.UTF8.GetBytes($"{username}:{apiKey}"));
            logger.WriteTo.Logger(copy => copy
                .Enrich.WithProperty("delivery_route", "otel")
                .WriteTo.OpenTelemetry(options =>
                {
                    options.Endpoint = endpoint!;
                    options.Protocol = OtlpProtocol.HttpProtobuf;
                    options.Headers["Authorization"] = "Basic " + token;
                    options.ResourceAttributes["service.name"] = service;
                }, ignoreEnvironment: true));
        }
        return logger;
    }

    static bool Ready(string? value) =>
        !string.IsNullOrWhiteSpace(value)
        && !value.Contains('{') && !value.Contains('<')
        && !value.Contains("placeholder", StringComparison.OrdinalIgnoreCase)
        && !value.Contains("your-", StringComparison.OrdinalIgnoreCase);
}

This uses an explicit two-stage logger setup: the early logger is an ordinary Serilog logger held in a using variable; UseSerilog installs the host logger afterwards. The host logger is flushed in finally, and the early logger is disposed when its enclosing scope ends. Preserve both lifetimes when adapting the example so buffered startup messages receive a graceful shutdown too.

The shared helper is the part to preserve when adapting this to an existing application. Adding OTLP only to the early logger would leave normal application logs on the old route. Adding it only to the host logger would leave the earliest startup messages out of the comparison. Keep both calls, and add your existing enrichers to that shared helper.

Notice ignoreEnvironment: true. Supported OTEL_* environment variables can otherwise override the sink's programmatic endpoint, protocol, headers or resource settings. This switch belongs to the Serilog sink. It does not turn off .NET's Logit__... configuration variables or control a separate tracing SDK. The OpenTelemetry sink documentation describes its configuration options.

The Serilog sink's Headers dictionary requires a real space after Basic, whereas the SDK configuration recipe in the linked October post uses %20.

Verify both copies in your Logs stack

Build the application, start it on a known local port, then send a request with a recognisable event identifier. Use another terminal for the request while the application is running.

dotnet build
dotnet run --no-launch-profile --urls http://localhost:5080
curl --fail http://localhost:5080/migration-log/migration-check-001

Wait for the batch to send, then open your stack and select Launch Logs. In OpenSearch Dashboards, open Discover, select the data view containing the destination logs and set the time picker to the last 15 minutes. The Logstash route commonly uses logstash-*. Confirm that your chosen view also covers the OTLP destination before comparing copies.

Begin with a message search for migration-check-001. Expand both documents and inspect the actual field names. If your mapping exposes the resource directly as service.name, a Lucene query such as service.name:"orders-api" scopes the search to this service. If the resource appears under resource.attributes.service.name, use resource.attributes.service.name:"orders-api" instead. Select the field from Discover's field list to avoid guessing a prefix.

Next query each route, adapting the field paths to the expanded documents:

  • delivery_route:"logstash" AND message:*migration-check-001* finds the TCP copy when those names are preserved.
  • delivery_route:"otel" isolates the new route. Add the event identifier using its indexed property or rendered message field.
  • service.name:"orders-api" AND delivery_route:"otel" combines service identity and route when both fields are exposed at those paths.

A successful application HTTP response confirms that the request handler ran. Confirm remote delivery separately by finding both route-labelled documents in Discover. If only one appears, widen the time window, confirm the destination data view and inspect the missing route's settings before generating more traffic. Keep the existing Logstash route active while resolving the difference.

Compare counts with a fixed window

For a useful comparison, send a known set of identifiers, such as 100 distinct migration-check-... values. Wait for delivery and compare the two route filters over the same absolute start and end times. Count the application check events separately from request-completion logs. This sample emits both, so counting every document would answer a different question.

Look for missing identifiers and repeated identifiers, then compare severity, exception details and the properties used by existing alerts. A difference can come from a Logstash filter, a level threshold, a field mapping or a delivery problem. Equal totals alone do not establish equivalence: one missing event and one repeated event can cancel out. Keep a few representative failures in the comparison, not just Information messages from a quiet endpoint.

Compare fields before moving queries

Keep two Discover tabs open over the same absolute time window, one for each route. Use an event identifier to pair documents, then record the indexed paths that your team actually uses. A short mapping of old field, new field and intended meaning makes later query changes reviewable. Include a normal request, a warning and an exception so the comparison covers more than message text.

Existing dependencyWhat to compare in the OTLP copyMigration decision
Service and environment filtersResource attributes versus ordinary event properties and Logstash enrichmentKeep the same service identity and supply any environment labels previously added by the pipeline.
Error alertsSeverity text or number, exception type, message and stack traceTranslate the predicate to the new fields and confirm it selects the same failures.
Request and order searchesIdentifier casing, nesting and exact-match field availabilityPreserve identifier values and update field paths in saved searches.
Latency and volume panelsNumeric types, duration units and event timestampsKeep aggregation units and time boundaries consistent.

Inspect values as well as names. A number indexed as text cannot support the same numeric aggregation, and a duration in milliseconds needs a different interpretation from seconds. A Logstash rename may also conceal that one field is an application event time while another is an ingest time. Choose comparable timestamps before investigating a count mismatch at a window boundary.

Check message templates separately from rendered messages. A saved search that matches a sentence can stop working when the text moves to a different indexed field, even though the order identifier remains available as a structured attribute. Prefer the identifier and severity fields for operational queries where possible. Confirm that any keyword field used for grouping exists on the new documents before replacing a dashboard panel.

Refresh the data view's field list if newly indexed fields do not appear in Discover. If the two routes give the same field incompatible types, narrow the data view to inspect each destination and resolve the mapping or property naming before relying on a combined aggregation. Changing a saved search cannot repair an incompatible indexed type.

Keep trace correlation consistent

The Serilog sink sends logs; it does not export traces. When an activity is active, inspect the OTLP log's native trace and span identifiers and compare them with the identifiers your existing Logstash copy exposes. Keep the tracing SDK's service identity aligned with the logging resource. Use the APM add-on if you want to store and investigate the matching traces.

For an application that already exports traces, follow an existing request from the OTLP log into its stored trace before moving investigation links. A rendered TraceId property and a native trace_id field may occupy different indexed paths. Update saved searches and dashboard links to the path actually present in your stack. Keep Serilog as the log exporter during the overlap; adding another OpenTelemetry logging provider creates an additional copy and complicates the comparison.

Troubleshoot a missing copy

A working console logger can conceal a broken network route. Identify which copy is missing before changing shared logging settings, since a shared level threshold or enricher affects both destinations.

SymptomLikely causeAction
OTLP authentication failsThe Hosted credentials are wrong, stale or formatted incorrectly.Use the credential pair from Endpoints and refresh deployment secrets after rotation.
No OTLP copy, but TCP continuesThe helper has skipped blank or placeholder OTLP settings.Supply every required setting and restart the application with the intended deployment configuration.
Logs have an unknown service nameThe OTLP resource lacks an explicit application identity.Set ResourceAttributes["service.name"], alongside any ordinary event property.
Events near shutdown are missingThe process stopped with events still buffered.Keep Log.CloseAndFlush() and allow enough time for graceful termination.

Resolve authentication errors

For a 401 response, confirm the username, Hosted OpenTelemetry API key and current credential version. A rotated key must reach every running instance, including older deployments still participating in the overlap. Inspect configuration sources without printing the key or complete Authorization header. Leave the working route in place while correcting the failing sink.

Use the sink's endpoint semantics

Select HttpProtobuf and the HTTPS base URL from Endpoints. Hosted OpenTelemetry exposes HTTPS on 443 rather than public gRPC on 4317 or plain HTTP on 4318. This sink appends the logs path to its base Endpoint; a separate signal-specific exporter can expect a full path. Keep those settings distinct when the application also exports traces.

Give the batch time to finish

Writing an event and delivering it are separate operations. Batching can leave recent events in memory when the process exits. Allow graceful termination to flush both logger lifetimes, and avoid immediate process exit for normal shutdown. A forced kill bypasses cleanup, and an in-memory queue cannot promise recovery afterwards. Include a deployment or restart in the overlap review to assess the behaviour that matters to your application.

Stage the cutover and keep a rollback route

Two sinks mean two copies of each event that passes both routes' level thresholds. If both copies are stored, expect more ingested and retained data. The increase will not necessarily be exactly twice the byte count because JSON and OTLP documents have different shapes and your filters may change event sizes. Measure actual stack volume during the comparison and set an end date for the overlap.

1. Limit the initial overlap

Start with one service or a small deployment group. Keep the same severity threshold on both routes and record which instances have the new sink, the deployment time and the comparison window. Comparing all Logstash traffic with OTLP traffic from only one instance produces a misleading gap. Scope both queries to the same population, using an existing instance or deployment property when available.

Include a busy period, an ordinary release and representative failures before making the decision. Agree what must match: event identifiers, service identity, exception context and the properties used by critical searches. Explain any intentional difference, such as a Logstash filter dropping a class of events. Give unresolved differences an owner rather than accepting matching totals as sufficient evidence.

2. Move investigation tools before paging

Inventory the saved searches used in incident runbooks, dashboard panels linked from alerts, service error monitors and missing-log monitors. Include less visible dependencies such as scheduled reports and links shared with support. Prioritise the searches responders open first: request or order lookup, recent exceptions, failed dependencies and deployment comparisons.

Duplicate each essential saved search with an OTLP route filter and the field paths from your comparison. Give it a clear temporary name and compare its results with the existing search over the same interval. Move dashboard panels only after their filters, grouping fields and units produce the intended result. Keep the old saved objects available through the rollback window so responders can still investigate earlier data.

3. Move alerts without counting both copies

During the overlap, keep the active alert scoped to one route. A monitor that counts both copies can cross its threshold twice as quickly while application behaviour stays the same. For error-rate monitors, scope both the error numerator and the total-event denominator consistently. Confirm that a request-completion event and an application error are not accidentally counted as equivalent events.

Create the OTLP version of each critical monitor with notifications disabled while reviewing its matching documents and trigger conditions. Start with service error alerts, exception-specific alerts and ingestion silence alerts, then move lower-priority volume thresholds. Carry across grouping, lookback windows and notification context; a new severity field alone does not reproduce the whole rule. Enable the replacement and disable the corresponding old monitor in a coordinated change so ownership is clear.

4. Retire the old sink gradually

Expand the OTLP configuration to the remaining instances and confirm each deployment group appears before removing its Logstash sink. Remove the retiring sink from the shared helper so both bootstrap and host logging follow the decision. Retain shared enrichers and redaction. Record the last deployment time using both routes, then confirm new events continue on the retained route while old-route volume falls.

Review stack volume, event freshness, essential saved searches and alert behaviour after the change. Retiring a sink stops future copies; it does not remove documents already retained in the stack. Keep route filters on searches spanning the overlap or historical charts can still include duplicates. Preserve the old field mapping notes until the affected history is no longer needed.

5. Roll back with a specific trigger

Keep the previous deployment configuration and Logstash input available for an agreed period. Define rollback triggers before cutover: missing critical event identifiers, unusable exception context, delayed ingestion beyond your operational tolerance or a critical monitor that cannot select the right documents. Record who can restore the route and which saved searches and alerts return with it.

If a trigger occurs, restore the Logstash sink for the affected instances and point active monitors back to its fields and route filter. If Logstash is still shipping, switching the investigation tools back may be enough while you resolve the OTLP issue. Confirm fresh Logstash events arrive before relying on it again. Restoring a sink resumes future delivery; it does not recover events that were never stored, so investigate the gap separately.

End the overlap deliberately once the retained route meets the agreed criteria. Keep delivery_route as provenance if it helps historical searches, or remove it in a later change after updating its consumers. Retaining both routes permanently is also possible, but assign an owner, a reason and a volume budget rather than leaving duplication as migration residue.

Frequently asked questions

Do I need to run a local OpenTelemetry Collector?

No. The Serilog sink can send OTLP/HTTP directly to Hosted OpenTelemetry. A local Collector remains useful when you need processing or routing before export, but it adds a component to operate. This example keeps the application-to-Hosted path direct so the initial log delivery check is easy to follow.

Can I keep my existing Logstash filters?

Yes, for the copy still sent to Logstash. Keep that input and pipeline during migration. Review every filter whose output your team depends on before retiring the route. Equivalent enrichment and redaction need to exist on the retained path; sharing a destination stack does not copy the old pipeline's transformations.

Does adding this sink automatically give me traces?

The logging configuration here sends log records. It can include activity identifiers on those records, but that alone does not export the matching activity as a trace. Keep your existing tracing SDK configured to export spans, and use the APM add-on to store and investigate those traces.

Why do I see unknown_service?

Set the resource's service.name explicitly rather than relying on an ordinary event property; an absent resource identity can appear as unknown_service:dotnet. Use a stable application name and keep the tracing SDK's service name consistent with it. Put changing deployment details in separate attributes.

Should I add /v1/logs to Endpoint?

Use the base URL from Endpoints for this sink's Endpoint and let the sink supply the logs path. Keep any separate tracing exporter configuration independent, because a signal-specific endpoint can require its full path. Avoid copying endpoint settings between exporters without consulting their documentation.

Why does the sample start without an API key?

The helper skips the OTLP sink when its connection settings are missing or contain obvious placeholder text. Console logging continues, as does a valid TCP route. That keeps a fresh local checkout runnable. Check deployment configuration separately if missing remote logs should prevent a production release.

Will both routes always have identical counts?

They should carry the same selected application events when configured equivalently, but they are independent delivery paths. Filters, thresholds, retry behaviour and interrupted processes can cause differences. Compare identifiers over a fixed window, allow batches to finish, and investigate mismatches before treating totals as a migration sign-off.

Can I use the usual OTLP port 4317?

Use HTTPS 443 with HttpProtobuf for Hosted OpenTelemetry. Port 4317 belongs to gRPC receiver examples and is not an exposed Hosted port. A local Collector can accept gRPC from your applications and forward over HTTPS if that fits your architecture.

Start with one service

Pick an application whose logs your team already knows. Keep its Logstash copy, add the OTLP sink and pair identifiable events in Discover. Resolve field differences, move the incident searches and alerts, then retire the old sink with a rollback configuration ready. The cutover is complete when responders can use the retained copy for the same operational decisions.

Get the latest Elastic Stack & logging resources when you subscribe

Want to see this in action?
Start a free trial and connect logs to your alert workflows.