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
13 changes: 13 additions & 0 deletions .changeset/dotnet-otel-correlation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@smooai/logger': patch
---

.NET: log records now carry the active OTel span's real W3C `traceId`/`spanId`
via `Activity.Current`, falling back to the correlation uuid when no valid
activity is in scope.

The version bump for the .NET package is driven from `package.json` by
`scripts/sync-versions.mjs`, so this file exists to make sure the release
CHANGELOG actually mentions the change — without it the NuGet package still
republishes (any changeset in the release triggers every language), just with no
record of what changed.
1 change: 1 addition & 0 deletions dotnet/src/SmooAI.Logger/LogContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public static class ContextKey
public const string RequestId = "requestId";
public const string Duration = "duration";
public const string TraceId = "traceId";
public const string SpanId = "spanId";
public const string Error = "error";
public const string Namespace = "namespace";
public const string Service = "service";
Expand Down
12 changes: 12 additions & 0 deletions dotnet/src/SmooAI.Logger/SmooLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,18 @@ private void Emit(Level level, string message, object? data, Exception? error)
}
}

// Correlate with the active distributed trace when one is in scope: stamp the
// real W3C trace/span id from Activity.Current instead of the fabricated uuid
// seeded at construction. Falls back to the prior traceId (no spanId) when no
// W3C activity is active. This is captured at emit time — the span is live at
// the log call, not necessarily when the logger was created.
var activity = System.Diagnostics.Activity.Current;
if (activity is not null && activity.IdFormat == System.Diagnostics.ActivityIdFormat.W3C)
{
entry[ContextKey.TraceId] = activity.TraceId.ToHexString();
entry[ContextKey.SpanId] = activity.SpanId.ToHexString();
}

entry[ContextKey.Level] = (int)level;
entry[ContextKey.LogLevel] = level.ToWireString();
entry[ContextKey.Time] = DateTimeOffset.UtcNow.ToString("O");
Expand Down
64 changes: 64 additions & 0 deletions dotnet/tests/SmooAI.Logger.Tests/TraceCorrelationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System.Diagnostics;
using System.Text.Json;
using SmooAI.Logger;

namespace SmooAI.Logger.Tests;

/// <summary>
/// Verifies the emit-time trace correlation (th-de3805): a log emitted inside an
/// active W3C <see cref="Activity"/> carries that span's real trace/span id, and
/// falls back to the fabricated correlation uuid when no activity is in scope.
/// </summary>
public class TraceCorrelationTests
{
private static (SmooLogger Logger, StringWriter Out) Build()
{
var writer = new StringWriter();
return (new SmooLogger(new SmooLoggerOptions { Output = writer, PrettyPrint = false }), writer);
}

private static JsonElement ParseSingle(string captured)
{
var line = captured.Split('\n', StringSplitOptions.RemoveEmptyEntries)[0];
return JsonDocument.Parse(line).RootElement;
}

[Fact]
public void Log_Within_Active_Activity_Carries_Real_TraceAndSpanId()
{
// ActivitySource only starts activities when a listener samples them.
using var source = new ActivitySource("smooai.logger.tests");
using var listener = new ActivityListener
{
ShouldListenTo = s => s.Name == "smooai.logger.tests",
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
};
ActivitySource.AddActivityListener(listener);

var (logger, writer) = Build();
using var activity = source.StartActivity("op");
Assert.NotNull(activity);
Assert.Equal(ActivityIdFormat.W3C, activity!.IdFormat);

logger.LogInfo("inside span");
var entry = ParseSingle(writer.ToString());

Assert.Equal(activity.TraceId.ToHexString(), entry.GetProperty("traceId").GetString());
Assert.Equal(activity.SpanId.ToHexString(), entry.GetProperty("spanId").GetString());
}

[Fact]
public void Log_Without_Activity_Falls_Back_To_Correlation_Uuid()
{
// No listener registered here, and any ambient activity cleared.
Activity.Current = null;

var (logger, writer) = Build();
logger.LogInfo("no span");
var entry = ParseSingle(writer.ToString());

// Prior behavior: traceId == the fabricated correlation id, no spanId emitted.
Assert.Equal(entry.GetProperty("correlationId").GetString(), entry.GetProperty("traceId").GetString());
Assert.False(entry.TryGetProperty("spanId", out _));
}
}
Loading