Package
Logthread for .NET
e9b9919ef111… 029e48f2606d… Send ILogger output (or direct calls) to Logthread. The SDK never blocks your code: a log call appends to a bounded in-memory queue and a single background task delivers batches to the Logthread ingest gateway.
Packages (hosted by Logthread as a NuGet feed, not on nuget.org yet):
| project | targets | depends on |
|---|---|---|
Logthread (core client) | netstandard2.1, net8.0 | nothing |
Logthread.Extensions.Logging | net8.0 | Microsoft.Extensions.Logging.Abstractions, Microsoft.Extensions.Configuration.Abstractions |
Logthread.AspNetCore | net8.0 | ASP.NET Core shared framework, Logthread.Extensions.Logging |
Logthread.OpenTelemetry | net8.0, net9.0 | Logthread.AspNetCore and the OpenTelemetry.* packages listed below |
Logthread.Serilog | net8.0 | Serilog |
Logthread.NLog | net8.0 | NLog |
Logthread.Log4Net | net8.0 | log4net |
Install#
# once per repository, next to your .sln or .csproj: Logthread packages only from the Logthread feed,
# everything else (Microsoft.Extensions.*) from nuget.org
curl -fsSLo nuget.config https://downloads.logthread.com/sdk/nuget/nuget.config
dotnet add package Logthread.Extensions.Logging # includes Logthread
dotnet add package Logthread.AspNetCore # optional: per-request correlation ids
# or just the core client:
dotnet add package LogthreadThe config uses NuGet package source mapping (NuGet 6+). Already have a nuget.config? Copy its logthread source and both packageSourceMapping entries into yours. Don't add the feed as a plain extra source: NuGet would then also accept a Logthread* package from nuget.org. --source on its own fails too, because it hides nuget.org where Microsoft.Extensions.* live. The download page, with versions and SHA-256 checksums, is <https://logthread.com/sdk#dotnet-logging>.
Quick start#
export LOGTHREAD_INGEST_KEY=<your ingest key> # shown once in onboarding
export LOGTHREAD_ENDPOINT=https://ingest.example.com # the ingest base URL; /v1/logs is appendedbuilder.Logging.AddLogthread(); // ASP.NET Core or any generic host
logger.LogInformation("order {OrderId} created", 42); // forwarded, with OrderId as an attributeWithout a key or endpoint the SDK disables itself, prints one line to stderr (logthread: LOGTHREAD_INGEST_KEY is not set; logs are not being sent) and your application carries on.
Without a host, use the core client directly:
using var lt = new LogthreadClient(new LogthreadOptions { Service = "worker" });
lt.Info("started", new Dictionary<string, object?> { ["queue"] = "emails" });
lt.Error("failed", exception: ex);
await lt.FlushAsync();A runnable sample is in examples/Basic.
Configuration#
Precedence: explicit options > environment > defaults. Configure in code with AddLogthread(o => o.Service = "api"), or from configuration with AddLogthread(configuration) (binds the Logthread section: ApiKey, Endpoint, Service, MinLevel, Attributes:name, Resource:name, RedactKeys:0, ...).
| option | env | default | notes |
|---|---|---|---|
ApiKey | LOGTHREAD_INGEST_KEY | none | sent as Authorization: Bearer; never logged |
Endpoint | LOGTHREAD_ENDPOINT | none | base URL; /v1/logs is appended (a value ending in /v1/logs is accepted). HTTPS required unless the host is localhost, 127.0.0.1, ::1 or *.localhost, or AllowInsecure |
AllowInsecure | LOGTHREAD_ALLOW_INSECURE | false | allow plain http to other hosts |
Service | LOGTHREAD_SERVICE, then OTEL_SERVICE_NAME | process name (the entry assembly name under dotnet), else unknown_service | resource service.name |
Environment | LOGTHREAD_ENVIRONMENT | none | resource deployment.environment |
Version | LOGTHREAD_SERVICE_VERSION | none | resource service.version |
Resource | none | {} | extra resource attributes |
Attributes | none | {} | added to every record |
MinLevel | LOGTHREAD_MIN_LEVEL | info | trace|debug|info|warn|error|fatal |
Enabled | LOGTHREAD_DISABLED=true disables | true | kill switch without a code change |
BatchSize | LOGTHREAD_BATCH_SIZE | 512 | flush when this many records wait |
FlushIntervalMs | LOGTHREAD_FLUSH_INTERVAL_MS | 1000 | flush at this interval if anything is queued |
MaxQueueRecords | LOGTHREAD_MAX_QUEUE_RECORDS | 10 000 | hard bound |
MaxQueueBytes | LOGTHREAD_MAX_QUEUE_BYTES | 8 MiB | hard bound on queued, uncompressed JSON |
MaxBatchBytes | LOGTHREAD_MAX_BATCH_BYTES | 1 MiB | a batch is cut at this size |
RequestTimeoutMs | LOGTHREAD_REQUEST_TIMEOUT_MS | 10 000 | per HTTP attempt |
MaxRetries | LOGTHREAD_MAX_RETRIES | 6 | retries per batch after the first attempt |
RedactKeys | none | password, passwd, secret, token, authorization, api_key, apikey, cookie, set-cookie | keys (case-insensitive, whole key or dotted suffix) whose value becomes [REDACTED]; an empty list disables. Value-level masking (cards, Aadhaar, PAN) is done by server-side Pipelines |
BeforeSend | none | none | Func<LogthreadEvent, LogthreadEvent?>: edit or drop a record, see Masking values |
OnError | none | none | Action<LogthreadDiagnostic> for SDK diagnostics |
Debug | LOGTHREAD_DEBUG=true | false | verbose diagnostics to stderr |
ShutdownTimeoutMs | LOGTHREAD_SHUTDOWN_TIMEOUT_MS | 5000 | default timeout of Flush/Shutdown/dispose |
CorrelationHeaders | LOGTHREAD_CORRELATION_HEADERS | x-correlation-id, x-request-id | request headers searched for a correlation id; the first is echoed and sent on outgoing calls |
EchoCorrelationHeader | none | true | echo the id on the response |
FlushOnProcessExit | none | true | best-effort flush (at most 2 s) at process exit |
HttpHandlerFactory | none | pooled SocketsHttpHandler | Func<HttpMessageHandler> that creates the handler the client sends with; see Proxy and TLS |
CaFile | LOGTHREAD_CA_FILE | none | PEM file of extra certificate authorities to trust |
Proxy | HTTPS_PROXY, HTTP_PROXY, NO_PROXY (read by .NET) | from the environment | http://[user:pass@]host:port for log delivery |
UseProxy | none | true | false ignores proxy settings and the proxy environment variables |
Invalid values (negative size, unknown level) fall back to the default with one diagnostic; they never throw.
What it does for your app#
- A log call never blocks, throws or does I/O. It checks the level, builds the record and appends it to a bounded queue. Measured p99 is a few microseconds; the test suite asserts under 1 ms.
- Bounded memory. The queue never exceeds
MaxQueueRecordsandMaxQueueBytes. When full, the new record is dropped and counted (DroppedQueueFull). At most one batch is in flight; at most one batch awaits a retry. - One background task delivers batches in order with a
CancellationToken. It does not use your synchronization context. - No recursion. Nothing logged from the SDK's own delivery flow (including your
OnErrorcallback) is forwarded. - Failures are swallowed, counted and reported only through
OnError/Debug; stderr output is limited to one line per minute per category. - Thread safe from any thread. The only global effects are an
ILoggerProviderand, by default, aProcessExithook that flushes for at most 2 s. - Shutdown is bounded. Disposing the provider (or
Shutdown) flushes within its timeout, never throws, and a second call is a no-op. Logging after shutdown does nothing.
Masking values (BeforeSend)#
RedactKeys masks by attribute name. For values hiding in free text, set BeforeSend. It receives a LogthreadEvent (Level, Message, Attributes as a dictionary of string, long, double or bool values) after attributes are flattened and RedactKeys has run, on the logging thread, so keep it quick. Edit the event in place and return it, return a new one, or return null to drop the record. The result goes through the same rules as any record: logthread.* names are dropped, RedactKeys applies to keys you add, and the limits hold. If the hook throws, the record is dropped (never sent unmasked) and OnError is told.
builder.Logging.AddLogthread(o => o.BeforeSend = ev =>
{
ev.Message = Regex.Replace(ev.Message, @"\b\d{13,16}\b", "[card]");
return ev.Attributes.TryGetValue("path", out var p) && (p as string) == "/healthz" ? null : ev; // drop noise
});Message bodies and string values are limited in UTF-8 bytes (32 KiB and 8 KiB), not characters, so a body of multi-byte text is cut where the byte limit says.
Proxy and TLS#
.NET reads HTTPS_PROXY, HTTP_PROXY and NO_PROXY for HttpClient by default, so log delivery already goes through a proxy that the environment names. Set Proxy (http://user:pass@proxy:3128) to name one in code, UseProxy = false to ignore the environment. Annotations use the same environment rules.
To trust a private CA, or the CA of a TLS-inspecting proxy, set CaFile (or LOGTHREAD_CA_FILE) to a PEM file. It is added to the system store, and only an unknown issuer is excused: a wrong host name or an expired certificate still fails. .NET does not read SSL_CERT_FILE.
The default handler is a SocketsHttpHandler (HttpClientHandler on netstandard2.1) with PooledConnectionLifetime of 5 minutes, so a DNS change reaches a long-running service, and with redirects off. For anything else (client certificates, a custom proxy, a test double) pass HttpHandlerFactory = () => new MyHandler(); the client owns and disposes the handler it creates, and CaFile, Proxy and UseProxy then apply to your handler, not to the client.
Serilog, NLog and log4net#
Serilog's UseSerilog() replaces the Microsoft.Extensions.Logging providers, so the ILogger provider above never sees those logs. Each of these libraries has its own package, a thin adapter over the same client (same options, batching, redaction, BeforeSend and correlation id). All of them map the level, logger name, structured properties and exception the way the ILogger provider does, and an explicit correlation_id property wins over the ambient LogthreadCorrelation id. Options not set in code come from the LOGTHREAD_* environment variables, and the service name is the Service option or LOGTHREAD_SERVICE.
Serilog (dotnet add package Logthread.Serilog):
Log.Logger = new LoggerConfiguration()
.WriteTo.Logthread(o => o.Service = "orders-api") // or set LOGTHREAD_SERVICE and call .WriteTo.Logthread()
.CreateLogger();
// ...
Log.CloseAndFlush(); // flushes and shuts the client downMessage template properties become attributes (structures are flattened to dotted keys), SourceContext becomes logger.name, and the body is the rendered message. Pass an existing client with .WriteTo.Logthread(client).
NLog (dotnet add package Logthread.NLog):
<nlog>
<extensions><add assembly="Logthread.NLog"/></extensions>
<targets><target xsi:type="Logthread" name="logthread" service="orders-api" /></targets>
<rules><logger name="*" minlevel="Info" writeTo="logthread" /></rules>
</nlog>or in code: config.AddRuleForAllLevels(new LogthreadTarget { Service = "orders-api" }). The target also accepts apiKey, endpoint, environment and version. Event properties, ScopeContext values and contextproperty entries become attributes.
log4net (dotnet add package Logthread.Log4Net):
<appender name="Logthread" type="Logthread.Log4Net.LogthreadAppender, Logthread.Log4Net">
<Service value="orders-api" />
</appender>
<root><level value="INFO" /><appender-ref ref="Logthread" /></root>Also configurable: ApiKey, Endpoint and Environment. LoggingEvent properties and the ThreadContext and LogicalThreadContext properties and stacks become attributes (the log4net:* bookkeeping properties are left out).
If you use Microsoft.Extensions.Logging directly, the provider's configuration alias is Logthread, so levels can be set per provider in appsettings.json:
{ "Logging": { "LogLevel": { "Default": "Information" }, "Logthread": { "LogLevel": { "Default": "Warning", "Noisy.Category": "None" } } } }Delivery and retries#
Records are sent as OTLP/HTTP JSON to POST {endpoint}/v1/logs, gzip from 1 KiB, in order, one batch at a time. A 200 means the gateway stored the batch durably.
| response | action |
|---|---|
| 200 | batch done |
| 400, 415, 422, other 4xx (except 408 and 425) | permanent: drop, count DroppedRejected, one diagnostic with the gateway message |
| 401, 403 | key invalid, revoked or workspace suspended: stop sending for 5 minutes (records keep queueing up to the bound), then try one batch; on success resume |
| 413 | split the batch in two and send each half, recursively; a single record that is still too large is dropped |
| 429 | wait Retry-After seconds (capped at 60 s), then retry |
| 408, 425, 500, 502, 503, 504, other 5xx, network error, timeout | exponential backoff with full jitter (base 500 ms, factor 2, cap 30 s); Retry-After is honoured on 503 |
After MaxRetries retries the batch is dropped (DroppedRetriesExhausted) and the worker moves on. Delivery is at-least-once: each record carries a stable event_id attribute so a retry after an unknown outcome is idempotent for the reader.
Records are capped before sending: body 32 KiB, string values 8 KiB (both end in …[truncated]), keys 128 characters, 124 attributes per record (the gateway allows 128 and stamps 4 itself; extras are dropped and sdk.attributes_dropped is added). Nested objects flatten to dotted keys (three key segments); deeper objects and arrays become a JSON string. logthread.* attributes are reserved and dropped.
What is forwarded from ILogger#
ILogger | Logthread |
|---|---|
| rendered message | body |
| level | severity (Critical = fatal) |
| category name | logger.name |
template arguments ({OrderId}) | attribute OrderId |
scopes (BeginScope) | attributes (keyed scopes); other scopes joined into scope |
EventId | event.id, event.name |
| exception | exception.type, exception.message, exception.stacktrace |
Correlating with traces#
If a System.Diagnostics.Activity is current (ASP.NET Core and OpenTelemetry both create them), each record gets its traceId and spanId. Pass traceId / trace_id (32 hex) or spanId / span_id (16 hex) as an attribute to override. Add request_id or any attribute you want to search on; Logthread's correlation runs server-side.
Correlation ids#
Every log written while handling one HTTP request can carry the same correlation_id, so Logthread groups them across services with no per-call code.
dotnet add package Logthread.AspNetCoreASP.NET Core minimal API (place the middleware as early as possible, before routing):
var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddLogthread();
builder.Services.AddLogthreadCorrelationForAllHttpClients(); // optional: propagate to outgoing HttpClient calls
var app = builder.Build();
app.UseLogthreadCorrelation(); // before UseRouting / endpoints
app.MapGet("/orders/{id}", (int id, ILogger<Program> log) =>
{
log.LogInformation("loading order {OrderId}", id); // carries correlation_id
return Results.Ok();
});
app.Run();Per request: the id is read from the first present header in CorrelationHeaders (default x-correlation-id, x-request-id; env LOGTHREAD_CORRELATION_HEADERS, comma-separated). It is accepted only if, trimmed, it is 1-128 characters of [A-Za-z0-9._:-]; otherwise (or when missing) a lowercase UUID v4 is generated. The id is held in an AsyncLocal (so it follows awaits and thread hops) and in an ILogger scope, and is echoed on the response as the first configured header (EchoCorrelationHeader = false turns that off; a header the app already set is never overwritten). An explicit correlation_id attribute in a log call wins; records outside any context get none.
Outgoing calls (opt-in): services.AddHttpClient("billing").AddLogthreadCorrelation() for one client, or services.AddLogthreadCorrelationForAllHttpClients() for all. The handler adds the id as the first configured header name unless the request already has it, and only inside a context. Without DI use new LogthreadCorrelationHandler { InnerHandler = ... }.
Background jobs and queue consumers:
using (LogthreadCorrelation.Begin(message.CorrelationId)) // null or invalid => a new id is generated
{
logger.LogInformation("processing {JobId}", job.Id); // carries correlation_id
await handler.RunAsync(job);
}
// LogthreadCorrelation.Current reads the id.Mark a deploy or event (annotations)#
Draw a marker on your charts when something happens, such as a deploy. This needs an API key with the "Create annotations" permission (Keys → API keys), not an ingest key. Set it as LOGTHREAD_API_KEY; LOGTHREAD_API_URL defaults to https://logthread.com. time defaults to now and must be within the last 30 days; times are sent as UTC ISO 8601 with a Z. Errors carry the standard code and message (forbidden for a key without the permission, invalid_request for a bad body).
using Logthread;
var item = await LogthreadAnnotations.CreateAsync(
"deploy 1.4.2",
text: "rolled out to production", // optional
service: "api", // optional
tags: new[] { "deploy" }); // optional
// time / end: DateTimeOffset. apiKey / endpoint / httpClient override the defaults.
// failures throw LogthreadApiException with .Code, .Message and .StatusThe .NET SDK ships no command line tool, so CI uses curl or a small dotnet run step that calls the method above.
In CI, a GitHub Actions step after your deploy:
- name: Mark deploy in Logthread
env:
LOGTHREAD_API_KEY: ${{ secrets.LOGTHREAD_API_KEY }}
run: |
curl -fsS -X POST https://logthread.com/api/annotations \
-H "Authorization: Bearer $LOGTHREAD_API_KEY" -H "Content-Type: application/json" \
-d "{\"title\":\"deploy ${GITHUB_SHA::7}\",\"service\":\"api\",\"tags\":[\"deploy\"]}"The same call without the SDK:
curl -fsS -X POST https://logthread.com/api/annotations \
-H "Authorization: Bearer $LOGTHREAD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"deploy '"$GITHUB_SHA"'","service":"api","tags":["deploy"]}'Troubleshooting#
client.GetStats()returnsQueued,SentRecords,SentBatches,DroppedQueueFull,DroppedRejected,DroppedRetriesExhausted,DroppedShutdown,Retries,LastError,DisabledUntil,Enabled. With the provider:provider.Client.GetStats().- Set
LOGTHREAD_DEBUG=true(orDebug = true) for diagnostics on stderr, or passOnError. DisabledUntilset means the gateway answered 401/403: check the key and that the workspace is active.- Nothing arrives and
Enabledis false: the key or endpoint is missing, the endpoint is plain http to a remote host, orLOGTHREAD_DISABLED=true.
Traces and metrics: Logthread.OpenTelemetry#
One call wires the OpenTelemetry .NET SDK end to end for Logthread: resource, traces, metrics and logs, exported to your Logthread endpoint with your ingest key. It adds no code of its own to the request path beyond the standard instrumentation and a small middleware, and it never throws into your application.
dotnet add package Logthread.OpenTelemetry # brings Logthread.AspNetCore and Logthread.Extensions.Logging
export LOGTHREAD_INGEST_KEY=<your ingest key>
export LOGTHREAD_ENDPOINT=https://ingest.example.combuilder.AddLogthread(); // WebApplicationBuilder / HostApplicationBuilder
// or: builder.Services.AddLogthread();That is all. Logthread:* configuration and LOGTHREAD_* environment variables work as for the logging packages (AddLogthread(o => o.Service = "api") for code). Without a key or endpoint it registers nothing and prints one line to stderr. A runnable app with EF Core (SQLite or SQL Server) is in examples/dotnet-aspnetcore-efcore; the .NET, EF Core and SQL Server guide walks through it and has the download.
What is captured#
| signal | what |
|---|---|
| traces | ASP.NET Core requests (route templates as span names, never raw paths), HttpClient calls, SqlClient and EF Core commands (normalised statement text, never parameter values), gRPC client calls |
| metrics | http.server.request.duration, http.server.active_requests, the built-in meters Microsoft.AspNetCore.Hosting, .Server.Kestrel, .Http.Connections, .Routing, .Diagnostics, .RateLimiting, .Authentication, .Authorization, .Identity (on the .NET versions that publish them), Microsoft.EntityFrameworkCore (EF Core 9+), System.Runtime, System.Net.Http, System.Net.NameResolution, plus the OpenTelemetry Runtime and Process instrumentation (GC, heap, thread pool, exceptions, CPU, memory) |
| logs | ILogger through the Logthread provider (batched, bounded), each record with traceId, spanId and the request correlation id |
Logthread meter logthread.db.query.duration | histogram in ms of every EF Core / SqlClient statement, keyed by db.query.hash (hash of the normalised statement), db.system and db.name. At most 500 distinct statements are tracked, the rest are reported as other. It does not depend on the trace sampling ratio. A statement that runs through EF Core and SqlClient is counted once |
Logthread meter logthread.aspnetcore.active_users | opt-in gauge of distinct authenticated users in a sliding 5 minute window |
Logthread meter logthread.aspnetcore.sign_ins | counter of sign-ins: read from the ASP.NET Core Identity (.NET 9) and authentication (.NET 10) sign-in meters, or call LogthreadTelemetry.RecordSignIn("Cookies") from your own login code on older runtimes |
Every request span also carries correlation_id (see Correlation ids); the correlation middleware is added to the front of the pipeline for you.
Export is OTLP/HTTP protobuf to {endpoint}/v1/traces and {endpoint}/v1/metrics with Authorization: Bearer <ingest key>, delta temporality for counters and histograms (observable up-down counters such as GC heap size are cumulative, as the OpenTelemetry specification requires), a 60 s metric interval, a 5 s span batch delay and a bounded span queue (2048). Sampling is parent-based with a ratio (default 1.0).
Database statements#
Both instrumentations see the SQL text. Logthread normalises it before export and removes the raw text:
SELECT * FROM Users WHERE Id = 42 AND Name = 'O''Brien' AND Id IN (1, 2, 3) -- note
=> SELECT * FROM Users WHERE Id = ? AND Name = ? AND Id IN (?)Numbers, strings, N'..', dollar-quoted strings, comments, IN (...) lists of any length, multi-row VALUES lists and the EF Core SET NOCOUNT ON; preamble are normalised; quoted identifiers and @parameter placeholders are kept. The span gets db.query.text (and db.statement, with the same literal-free text), db.query.hash, and db.system / db.name in one vocabulary whichever instrumentation produced it. Parameter values are never recorded. The span name becomes SELECT <database> when the instrumentation only used the database name.
Cardinality guard#
A metric view keeps only allow-listed attributes on every meter Logthread wires (HTTP method, route template, status code, error type, protocol, server.address/server.port, db.system, db.name, db.query.hash, the ASP.NET Core and GC/process attribute sets). A per-request value (user id, raw path, trace id) added by any of those meters can never become a time series. Add your own keys with options.AllowedMetricAttributes.Add("tenant.tier"), your own meters with options.Meters.Add("MyApp") (they are guarded too), or switch the guard off with CardinalityGuard = false. Meters Logthread does not know are not exported unless you add them.
Active users#
options.ActiveUsers = true counts distinct authenticated users (NameIdentifier, sub or Identity.Name) seen in the last 5 minutes. Each id is hashed with HMAC-SHA256 under a random per-process salt and fed to a HyperLogLog sketch (about 16 KiB per 10 s slot, constant whatever the number of users, error about 1%). Identities are never stored, never exported and never attached to metrics or spans; the gauge has no attributes. The count is per process: sum across instances is an upper bound.
Options#
Each option is also available as Logthread:<Name> configuration. Precedence: code > configuration > environment.
| option | env | default | notes |
|---|---|---|---|
ApiKey, Endpoint, AllowInsecure, Service, Environment, Version, Enabled | as the logging packages | none | Service falls back to OTEL_SERVICE_NAME, then the entry assembly; Version to the assembly version; Environment becomes deployment.environment |
Resource | none | {} | extra resource attributes |
Tracing, Metrics, Logs | LOGTHREAD_TRACING, LOGTHREAD_METRICS, LOGTHREAD_LOGS | true | switch a signal off |
SamplingRatio | LOGTHREAD_TRACE_SAMPLING_RATIO | 1.0 | parent-based ratio, 0 to 1 |
ExportIntervalMs | LOGTHREAD_EXPORT_INTERVAL_MS | 60000 | metrics, minimum 1000 |
MaxTraceQueueSize | LOGTHREAD_MAX_TRACE_QUEUE_SIZE | 2048 | spans wait here; more are dropped |
ExportTimeoutMs | LOGTHREAD_EXPORT_TIMEOUT_MS | 10000 | per export request |
ActiveUsers | LOGTHREAD_ACTIVE_USERS | false | see above |
DbQueryMetrics | LOGTHREAD_DB_QUERY_METRICS | true | logthread.db.query.duration |
MaxDbStatements | LOGTHREAD_MAX_DB_STATEMENTS | 500 | distinct statements before other |
CardinalityGuard | LOGTHREAD_CARDINALITY_GUARD | true | |
AllowedMetricAttributes, Meters | none | empty | extend the guard / the exported meters |
ExcludePaths | none | /health, /healthz, /livez, /readyz | request path prefixes that are not traced |
RecordExceptions | none | false | exception events on spans (messages can contain data) |
KeepRawSqlText | none | false | keep the raw db.statement (not recommended) |
ExportEnabled | none | true | false instruments without exporting (tests) |
ConfigureLogging | none | none | Action<LogthreadOptions> for the logging client |
ConfigureTracing, ConfigureMetrics | none | none | add sources, processors, meters or views to the providers |
Requests to the ingest endpoint itself (the SDK's own log delivery) are not traced; the http.client.* metrics still count them, under server.address of the ingest host.
Privacy defaults#
No request or response bodies, headers or query strings are captured. SQL parameter values are never recorded and statement text has its literals removed. Exception messages are not attached to spans unless RecordExceptions is on. User identities are only used for the active-users sketch, in hashed form, in memory. Metric attributes are restricted to the allow-list.
Upstream OpenTelemetry packages and their status#
Logthread depends only on OpenTelemetry.* packages, pinned to exact versions and updated by Logthread. Pre-release instrumentation is pinned exactly; stable packages follow the 1.x line.
| package | version | status upstream |
|---|---|---|
OpenTelemetry, OpenTelemetry.Extensions.Hosting, OpenTelemetry.Exporter.OpenTelemetryProtocol | 1.20.0 | stable |
OpenTelemetry.Instrumentation.AspNetCore, .Http, .Runtime, .SqlClient | 1.20.0 | stable |
OpenTelemetry.Instrumentation.Process | 1.20.0-rc.1 | release candidate |
OpenTelemetry.Instrumentation.EntityFrameworkCore | 1.20.0-beta.1 | beta |
OpenTelemetry.Instrumentation.GrpcNetClient | 1.20.0-beta.1 | beta |
Because three of them are pre-release, Logthread.OpenTelemetry itself is a 0.x package. Their attribute names can change between releases (the SqlClient instrumentation already publishes the newer db.namespace / db.system.name names while EF Core publishes db.name / db.system); Logthread writes db.system, db.name, db.query.text and db.query.hash on every database span so dashboards do not depend on that.
Limits#
- The logging packages send logs only. Traces and metrics: use
Logthread.OpenTelemetry(above), or standard OpenTelemetry (zero-code auto-instrumentation or the SDK) to the same Logthread endpoint and ingest key:OTEL_EXPORTER_OTLP_ENDPOINT=$LOGTHREAD_ENDPOINT,OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20$LOGTHREAD_INGEST_KEY. Onboarding has the exact snippet per language. This SDK attaches the active trace and span ids to log records so logs and traces link up. - Delivery is at-least-once, and records are dropped (and counted) when the queue is full or the gateway keeps failing.
exception.stacktraceis subject to the 8 KiB string limit.
Development#
docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:8.0 dotnet testLogthread.OpenTelemetry also targets net9.0; that target is only built by the .NET 9 SDK image (the .NET 8 image builds net8.0 only). To run its tests on both: docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:9.0 dotnet test tests/Logthread.OpenTelemetry.Tests -f net9.0. deploy/scripts/build-sdks.sh packs with the .NET 9 image.
The tests run against an in-process fake gateway (HttpListener) that answers scripted response sequences, with an injectable clock, random source and delay.
Report a change#
Report a deploy, config change, flag flip or migration so an incident can list what changed before it. It uses the same API key as annotations; type is deploy, config, flag, migration, infra or other, and a retry with the same id updates the change. See What changed before an incident.
await LogthreadAnnotations.ReportChangeAsync("deploy", "checkout 2.4", service: "checkout", version: "2.4", id: runId);