---
title: .NET SDK
path: sdk/dotnet
status: published
---

# .NET SDK

The .NET SDK (`ScaiLabs.ScaiLog`, net8.0) is the .NET emit side of ScaiLog: a
`Logger` plus `Pi.Mark` give you GDPR-native structured logging that ships
entries over a Unix socket to the local `scailog-agent`. It uses only the base
class library (zero external dependencies) and is wire-compatible with the
Python, TypeScript, and Go SDKs — a .NET-emitted entry is encrypted at ingest and
decrypts correctly through DSAR.

## Install

```bash
dotnet add package ScaiLabs.ScaiLog
```

The package targets `net8.0`.

## Emit a log entry

Construct a `Logger` with your tenant and service, then call a level method with
a `call_site_id`, a constant message, an optional `subject`, and a `fields`
dictionary. Wrap any personal-information value in `Pi.Mark`:

```csharp
using ScaiLabs.ScaiLog;

using var log = new Logger(new LoggerOptions { Tenant = "acme", Service = "checkout" });

var eventId = log.Info("SCAI-CHECKOUT-PAID-001", "order paid",
    subject: "usr_8842", // required whenever a field is PI-marked
    fields: new Dictionary<string, object?>
    {
        ["method"] = "card",
        ["email"]  = Pi.Mark("jan@example.nl", "direct_identifier"),
        ["ip"]     = Pi.Mark("203.0.113.7", "online_identifier"),
    });
```

Each level method returns the entry's ULID `eventId` (or `""` if the entry was
rejected by a contract check — see below). Plain fields (`method`) are stored in
cleartext and must stay PI-free; `Pi.Mark` fields are encrypted at ingest and
never enter the plaintext full-text index. Keep variable data in `fields`, not in
the message.

### Log levels

Six methods with the same signature
`Level(string site, string message, string? subject = null, IDictionary<string, object?>? fields = null, string? traceId = null, string? requestId = null)`:

```csharp
log.Trace("SCAI-CACHE-MISS-001", "cache miss");
log.Debug("SCAI-CACHE-EVICT-001", "evicted key");
log.Info("SCAI-USER-LOGIN-001", "user logged in", subject: "usr_8842");
log.Warn("SCAI-RATE-NEAR-001", "approaching rate limit");
log.Error("SCAI-DB-TXN-FAIL-001", "transaction rolled back");
log.Fatal("SCAI-BOOT-CONFIG-001", "missing required config");
```

## Contract rules

The SDK enforces the shared ScaiLog contract before an entry leaves the process.
On a violation the entry is **rejected**: the method returns `""` and a PII-free
diagnostic (the error code, service, and offending detail — never a field value)
is written to stderr, so the offending PII is never emitted.

- **`site`** (`call_site_id`) must match `^[A-Z0-9]+(-[A-Z0-9]+){2,}$`; the
  `AUTO-` / `SCAILOG-` prefixes are reserved. An empty site auto-derives an
  `AUTO-<hash>` id (wire-identical to the other SDKs) rather than being dropped.
- **`Pi.Mark(value, type)`** marks a field as personal information; markers
  attach to fields only, never the message.
- A PI-marked field **requires** a subject (`SUBJECT_REQUIRED`).
- Reserved field names (`event_id`, `msg`, …) are rejected the same way.

## Context propagation

Subject, trace id, and request id ride on an `AsyncLocal` and are used as
defaults when a call doesn't pass them explicitly. They flow across `await`
boundaries; push them with `LogContext.Push`, and dispose the scope (via `using`)
to restore the previous context:

```csharp
using (LogContext.Push(subject: user.Id, requestId: req.Id))
{
    // picks up subject + requestId from the ambient context
    log.Info("SCAI-API-HANDLED-001", "handled");
}
```

Explicit arguments on a call override the ambient context; nested pushes inherit
unspecified values from the enclosing scope.

## Configuration

`LoggerOptions { Tenant, Service, Environment, SocketPath, QueueSize }` configures
the logger. `Environment` defaults to `prod`, `SocketPath` defaults to
`$SCAILOG_SOCKET` or `/run/scailog/agent.sock`, and `QueueSize` defaults to
10,000.

Entries are queued and sent by a background sender as NDJSON over the socket. If
the queue is full or the socket is unavailable, the entry is written to stderr in
the same NDJSON shape (with PI values redacted) rather than being silently
dropped. Call `log.Flush()` before a short-lived process exits; disposing the
`Logger` (the `using` above) shuts down the background sender.
