Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -107,36 +107,9 @@ static void RenderPropertyValueUnaligned(LogEventPropertyValue propertyValue, Te
return;
}

if (value is ValueType)
if (formatProvider?.GetFormat(typeof(ICustomFormatter)) is ICustomFormatter customFormatter)
{
if (value is int or uint or long or ulong or decimal or byte or sbyte or short or ushort)
{
output.Write(((IFormattable)value).ToString(format, formatProvider));
return;
}

if (value is double d)
{
output.Write(d.ToString(format, formatProvider));
return;
}

if (value is float f)
{
output.Write(f.ToString(format, formatProvider));
return;
}

if (value is bool b)
{
output.Write(b);
return;
}
}

if (value is IFormattable formattable)
{
output.Write(formattable.ToString(format, formatProvider));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deleted code here is all still required, without it, the format provider isn't passed through IFormattable.

Copy link
Author

@aradalvand aradalvand Sep 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so. Custom formatters often do this internally if they see fit.
Here's an example

public sealed class LogMessageFormatter(IFormatProvider? fallbackFormatter = null) : IFormatProvider, ICustomFormatter
{
	public static LogMessageFormatter WithInvariant { get; } = new(CultureInfo.InvariantCulture);

	public string Format(string? format, object? arg, IFormatProvider? _)
	{
		return arg switch
		{
			// NOTE: Print timestamps based on the host machine's timezone.
			Instant instant => instant
				.InZone(DateTimeZoneProviders.Tzdb.GetSystemDefault())
					.ToString("yyyy-MM-ddTHH:mm:sso<g>", formatProvider: fallbackFormatter), // NOTE: NodaTime's ZonedDateTime's format strings: https://nodatime.org/3.2.x/userguide/zoneddatetime-patterns

			// NOTE: Truncate GUIDs (because they're long AF), and only show the last 12 characters.
			IId id => id.Value.ToString()[^12..], // NOTE: We choose the last part (rather than the first bit, which is often more common when it comes to UUIDs), because we're using UUIDv7 which have their random bits at the end, the first ones signify the timestamp and are therefore prone to collision.

			// NOTE: Display the type's name rather than its fully-qualified name.
			Type type => type.SimpleName(),

			// NOTE: For anything else, if the object implements `IFormattable` (which means that it has a `.ToString()` overload that receives a format string), we want to use that. This is important in cases where the argument's format string is provided in the template (such as console template's `Timestamp`).
			IFormattable thing => thing.ToString(format, fallbackFormatter),

			_ => arg?.ToString() ?? "NULL",
		};
	}

	public object? GetFormat(Type? formatType)
	{
		return formatType == typeof(ICustomFormatter)
			? this
			: fallbackFormatter?.GetFormat(formatType);
	}
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's no custom formatter, though, we just fall through to output.Write(value) (no format provider used).

output.Write(customFormatter.Format(format, value, formatProvider));
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public void FormatsEmbeddedStringsWithoutQuoting()
};

var actual = CleanMessageTemplateFormatter.Format(template, properties, null);

// The default formatter would produce "Hello, \"world\"!" here.
Assert.Equal("Hello, world!", actual);
}
Expand All @@ -28,17 +28,103 @@ public void FormatsEmbeddedStructuresAsJson()
var template = new MessageTemplateParser().Parse("Received {Payload}");
var properties = new Dictionary<string, LogEventPropertyValue>
{
["Payload"] = new StructureValue(new []
{
["Payload"] = new StructureValue(
[
// Particulars of the JSON structure are unimportant, this is handed of to Serilog's default
// JSON value formatter.
new LogEventProperty("a", new ScalarValue(42))
})
])
};

var actual = CleanMessageTemplateFormatter.Format(template, properties, null);

// The default formatter would produce "Received {a = 42}" here.
Assert.Equal("Received {\"a\":42}", actual);
}

[Fact]
public void CustomFormatterWorksForScalarValues()
{
var template = new MessageTemplateParser().Parse("Event occurred at: {Timestamp}");
var timestamp = new DateTime(2024, 1, 15, 14, 30, 45);
var properties = new Dictionary<string, LogEventPropertyValue>
{
["Timestamp"] = new ScalarValue(timestamp)
};

var formatProvider = new CustomDateTimeFormatProvider();
var actual = CleanMessageTemplateFormatter.Format(template, properties, formatProvider);

// The CustomDateTimeFormatProvider should be invoked to format the DateTime object
Assert.Equal("Event occurred at: CUSTOM(2024-01-15 14:30:45)", actual);
}

[Fact]
public void CustomFormatterWorksWithMultipleScalarValues()
{
var template = new MessageTemplateParser().Parse("Events: {Start} and {End}");
var start = new DateTime(2024, 1, 15, 9, 0, 0);
var end = new DateTime(2024, 1, 15, 17, 0, 0);
var properties = new Dictionary<string, LogEventPropertyValue>
{
["Start"] = new ScalarValue(start),
["End"] = new ScalarValue(end)
};

var formatProvider = new CustomDateTimeFormatProvider();
var actual = CleanMessageTemplateFormatter.Format(template, properties, formatProvider);

Assert.Equal($"Events: CUSTOM(2024-01-15 09:00:00) and CUSTOM(2024-01-15 17:00:00)", actual);
}

[Fact]
public void CustomFormatterWorksWithMixedTypes()
{
var template = new MessageTemplateParser().Parse("Event at {Timestamp} with {Count} items");
var timestamp = new DateTime(2024, 1, 15, 14, 30, 45);
var properties = new Dictionary<string, LogEventPropertyValue>
{
["Timestamp"] = new ScalarValue(timestamp),
["Count"] = new ScalarValue(42)
};

var formatProvider = new CustomDateTimeFormatProvider();
var actual = CleanMessageTemplateFormatter.Format(template, properties, formatProvider);

// NOTE: DateTime should be custom formatted, Count should use default formatting
Assert.Equal("Event at CUSTOM(2024-01-15 14:30:45) with 42 items", actual);
}

[Fact]
public void CustomFormatterWorksWithAlignment()
{
var template = new MessageTemplateParser().Parse("Event: {Timestamp,30}");
var timestamp = new DateTime(2024, 1, 15, 14, 30, 45);
var properties = new Dictionary<string, LogEventPropertyValue>
{
["Timestamp"] = new ScalarValue(timestamp)
};

var formatProvider = new CustomDateTimeFormatProvider();
var actual = CleanMessageTemplateFormatter.Format(template, properties, formatProvider);

var expected = $"Event: CUSTOM(2024-01-15 14:30:45)";
Assert.Equal(expected, actual);
}
}

class CustomDateTimeFormatProvider : IFormatProvider, ICustomFormatter
{
public const string DateTimeFormatting = "CUSTOM-DATE-TIME-FORMATTING";
public object? GetFormat(Type? formatType) =>
formatType == typeof(ICustomFormatter)
? this
: null;

public string Format(string? format, object? arg, IFormatProvider? formatProvider) => arg switch
{
DateTime dateTime => $"CUSTOM({dateTime:yyyy-MM-dd HH:mm:ss})",
IFormattable formattable => formattable.ToString(format, formatProvider),
_ => arg?.ToString() ?? ""
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public async Task DefaultScopeIsNull()
var scopeLogs = Assert.Single(resourceLogs.ScopeLogs);
Assert.Null(scopeLogs.Scope);
}

[Fact]
public async Task SourceContextNameIsInstrumentationScope()
{
Expand All @@ -27,7 +27,7 @@ public async Task SourceContextNameIsInstrumentationScope()
var scopeLogs = Assert.Single(resourceLogs.ScopeLogs);
Assert.Equal(contextType.FullName, scopeLogs.Scope.Name);
}

[Fact]
public async Task ScopeLogsAreGrouped()
{
Expand All @@ -47,10 +47,26 @@ public async Task ScopeLogsAreGrouped()
Assert.Single(resourceLogs.ScopeLogs.Single(r => r.Scope == null).LogRecords);
}

static async Task<ExportLogsServiceRequest> ExportAsync(IReadOnlyCollection<LogEvent> events)
[Fact]
public async Task CustomFormatterSupportedEndToEnd()
{
var timestamp = new DateTime(2024, 6, 15, 10, 30, 0);

var events = CollectingSink.Collect(log =>
log.Information("Event occurred at: {Timestamp}", timestamp));

var request = await ExportAsync(events, new CustomDateTimeFormatProvider());
var resourceLogs = Assert.Single(request.ResourceLogs);
var scopeLogs = Assert.Single(resourceLogs.ScopeLogs);
var logRecord = Assert.Single(scopeLogs.LogRecords);

Assert.Equal($"Event occurred at: CUSTOM(2024-06-15 10:30:00)", logRecord.Body.StringValue);
}

static async Task<ExportLogsServiceRequest> ExportAsync(IReadOnlyCollection<LogEvent> events, IFormatProvider? formatProvider = null)
{
var exporter = new CollectingExporter();
var sink = new OpenTelemetryLogsSink(exporter, null, new Dictionary<string, object>(), OpenTelemetrySinkOptions.DefaultIncludedData);
var sink = new OpenTelemetryLogsSink(exporter, formatProvider, new Dictionary<string, object>(), OpenTelemetrySinkOptions.DefaultIncludedData);
await sink.EmitBatchAsync(events);
return Assert.Single(exporter.ExportLogsServiceRequests);
}
Expand Down