Telemetry, Logging, and Auditing in .NET: Designing the Right Data Path

Telemetry, logging and auditing are often implemented through the same API because all three produce timestamped records. That similarity is superficial. They answer different questions, tolerate different failure modes and require different storage contracts.

Telemetry explains how a system behaves. Logs explain what happened inside a running component. Audit records prove that a relevant action occurred in a business or security context. A single event, such as changing a customer’s payment address, may produce all three signals, but the resulting data should not be treated as three copies of the same message.

This distinction determines where the data belongs. Diagnostic logs and other telemetry need a system optimized for high-volume ingestion, correlation, aggregation, alerting and bounded operational retention. Azure Application Insights is an example of such a telemetry sink. Audit records need a durable application-owned schema, predictable retrieval, explicit authorization and a retention policy tied to legal or business requirements. A database such as Azure Cosmos DB can provide that contract, including per-item time-to-live (TTL).

The architectural rule is simple: observability data is evidence for operating software; audit data is evidence about actions within the software’s domain. Treating one as the other usually creates either an unreliable audit trail or an expensive and ineffective logging system.

Telemetry is the broadest term. It is data emitted by a running system and transported to another system for observation. The common observability signals are traces, metrics and logs:

  • Traces describe the path and timing of work across process and network boundaries.
  • Metrics aggregate numeric measurements over time, such as request rate, latency or failure count.
  • Logs describe discrete events with a severity, message template and structured properties.

Logging is therefore commonly one form of telemetry. The distinction still matters in application design because log records have different cardinality and query behavior than metrics or traces. A log can contain an order identifier; an order identifier should almost never become a metric dimension. A trace can correlate one request across dependencies; a log can explain a branch decision inside that request.

Auditing is not a fourth observability signal. It is an application capability with a different purpose. An audit record describes a security-sensitive or business-relevant action in a way that can later answer questions such as:

  • Who attempted or completed the action?
  • What kind of object was affected?
  • When did the action occur?
  • What was the outcome?
  • Through which application, credential or delegated identity did it happen?
  • Which policy, reason or approval was associated with it?
  • How can the record be correlated with the operational trace without depending on that trace?

The following comparison captures the main differences:

ConcernMetrics and tracesApplication logsAudit records
Primary purposeHealth, performance and distributed behaviorDiagnosis and operational explanationAccountability, security and business evidence
Typical audienceSRE, operations and developmentDevelopment, support and security operationsSecurity, compliance, support and domain owners
Data shapeAggregated measurements and correlated spansSemi-structured, high-volume eventsExplicit, versioned domain records
Loss toleranceUsually some loss is acceptableUsually some loss is acceptableLoss may violate a business or compliance requirement
SamplingExpected for some signals and workloadsPossible according to pipeline policyNormally forbidden
RetentionOperational, often days or monthsOperational, often days or monthsPolicy-driven and sometimes measured in years
Main access patternAggregate, correlate and alertSearch by time, service, severity and correlationRetrieve by tenant, subject, actor, action and time range
Preferred storageTelemetry backendCentral logging or telemetry sinkApplication-controlled durable store

This is not merely a storage optimization. Purpose affects the record itself. A diagnostic log may say that an authorization handler rejected a request because a claim was missing. An audit event should state that actor A attempted action B on subject C, that the decision was denied, and which stable policy code caused the denial. Internal class names and stack traces help diagnostics; they are poor audit contracts.

Why logs belong in a logging sink

Application logs are a write-heavy, time-oriented workload. Their value comes from central collection and correlation, not from being rows in the application’s primary database.

A dedicated sink such as Application Insights provides capabilities that ordinary application tables do not provide efficiently:

  • ingestion from many instances and services;
  • correlation with requests, dependencies, exceptions and distributed traces;
  • full-text and structured queries over operational fields;
  • dashboards, alerts and workbooks;
  • retention and workspace lifecycle controls;
  • volume management, sampling and ingestion processing; and
  • integration with deployment, infrastructure and platform telemetry.

Writing every application log to a domain database reverses those advantages. Logging volume competes with business transactions for throughput, storage and indexes. A production incident can create a burst of error logs exactly when the business database is already under pressure. Log schema changes require migrations or weak generic tables. Cleanup becomes an application job. Cross-service correlation becomes harder because each service owns a different table or database.

Database logging also creates a dangerous dependency direction. The code that reports a database outage may need the same database to persist the report. A telemetry exporter can buffer and transmit through a separate channel, while the application continues to fail or recover according to its actual business rules.

Application Insights is not the only valid sink. OpenTelemetry can export to Azure Monitor, an OpenTelemetry Collector, Grafana, Elasticsearch or another backend. The important property is that operational signals leave the transaction path and enter a system designed to ingest and analyze them.

Structured logging and telemetry in ASP.NET Core

Logs should be structured at the point where they are produced. A message template and named properties allow the backend to filter and aggregate without parsing rendered English text.

 1public sealed class OrderService
 2{
 3    private readonly ILogger<OrderService> _logger;
 4
 5    public OrderService(ILogger<OrderService> logger)
 6    {
 7        _logger = logger;
 8    }
 9
10    public async Task<Order> SubmitAsync(
11        Order order,
12        CancellationToken cancellationToken)
13    {
14        using IDisposable? scope = _logger.BeginScope(
15            new Dictionary<string, object?>
16            {
17                ["TenantId"] = order.TenantId,
18                ["OrderId"] = order.Id
19            });
20
21        _logger.LogInformation(
22            "Submitting order with {LineCount} lines through {Channel}",
23            order.Lines.Count,
24            order.Channel);
25
26        try
27        {
28            Order submittedOrder = await PersistAsync(order, cancellationToken);
29
30            _logger.LogInformation(
31                "Order submission completed with status {OrderStatus}",
32                submittedOrder.Status);
33
34            return submittedOrder;
35        }
36        catch (Exception exception)
37        {
38            _logger.LogError(exception, "Order submission failed");
39            throw;
40        }
41    }
42}

The template remains stable while values become queryable fields. The current trace context is normally attached by the OpenTelemetry and Azure Monitor pipeline, so a custom correlation identifier is not required for every log call. Scopes are useful for bounded context shared by several records.

The log still needs a data-classification review. Access tokens, passwords, connection strings, complete request bodies, payment data and unbounded user-provided text do not belong in telemetry. Customer and order identifiers may also be personal or commercially sensitive data. When correlation can work with an internal opaque identifier or a one-way representation, that is preferable to emitting a human-readable identifier.

The production pipeline can use OpenTelemetry with the Azure Monitor distribution:

 1using Azure.Monitor.OpenTelemetry.AspNetCore;
 2using OpenTelemetry.Logs;
 3using OpenTelemetry.Metrics;
 4using OpenTelemetry.Trace;
 5
 6WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
 7
 8builder.Logging.AddOpenTelemetry(logging =>
 9{
10    logging.IncludeFormattedMessage = true;
11    logging.IncludeScopes = true;
12});
13
14builder.Services
15    .AddOpenTelemetry()
16    .WithTracing(tracing =>
17    {
18        tracing.AddAspNetCoreInstrumentation();
19        tracing.AddHttpClientInstrumentation();
20    })
21    .WithMetrics(metrics =>
22    {
23        metrics.AddAspNetCoreInstrumentation();
24        metrics.AddHttpClientInstrumentation();
25        metrics.AddRuntimeInstrumentation();
26    })
27    .UseAzureMonitor();

Exporter configuration, including the Application Insights connection string or managed Azure configuration, belongs in deployment configuration rather than source code. The application emits standard signals; the environment decides where they are exported.

Telemetry must also control cardinality. status, operation, region and bounded error categories are useful metric attributes. userId, orderId, raw URL values and exception messages are unbounded and do not belong on metrics. High-cardinality identifiers can remain carefully classified log properties or span attributes when request-level investigation genuinely requires them.

Why an audit trail cannot be reconstructed from logs

Using existing logs as an audit trail initially appears efficient. The same event already seems to be present, and Application Insights offers powerful queries. The contract breaks under closer inspection.

First, telemetry pipelines are allowed to optimize for observability. Sampling, rate limits, transient exporter failures, ingestion throttling and retention expiration can all remove records. These behaviors are reasonable when the goal is to understand aggregate system health. They are unacceptable when every privileged role assignment or bank-account change must be accounted for.

Second, log schemas are operational implementation details. Message templates change during refactoring. Property names vary between services. Severity levels are adjusted to control noise. None of those changes should silently alter a long-lived audit contract.

Third, telemetry access is usually broad within engineering and operations. Audit records often require narrower roles, tenant isolation, subject-access workflows, legal holds and explicit export procedures. Keeping both datasets in one workspace makes least-privilege access difficult.

Fourth, an audit query is part of application behavior. A support workflow may need the complete history of a user account, ordered and paginated. A security workflow may need all role changes performed by one actor within a period. Those are stable product queries, not ad hoc incident searches.

Finally, Application Insights is not the source of truth for application state. Its retention policy is operational, not a business data lifecycle guarantee. Exporting logs to longer-term storage can be useful for forensic analysis, but it still does not turn loosely structured diagnostics into a complete audit model.

Model auditing as an application contract

An audit event should be explicit, immutable and versioned. It should capture the action and its context without storing an unrestricted copy of the affected object.

 1using System.Text.Json.Serialization;
 2
 3public sealed record AuditRecord
 4{
 5    [JsonPropertyName("id")]
 6    public required string Id { get; init; }
 7
 8    [JsonPropertyName("tenantId")]
 9    public required string TenantId { get; init; }
10
11    [JsonPropertyName("subjectId")]
12    public required string SubjectId { get; init; }
13
14    [JsonPropertyName("actorId")]
15    public required string ActorId { get; init; }
16
17    [JsonPropertyName("actorType")]
18    public required string ActorType { get; init; }
19
20    [JsonPropertyName("action")]
21    public required string Action { get; init; }
22
23    [JsonPropertyName("targetType")]
24    public required string TargetType { get; init; }
25
26    [JsonPropertyName("targetId")]
27    public required string TargetId { get; init; }
28
29    [JsonPropertyName("outcome")]
30    public required string Outcome { get; init; }
31
32    [JsonPropertyName("reasonCode")]
33    public string? ReasonCode { get; init; }
34
35    [JsonPropertyName("occurredAtUtc")]
36    public required DateTimeOffset OccurredAtUtc { get; init; }
37
38    [JsonPropertyName("traceId")]
39    public string? TraceId { get; init; }
40
41    [JsonPropertyName("schemaVersion")]
42    public int SchemaVersion { get; init; } = 1;
43
44    [JsonPropertyName("changedFields")]
45    public IReadOnlyCollection<string>? ChangedFields { get; init; }
46
47    [JsonPropertyName("ttl")]
48    public int TimeToLiveSeconds { get; init; }
49}

Stable action values such as customer.email.changed, role.assignment.created or payment.destination.change.denied are better than method names. actorType distinguishes a person, workload identity, support delegation or background process. subjectId identifies the person or account whose history is being described, while targetId identifies the concrete changed resource.

The optional changedFields property needs strict allow-listing. A password, authentication token, secret, full payment instrument or sensitive document must never be included as an old or new value. Even apparently harmless before-and-after values can increase privacy risk and storage cost. In many domains, recording the changed field names and a stable reason code is sufficient.

The trace identifier connects the audit record to operational telemetry during an investigation. It is a convenience, not a dependency: the audit record remains understandable after the trace has expired from Application Insights.

Store audit records in Cosmos DB with explicit TTL

Cosmos DB is a good audit-store candidate when the workload requires high write throughput, tenant-oriented partitioning, flexible but explicit documents and predictable time-based expiration. It is not automatically the right database for every audit system. Relational databases are often preferable when audit queries require joins, strict relational constraints or transactional coupling with relational business data.

For Cosmos DB, the partition key must follow real access and scale patterns. /tenantId is a practical starting point when most queries retrieve audit history within one tenant. A very large tenant or a workload dominated by subject-level reads may need a hierarchical partition key or another strategy. Partitioning by timestamp alone usually creates awkward cross-partition subject queries, while partitioning by a random event identifier destroys locality.

TTL should be enabled on the container with no automatic default expiry, then assigned explicitly per audit record. In Cosmos DB, a container defaultTtl value of -1 enables TTL while retaining items indefinitely unless an item supplies a positive ttl value. This prevents a missing application value from accidentally inheriting a short container-wide retention period.

A normal seven-year record can set its own retention value. Deriving the duration from AddYears(7) includes leap days instead of approximating every year as 365 days:

 1DateTimeOffset occurredAtUtc = timeProvider.GetUtcNow();
 2DateTimeOffset expiresAtUtc = occurredAtUtc.AddYears(7);
 3TimeSpan retention = expiresAtUtc - occurredAtUtc;
 4
 5AuditRecord record = new AuditRecord
 6{
 7    Id = Guid.NewGuid().ToString("N"),
 8    TenantId = tenantId,
 9    SubjectId = customerId,
10    ActorId = actorId,
11    ActorType = "user",
12    Action = "customer.email.changed",
13    TargetType = "customer",
14    TargetId = customerId,
15    Outcome = "succeeded",
16    OccurredAtUtc = occurredAtUtc,
17    TraceId = Activity.Current?.TraceId.ToString(),
18    ChangedFields = ["email"],
19    TimeToLiveSeconds = checked((int)retention.TotalSeconds)
20};

The example records that the email field changed without storing either address. When equality checks require a pseudonymous value, a plain hash of a small or predictable input is insufficient because it is susceptible to guessing. Keyed pseudonymization with managed key rotation may be required instead.

The writer uses CreateItemAsync, not an upsert, because replacing an audit event should not be part of normal application behavior:

 1using Microsoft.Azure.Cosmos;
 2
 3public interface IAuditStore
 4{
 5    Task AppendAsync(
 6        AuditRecord record,
 7        CancellationToken cancellationToken);
 8}
 9
10public sealed class CosmosAuditStore : IAuditStore
11{
12    private readonly Container _container;
13
14    public CosmosAuditStore(Container container)
15    {
16        _container = container;
17    }
18
19    public async Task AppendAsync(
20        AuditRecord record,
21        CancellationToken cancellationToken)
22    {
23        ItemRequestOptions requestOptions = new ItemRequestOptions
24        {
25            EnableContentResponseOnWrite = false
26        };
27
28        await _container.CreateItemAsync(
29            record,
30            new PartitionKey(record.TenantId),
31            requestOptions,
32            cancellationToken);
33    }
34}

An append-only application API is useful, but it is not an immutability guarantee by itself. The workload identity should receive only the Cosmos DB data-plane permissions it needs. Administrative delete or replace permissions should be separated from ordinary application access, and destructive operations should have their own monitored process. Backups, account-level security and key management remain part of the evidence model.

Cosmos DB TTL is based on the item’s last-modified timestamp. Updating an item changes that timestamp and therefore shifts expiration. That behavior reinforces the append-only rule. TTL cleanup is asynchronous and does not guarantee deletion at the exact second the value reaches zero. It is a lifecycle mechanism, not a precise scheduler and not a write-once-read-many compliance control.

Legal holds need a separate process. With TTL enabled, an item-level ttl of -1 can prevent expiration, but changing an existing record to apply a hold also changes its last-modified metadata. A stronger design can store hold state separately and move protected evidence to a dedicated retention boundary. The exact model must follow the applicable legal and compliance requirements rather than assuming that TTL alone provides compliance.

Do not create an unreliable dual write

The most subtle failure occurs when one request changes business data and then writes an audit record to another store:

11. Update the customer record.
22. Commit the business transaction.
33. Write the audit record to Cosmos DB.

If step 3 fails, the action exists without its required audit evidence. Reversing the order creates the opposite problem: an audit event may claim that an action succeeded even though the business transaction later failed.

An in-process Task.Run, an unawaited write or a best-effort ILogger call does not solve this. The process can terminate after the business commit and before the background work completes.

When the business database supports transactions, a transactional outbox is the usual solution. The business change and an audit-outbox message are committed in the same local transaction. A background processor reads the outbox, appends the durable audit record to Cosmos DB and marks the message as dispatched. Retries use a stable event identifier so duplicate delivery can be detected.

The resulting flow is:

1request
2  -> business transaction
3       -> update domain state
4       -> insert audit outbox message
5       -> commit
6  -> outbox processor
7       -> append audit record to Cosmos DB
8       -> mark outbox message dispatched

The outbox makes audit publication at-least-once, not magically exactly-once. AuditRecord.Id should therefore come from the stable outbox message identifier rather than a new GUID on every retry. A Cosmos DB conflict for that identifier can be treated as a duplicate only after the stored event is known to represent the same payload. Silently swallowing every conflict can hide an identifier collision or inconsistent retry.

If both the domain document and audit record can live in the same Cosmos DB container and logical partition, a transactional batch can commit them atomically. Separate containers do not participate in the same transactional batch. Combining records in one container is only appropriate when partitioning, throughput, indexing, retention and access-control requirements are compatible; audit data should not be forced into a domain container solely to obtain atomicity.

Some high-risk operations require fail-closed behavior. A privileged action may need to be rejected when the system cannot durably enqueue its audit event. Less critical activity may accept delayed audit publication through a durable outbox. That policy must be explicit per action category. A global best-effort policy quietly turns the most important audit events into the least reliable ones during an incident.

Retention is part of the data model

Retention should be selected from a policy, not chosen as a convenient database default. Different event categories can require different lifetimes:

  • authentication and access-decision events may need a relatively short security-investigation window;
  • changes to permissions, payment destinations or contractual settings may require years;
  • unsuccessful low-risk operations may need less retention than completed high-risk changes; and
  • legal holds may override normal expiration.

The chosen policy can map an action category to a positive ttl value when the record is created. Policy versioning should also be recorded when retention rules are expected to evolve. Existing items do not gain a new TTL merely because application configuration changed; a deliberate migration or lifecycle process is required.

Retention is also a privacy control. Keeping audit data indefinitely increases exposure and may conflict with data-minimization obligations. Conversely, deleting it too early can violate regulatory or contractual duties. Identifiers, pseudonymization strategy, encryption, legal basis, subject-access behavior and deletion exceptions all belong in the design review.

Queryability and access control are first-class requirements

An audit store is useful only when authorized workflows can retrieve complete records predictably. Queries should normally target one partition and use continuation tokens rather than unbounded reads.

 1using Microsoft.Azure.Cosmos;
 2
 3QueryDefinition query = new QueryDefinition(
 4    "SELECT * FROM audit " +
 5    "WHERE audit.tenantId = @tenantId " +
 6    "AND audit.subjectId = @subjectId " +
 7    "AND audit.occurredAtUtc >= @fromUtc " +
 8    "ORDER BY audit.occurredAtUtc DESC")
 9    .WithParameter("@tenantId", tenantId)
10    .WithParameter("@subjectId", subjectId)
11    .WithParameter("@fromUtc", fromUtc);
12
13QueryRequestOptions options = new QueryRequestOptions
14{
15    PartitionKey = new PartitionKey(tenantId),
16    MaxItemCount = 100
17};
18
19FeedIterator<AuditRecord> iterator = container.GetItemQueryIterator<AuditRecord>(
20    query,
21    continuationToken,
22    options);
23
24FeedResponse<AuditRecord> page = await iterator.ReadNextAsync(cancellationToken);
25string? nextContinuationToken = page.ContinuationToken;

The matching composite index should be validated against actual query plans and request-unit charges. Indexing every large or rarely queried value increases write cost. A stable audit schema makes it possible to index actor, subject, action, outcome and time intentionally while excluding bulky metadata that is retrieved but never filtered.

The API in front of this query must authorize tenant, subject and purpose. Possessing a support role should not automatically grant unrestricted access to every audit record. Reads and exports of especially sensitive audit history may themselves require auditing. This recursive requirement is not a contradiction; it reflects the fact that viewing evidence can be a privileged action.

One action can produce all three signal types

Consider a support operator changing a customer’s email address:

  1. A trace measures the HTTP request, authorization check, database transaction and outbox publication.
  2. Metrics increment bounded counters such as customer.profile.changes with dimensions for outcome and channel.
  3. Structured logs explain exceptional branches, retry behavior and dependency failures, correlated through the trace identifier.
  4. An audit record captures the actor, subject, stable action, outcome, approved reason, timestamp and retention policy.

The signals overlap in time but not in responsibility. The metric does not contain the customer identifier. The logs do not become the durable proof. The audit record does not contain a stack trace. The trace identifier connects the systems for the period in which both datasets exist.

This separation also improves incident behavior. Application Insights can be temporarily noisy, sampled or unavailable without changing the domain transaction policy. Cosmos DB audit publication can retry through the outbox without blocking every request on a second remote write. A failure to durably enqueue a mandatory audit event can reject the action according to an explicit business rule.

Practical decision rules

The destination becomes easier to choose when the consuming question is stated first:

  • Data used to alert on latency, error rate, saturation or availability is telemetry.
  • Data used to diagnose code paths, exceptions and dependency behavior is a structured log in the telemetry pipeline.
  • Data required to establish who performed a relevant action, against which subject, with what outcome is an audit record.
  • Data needed to reconstruct current business state is domain data, not telemetry or an audit substitute.

Application Insights should receive logs, traces and metrics because it is designed to correlate and analyze operational signals. Cosmos DB or another application-owned database should receive audit events because auditing needs a durable schema, controlled queries, explicit retention and a delivery guarantee aligned with the business action.

The boundary should remain visible in code. ILogger, Activity and Meter belong to observability infrastructure. An IAuditStore or audit-outbox contract belongs to the application architecture. Hiding both behind a generic IEventWriter saves little code and erases the most important semantic distinction.

Conclusion

Telemetry, logging and auditing may all look like timestamped events, but they carry different promises. Telemetry and logs optimize for understanding a running system. They belong in a specialized sink such as Application Insights, where correlation, aggregation, search, alerting and operational retention are first-class capabilities.

Auditing optimizes for accountability. It needs a versioned domain schema, deliberate data minimization, tenant-aware queries, restricted access, reliable delivery and policy-driven retention. Cosmos DB can satisfy those requirements for suitable workloads, and item-level TTL provides a practical expiration mechanism when its asynchronous and non-immutable nature is understood.

The decisive design question is not where an event is easiest to write. It is which guarantee the event must still provide after services have restarted, telemetry has been sampled, implementation details have changed and the original operational traces have expired.

For further implementation detail, see the OpenTelemetry .NET documentation , Azure Monitor OpenTelemetry documentation , and Azure Cosmos DB time-to-live documentation .


Let's Work Together

Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.

New Platforms

Modernization

Training & Consulting