Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/trace-correlation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@smooai/audit": minor
---

Trace correlation: an emitted audit event now carries the W3C trace context of the
request that caused it, so a row in the audit store can be joined to a trace.

**The ids ride in the ENVELOPE, never inside the event.** The wire body is now
`{"event":<the sealed event>,"spanId":"…","traceId":"…"}`. The bytes under
`"event"` are exactly the bytes that were hashed — unchanged, byte-for-byte, with
or without a trace active — because `hashCurrent` covers canonical-JSON(event
minus `hashCurrent`) and any new event field would invalidate every stored chain
and every fixture in `spec/parity-corpus.json`. The corpus is untouched, and each
language asserts it inside an active span as well as outside one. Both ids are
OMITTED when there is no valid span: never `""`, never an all-zero id.

TypeScript: `AuditClient.emit(event, trace?)` captures the active context at
emit time behind an optional `@opentelemetry/api` peer dependency. Without it
installed (or without a registered SDK) it is a no-op, not a crash. `buildEnvelope`
/ `currentTraceContext` are exported for consumers on their own transport.

Rust: the same, behind a new optional `otel` cargo feature (off by default —
the crate does not link OpenTelemetry unless you ask for it). `AuditClient::emit`
uses the ambient span; `emit_with_trace` takes an explicit `TraceContext` that
wins per field. `TraceContext::current()` reads both context homes — a `tracing`
span via tracing-opentelemetry and an OTel-native one — because neither falls
back to the other.

Go: `AuditClient.Emit(ctx, event)` reads the span context already on the `ctx` it
takes (`trace.SpanContextFromContext(ctx).IsValid()` before touching the ids), via
the OpenTelemetry trace API only — no SDK, no exporter. Pinned to otel v1.35.0,
the newest release whose `go` directive (1.22.0) still builds on the Go 1.22 the
CI matrix pins; v1.36+ declare go 1.23.

Python: the ids come from the ambient span behind a guarded
`from opentelemetry import trace` import, exposed as the optional `otel` extra
(`pip install smooai-audit[otel]`). Without it installed, correlation is a no-op —
`opentelemetry-api` is never a hard dependency.

.NET: reads `Activity.Current` — the BCL type the OpenTelemetry .NET SDK itself
populates — so no new package reference. Non-W3C or unstarted activities report
nothing.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,31 @@ Every language exposes the same four things:
| `computeEventHash(event)` / `buildHashChain(events)` | The per-org-per-day SHA-256 hash chain. |
| `AuditClient` / `emit(event)` | POSTs an event to a configurable ingest endpoint with a bearer token. |

## The wire envelope

`emit` POSTs the canonical JSON of an **envelope**, not the bare event:

```json
{ "event": { "…the sealed event…": "…" }, "spanId": "00f067aa0ba902b7", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736" }
```

`traceId` / `spanId` are the W3C trace context captured at emit time, so an audit
row can be joined back to the request that caused it. They live in the envelope,
one level ABOVE the event, and never inside it: the hash chain covers
canonical-JSON(event minus `hashCurrent`), so a field added to the event would
change every hash and invalidate every chain already in a store. The bytes under
`"event"` are byte-identical with or without a trace active — every language
asserts the parity corpus inside an active span as well as outside one.

Both ids are omitted entirely when there is no valid span — never an empty string,
never the all-zero `00000000000000000000000000000000` id an unregistered SDK hands
you. OpenTelemetry is optional everywhere (TypeScript: an optional
`@opentelemetry/api` peer dependency; Rust: the `otel` cargo feature, off by
default; Python: the `otel` extra — `pip install smooai-audit[otel]` — behind a
guarded import; Go: the otel trace API only, no SDK, reading the span context off
the `ctx` you already pass to `Emit`; .NET: `Activity.Current` from the BCL, so no
new package at all), and with it absent the client behaves exactly as it did before.

## Install

**TypeScript / Node**
Expand Down
29 changes: 25 additions & 4 deletions dotnet/src/SmooAI.Audit/AuditClient.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text;

Expand Down Expand Up @@ -39,14 +40,19 @@ public AuditClient(AuditClientOptions options, HttpClient http)
}

/// <summary>
/// Seal the event (compute + stamp <see cref="AuditEvent.HashCurrent"/>) and POST its canonical
/// JSON to the endpoint with an <c>Authorization: Bearer</c> header. Throws on a non-success
/// status. ponytail: single POST, no retry/backoff — add it when a real transport SLA demands it.
/// Seal the event (compute + stamp <see cref="AuditEvent.HashCurrent"/>) and POST the canonical
/// JSON envelope — the sealed event plus the current <see cref="Activity"/>'s W3C trace ids, when
/// one is active — to the endpoint with an <c>Authorization: Bearer</c> header. Throws on a
/// non-success status. ponytail: single POST, no retry/backoff — add it when a real transport SLA
/// demands it.
/// </summary>
public async Task EmitAsync(AuditEvent @event, CancellationToken cancellationToken = default)
{
// Hash FIRST, from the event alone: the trace ids below ride in the envelope and can never
// enter the preimage.
var sealedEvent = @event with { HashCurrent = HashChain.ComputeEventHash(@event) };
var body = Canonical.ToCanonicalJson(sealedEvent);
var (traceId, spanId) = CurrentTraceContext();
var body = Canonical.ToCanonicalJsonEnvelope(sealedEvent, traceId, spanId);

using var request = new HttpRequestMessage(HttpMethod.Post, _options.Endpoint)
{
Expand All @@ -57,4 +63,19 @@ public async Task EmitAsync(AuditEvent @event, CancellationToken cancellationTok
using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}

/// <summary>
/// W3C trace ids of the ambient <see cref="Activity"/>, or <c>(null, null)</c> when there is no
/// activity or it is not W3C-formatted. No package needed — <c>System.Diagnostics.Activity</c> is
/// in the BCL and is what the OpenTelemetry .NET SDK itself populates.
/// </summary>
private static (string? TraceId, string? SpanId) CurrentTraceContext()
{
var activity = Activity.Current;
if (activity is null || activity.IdFormat != ActivityIdFormat.W3C || activity.TraceId == default)
{
return (null, null);
}
return (activity.TraceId.ToHexString(), activity.SpanId.ToHexString());
}
}
29 changes: 29 additions & 0 deletions dotnet/src/SmooAI.Audit/Canonical.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

namespace SmooAI.Audit;
Expand All @@ -24,6 +25,34 @@ public static class Canonical
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};

/// <summary>
/// Serialize the transport ENVELOPE to canonical JSON:
/// <c>{"event":{…the sealed event…},"spanId":"…","traceId":"…"}</c>.
/// The trace ids sit one level ABOVE the event, never inside it: they are added AFTER the hash is
/// computed, so the bytes under <c>"event"</c> — and therefore every hash — are identical whether
/// or not a span is active. A null/empty id is omitted entirely (never an all-zero id, never "").
/// </summary>
internal static string ToCanonicalJsonEnvelope(AuditEvent @event, string? traceId, string? spanId)
{
var envelope = new JsonObject
{
["event"] = JsonNode.Parse(JsonSerializer.Serialize(@event, SerializeOptions)),
};
if (!string.IsNullOrEmpty(traceId))
{
envelope["traceId"] = traceId;
}
if (!string.IsNullOrEmpty(spanId))
{
envelope["spanId"] = spanId;
}

using var doc = JsonDocument.Parse(envelope.ToJsonString());
var sb = new StringBuilder();
Write(sb, doc.RootElement);
return sb.ToString();
}

/// <summary>Serialize an audit event to its canonical JSON string.</summary>
public static string ToCanonicalJson(AuditEvent @event)
{
Expand Down
130 changes: 130 additions & 0 deletions dotnet/tests/SmooAI.Audit.Tests/AuditTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Diagnostics;
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using SmooAI.Audit;
Expand Down Expand Up @@ -64,6 +66,134 @@ public void BuildChainsHashPreviousAcrossEvents()
Assert.NotNull(chain[1].HashCurrent);
}

// --- Envelope trace correlation ---------------------------------------------------
// traceId/spanId ride OUTSIDE the hashed event, so an Activity must never move a hash.

private const string TraceIdHex = "4bf92f3577b34da6a3ce929d0e0e4736";

/// <summary>Captures the POSTed body instead of hitting the network.</summary>
private sealed class CapturingHandler : HttpMessageHandler
{
public string Body { get; private set; } = "";

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Body = await request.Content!.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.Accepted);
}
}

private static async Task<JsonObject> EmitAndCaptureAsync(AuditEvent evt)
{
using var handler = new CapturingHandler();
using var http = new HttpClient(handler);
var client = new AuditClient(new AuditClientOptions { Endpoint = "http://audit.test/ingest", Token = "t" }, http);
await client.EmitAsync(evt);
return JsonNode.Parse(handler.Body)!.AsObject();
}

/// <summary>Starts a real W3C Activity with a fixed trace id (listener required, no OTel SDK).</summary>
private static (ActivityListener Listener, ActivitySource Source, Activity Activity) StartActivity()
{
var listener = new ActivityListener
{
ShouldListenTo = _ => true,
Sample = static (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
};
ActivitySource.AddActivityListener(listener);
var source = new ActivitySource("SmooAI.Audit.Tests");
var parent = new ActivityContext(ActivityTraceId.CreateFromString(TraceIdHex), ActivitySpanId.CreateRandom(), ActivityTraceFlags.Recorded);
var activity = source.StartActivity("emit", ActivityKind.Internal, parent)!;
return (listener, source, activity);
}

private static AuditEvent SampleEvent() => new()
{
Id = "01A",
OrganizationId = "org-1",
ActorType = "user",
ActorId = "user-1",
Action = "crm.contact_created",
Resource = new AuditResource { Type = "crm.contact", Id = "c-1" },
Outcome = "success",
Metadata = new JsonObject(),
Timestamp = "2026-05-17T12:00:00.000Z",
};

[Fact]
public async Task EnvelopeCarriesTraceIdsWhenActivityIsActive()
{
var (listener, source, activity) = StartActivity();
JsonObject body;
try
{
body = await EmitAndCaptureAsync(SampleEvent());
}
finally
{
activity.Dispose();
source.Dispose();
listener.Dispose();
}

Assert.Equal(TraceIdHex, (string?)body["traceId"]);
Assert.Equal(activity.SpanId.ToHexString(), (string?)body["spanId"]);
}

[Fact]
public async Task EnvelopeOmitsTraceIdsWithoutActivity()
{
Activity.Current = null;

var body = await EmitAndCaptureAsync(SampleEvent());

// Omitted entirely — never an all-zero id, never an empty string.
Assert.False(body.ContainsKey("traceId"));
Assert.False(body.ContainsKey("spanId"));
}

/// <summary>
/// The hash-chain regression gate: every corpus fixture must seal to its committed hash — and the
/// envelope minus the trace ids must be the committed canonical bytes — with or without an Activity.
/// </summary>
[Theory]
[MemberData(nameof(Fixtures))]
public async Task CorpusHashUnchangedByActivity(string name, string eventJson, string expectedCanonical, string expectedHash)
{
_ = name;
var evt = JsonSerializer.Deserialize<AuditEvent>(eventJson, Relaxed)!;

Activity.Current = null;
var withoutActivity = await EmitAndCaptureAsync(evt);

var (listener, source, activity) = StartActivity();
JsonObject withActivity;
try
{
withActivity = await EmitAndCaptureAsync(evt);
}
finally
{
activity.Dispose();
source.Dispose();
listener.Dispose();
}

foreach (var body in new[] { withoutActivity, withActivity })
{
var sealedEvent = body["event"]!.DeepClone().AsObject();
Assert.Equal(expectedHash, (string?)sealedEvent["hashCurrent"]);

// The event minus its own hash must be the committed canonical bytes.
sealedEvent.Remove("hashCurrent");
var preimage = JsonSerializer.Deserialize<AuditEvent>(sealedEvent.ToJsonString(), Relaxed)!;
Assert.Equal(expectedCanonical, Canonical.ToCanonicalJson(preimage));
}

Assert.Equal(TraceIdHex, (string?)withActivity["traceId"]);
Assert.False(withoutActivity.ContainsKey("traceId"));
}

private static string CorpusPath()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
Expand Down
Loading
Loading