diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index be18640323..7fab354823 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -9,6 +9,7 @@ */ using System.Net.Http; +using Sentry.Experimental; using static System.Console; // Initialize the Sentry SDK. (It is not necessary to dispose it.) @@ -35,6 +36,18 @@ options.TracesSampleRate = 1.0; }); +#pragma warning disable SENTRY0002 +SentrySdk.Logger.Trace("Hello, World!", null, log => log.SetAttribute("trace", "trace")); +SentrySdk.Logger.Debug("Hello, .NET!", null, log => log.SetAttribute("trace", "trace")); +SentrySdk.Logger.Info("Information", null, log => log.SetAttribute("trace", "trace")); +SentrySdk.Logger.Warn("Warning with one {0}", ["parameter"], log => log.SetAttribute("trace", "trace")); +SentrySdk.Logger.Error("Error with {0} {1}", [2, "parameters"], log => log.SetAttribute("trace", "trace")); +SentrySdk.Logger.Fatal("Fatal {0} and {1}", [true, false], log => log.SetAttribute("trace", "trace")); +#pragma warning restore SENTRY0002 + +await Task.Delay(5_000); + +/* // This starts a new transaction and attaches it to the scope. var transaction = SentrySdk.StartTransaction("Program Main", "function"); SentrySdk.ConfigureScope(scope => scope.Transaction = transaction); @@ -96,3 +109,4 @@ async Task ThirdFunction() span.Finish(); } } +*/ diff --git a/src/Sentry/Experimental/SentryLog.cs b/src/Sentry/Experimental/SentryLog.cs new file mode 100644 index 0000000000..f7bdf687ed --- /dev/null +++ b/src/Sentry/Experimental/SentryLog.cs @@ -0,0 +1,131 @@ +using Sentry.Extensibility; +using Sentry.Infrastructure; +using Sentry.Internal.Extensions; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Sentry.Experimental; + +[Experimental(DiagnosticId.ExperimentalSentryLogs)] +public sealed class SentryLog : ISentryJsonSerializable +{ + private Dictionary? _attributes; + private int _severityNumber = -1; + + [SetsRequiredMembers] + internal SentryLog(SentrySeverity level, string message, object[]? parameters = null) + { + Timestamp = DateTimeOffset.UtcNow; + TraceId = SentryId.Empty; + Level = level; + Message = message; + Parameters = parameters; + } + + public required DateTimeOffset Timestamp { get; init; } + + public required SentryId TraceId { get; init; } + + public SentrySeverity Level + { + get => SentrySeverityExtensions.FromSeverityNumber(_severityNumber); + set => _severityNumber = SentrySeverityExtensions.ToSeverityNumber(value); + } + + public required string Message { get; init; } + + //public Dictionary? Attributes { get { return _attributes; } } + + public string? Template { get; init; } + + public object[]? Parameters { get; init; } + + public required int SeverityNumber + { + get => _severityNumber; + set + { + SentrySeverityExtensions.ThrowIfOutOfRange(value); + _severityNumber = value; + } + } + + public void SetAttribute(string key, string value) + { + _attributes ??= new Dictionary(); + _attributes[key] = new ValueTypePair(value, "string"); + } + + public void SetAttribute(string key, bool value) + { + _attributes ??= new Dictionary(); + _attributes[key] = new ValueTypePair(value, "boolean"); + } + + public void SetAttribute(string key, int value) + { + _attributes ??= new Dictionary(); + _attributes[key] = new ValueTypePair(value, "integer"); + } + + public void SetAttribute(string key, double value) + { + _attributes ??= new Dictionary(); + _attributes[key] = new ValueTypePair(value, "double"); + } + + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + _attributes = new Dictionary + { + { "sentry.environment", new ValueTypePair("production", "string")}, + { "sentry.release", new ValueTypePair("1.0.0", "string")}, + { "sentry.trace.parent_span_id", new ValueTypePair("b0e6f15b45c36b12", "string")}, + }; + + writer.WriteStartObject(); + writer.WriteStartArray("items"); + writer.WriteStartObject(); + + writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds()); + writer.WriteString("trace_id", TraceId); + writer.WriteString("level", Level.ToLogString()); + writer.WriteString("body", Message); + + writer.WritePropertyName("attributes"); + writer.WriteStartObject(); + + if (Template is not null) + { + writer.WriteSerializable("sentry.message.template", new ValueTypePair(Template, "string"), null); + } + + if (Parameters is not null) + { + for (var index = 0; index < Parameters.Length; index++) + { + var type = "string"; + writer.WriteSerializable($"sentry.message.parameters.{index}", new ValueTypePair(Parameters[index], type), null); + } + } + + if (_attributes is not null) + { + foreach (var attribute in _attributes) + { + writer.WriteSerializable(attribute.Key, attribute.Value, null); + } + } + + writer.WriteEndObject(); + + if (SeverityNumber != -1) + { + writer.WriteNumber("severity_number", SeverityNumber); + } + + writer.WriteEndObject(); + writer.WriteEndArray(); + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/Experimental/SentrySeverity.cs b/src/Sentry/Experimental/SentrySeverity.cs new file mode 100644 index 0000000000..189d8eac6c --- /dev/null +++ b/src/Sentry/Experimental/SentrySeverity.cs @@ -0,0 +1,74 @@ +using Sentry.Infrastructure; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Sentry.Experimental; + +//TODO: QUESTION: not sure about the name +// this is a bit different to Sentry.SentryLevel and Sentry.BreadcrumbLevel +[Experimental(DiagnosticId.ExperimentalSentryLogs)] +public enum SentrySeverity : short +{ + Trace, + Debug, + Info, + Warn, + Error, + Fatal, +} + +[Experimental(DiagnosticId.ExperimentalSentryLogs)] +internal static class SentrySeverityExtensions +{ + internal static string ToLogString(this SentrySeverity severity) + { + return severity switch + { + SentrySeverity.Trace => "trace", + SentrySeverity.Debug => "debug", + SentrySeverity.Info => "info", + SentrySeverity.Warn => "warn", + SentrySeverity.Error => "error", + SentrySeverity.Fatal => "fatal", + _ => throw new ArgumentOutOfRangeException(nameof(severity), severity, null), + }; + } + + internal static SentrySeverity FromSeverityNumber(int severityNumber) + { + ThrowIfOutOfRange(severityNumber); + + return severityNumber switch + { + >= 1 and <= 4 => SentrySeverity.Trace, + >= 5 and <= 8 => SentrySeverity.Debug, + >= 9 and <= 12 => SentrySeverity.Info, + >= 13 and <= 16 => SentrySeverity.Warn, + >= 17 and <= 20 => SentrySeverity.Error, + >= 21 and <= 24 => SentrySeverity.Fatal, + _ => throw new UnreachableException(), + }; + } + + internal static int ToSeverityNumber(SentrySeverity severity) + { + return severity switch + { + SentrySeverity.Trace => 1, + SentrySeverity.Debug => 5, + SentrySeverity.Info => 9, + SentrySeverity.Warn => 13, + SentrySeverity.Error => 17, + SentrySeverity.Fatal => 21, + _ => throw new ArgumentOutOfRangeException(nameof(severity), severity, null) + }; + } + + internal static void ThrowIfOutOfRange(int severityNumber) + { + if (severityNumber is < 1 or > 24) + { + throw new ArgumentOutOfRangeException(nameof(severityNumber), severityNumber, "SeverityNumber must be between 1 (inclusive) and 24 (inclusive)."); + } + } +} diff --git a/src/Sentry/Experimental/System.Diagnostics.CodeAnalysis.ExperimentalAttribute.cs b/src/Sentry/Experimental/System.Diagnostics.CodeAnalysis.ExperimentalAttribute.cs new file mode 100644 index 0000000000..b173d83323 --- /dev/null +++ b/src/Sentry/Experimental/System.Diagnostics.CodeAnalysis.ExperimentalAttribute.cs @@ -0,0 +1,29 @@ +#if !NET8_0_OR_GREATER +// ReSharper disable CheckNamespace +// ReSharper disable ConvertToPrimaryConstructor +namespace System.Diagnostics.CodeAnalysis; + +[AttributeUsage(AttributeTargets.Assembly | + AttributeTargets.Module | + AttributeTargets.Class | + AttributeTargets.Struct | + AttributeTargets.Enum | + AttributeTargets.Constructor | + AttributeTargets.Method | + AttributeTargets.Property | + AttributeTargets.Field | + AttributeTargets.Event | + AttributeTargets.Interface | + AttributeTargets.Delegate, Inherited = false)] +internal sealed class ExperimentalAttribute : Attribute +{ + public ExperimentalAttribute(string diagnosticId) + { + DiagnosticId = diagnosticId; + } + + public string DiagnosticId { get; } + + public string? UrlFormat { get; set; } +} +#endif diff --git a/src/Sentry/Experimental/System.Diagnostics.UnreachableException.cs b/src/Sentry/Experimental/System.Diagnostics.UnreachableException.cs new file mode 100644 index 0000000000..48b51df92e --- /dev/null +++ b/src/Sentry/Experimental/System.Diagnostics.UnreachableException.cs @@ -0,0 +1,22 @@ +#if !NET7_0_OR_GREATER +// ReSharper disable CheckNamespace +namespace System.Diagnostics; + +internal sealed class UnreachableException : Exception +{ + public UnreachableException() + : base("The program executed an instruction that was thought to be unreachable.") + { + } + + public UnreachableException(string? message) + : base(message ?? "The program executed an instruction that was thought to be unreachable.") + { + } + + public UnreachableException(string? message, Exception? innerException) + : base(message ?? "The program executed an instruction that was thought to be unreachable.", innerException) + { + } +} +#endif diff --git a/src/Sentry/Experimental/ValueTypePair.cs b/src/Sentry/Experimental/ValueTypePair.cs new file mode 100644 index 0000000000..f1d41a9a15 --- /dev/null +++ b/src/Sentry/Experimental/ValueTypePair.cs @@ -0,0 +1,26 @@ +using Sentry.Extensibility; + +namespace Sentry.Experimental; + +//TODO: remove? perhaps a simple System.ValueTuple`2 suffices +internal readonly struct ValueTypePair : ISentryJsonSerializable +{ + public ValueTypePair(object value, string type) + { + Value = value.ToString()!; + Type = type; + } + + public string Value { get; } + public string Type { get; } + + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + + writer.WriteString("value", Value); + writer.WriteString("type", Type); + + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/Infrastructure/DiagnosticId.cs b/src/Sentry/Infrastructure/DiagnosticId.cs index 92703ddc87..b85f489414 100644 --- a/src/Sentry/Infrastructure/DiagnosticId.cs +++ b/src/Sentry/Infrastructure/DiagnosticId.cs @@ -8,4 +8,13 @@ internal static class DiagnosticId /// internal const string ExperimentalFeature = "SENTRY0001"; #endif + + //TODO: QUESTION: Should we re-use the above for all [Experimental] features or have one ID per experimental feature? + internal const string ExperimentalSentryLogs = "SENTRY0002"; +} + +//TODO: not sure about this type name +internal static class UrlFormats +{ + internal const string ExperimentalSentryLogs = "https://github.com/getsentry/sentry-dotnet/issues/4132"; } diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index b62dc82c98..0fb07b47d2 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -1,3 +1,4 @@ +using Sentry.Experimental; using Sentry.Extensibility; using Sentry.Infrastructure; using Sentry.Internal; @@ -445,6 +446,19 @@ internal static Envelope FromClientReport(ClientReport clientReport) return new Envelope(header, items); } + [Experimental(DiagnosticId.ExperimentalSentryLogs)] + internal static Envelope FromLog(SentryLog log) + { + var header = DefaultHeader; + + var items = new[] + { + EnvelopeItem.FromLog(log) + }; + + return new Envelope(header, items); + } + private static async Task> DeserializeHeaderAsync( Stream stream, CancellationToken cancellationToken = default) diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 7c721db581..a73f9e4ab8 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -1,4 +1,6 @@ +using Sentry.Experimental; using Sentry.Extensibility; +using Sentry.Infrastructure; using Sentry.Internal; using Sentry.Internal.Extensions; using Sentry.Protocol.Metrics; @@ -24,6 +26,7 @@ public sealed class EnvelopeItem : ISerializable, IDisposable internal const string TypeValueProfile = "profile"; internal const string TypeValueMetric = "statsd"; internal const string TypeValueCodeLocations = "metric_meta"; + internal const string TypeValueLog = "log"; private const string LengthKey = "length"; private const string FileNameKey = "filename"; @@ -370,6 +373,19 @@ internal static EnvelopeItem FromClientReport(ClientReport report) return new EnvelopeItem(header, new JsonSerializable(report)); } + [Experimental(DiagnosticId.ExperimentalSentryLogs)] + internal static EnvelopeItem FromLog(SentryLog log) + { + var header = new Dictionary(3, StringComparer.Ordinal) + { + [TypeKey] = TypeValueLog, + ["item_count"] = 1, + ["content_type"] = "application/vnd.sentry.items.log+json", + }; + + return new EnvelopeItem(header, new JsonSerializable(log)); + } + private static async Task> DeserializeHeaderAsync( Stream stream, CancellationToken cancellationToken = default) diff --git a/src/Sentry/SentryLogger.cs b/src/Sentry/SentryLogger.cs new file mode 100644 index 0000000000..9595f06733 --- /dev/null +++ b/src/Sentry/SentryLogger.cs @@ -0,0 +1,68 @@ +using Sentry.Experimental; +using Sentry.Infrastructure; +using Sentry.Protocol.Envelopes; + +//TODO: add XML docs +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Sentry; + +/// +/// Creates and sends logs to Sentry. +/// +[Experimental(DiagnosticId.ExperimentalSentryLogs, UrlFormat = UrlFormats.ExperimentalSentryLogs)] +public sealed class SentryLogger +{ + //TODO: QUESTION: Trace vs LogTrace + // Trace() is from the Sentry Logs feature specs. LogTrace() would be more .NET idiomatic + public void Trace(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentrySeverity.Trace, template, parameters, configureLog); + } + + //TODO: QUESTION: parameter name "template" vs "format" + // "template" from the "sentry.message.template" attributes of the envelope + // "format" as in System.String.Format to be more idiomatic + public void Debug(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentrySeverity.Debug, template, parameters, configureLog); + } + + public void Info(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentrySeverity.Info, template, parameters, configureLog); + } + + //TODO: QUESTION: Warn vs Warning + // Warn is from the Sentry Logs feature specs. Warning would be more .NET idiomatic + public void Warn(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentrySeverity.Warn, template, parameters, configureLog); + } + + public void Error(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentrySeverity.Error, template, parameters, configureLog); + } + + public void Fatal(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentrySeverity.Fatal, template, parameters, configureLog); + } + + //TODO: consider ReadOnlySpan for TFMs where Span is available + // or: utilize a custom [InterpolatedStringHandler] for modern TFMs + // with which we may not be able to enforce on compile-time to only support string, boolean, integer, double + // but we could have an Analyzer for that, indicating that Sentry does not support other types if used in the interpolated string + // or: utilize a SourceGen, similar to the Microsoft.Extensions.Logging [LoggerMessage] + // with which we could enforce on compile-time to only support string, boolean, integer, double + private static void CaptureLog(SentrySeverity level, string template, object[]? parameters, Action? configureLog) + { + var message = string.Format(template, parameters ?? []); + SentryLog log = new(level, message); + configureLog?.Invoke(log); + + var hub = SentrySdk.CurrentHub; + _ = hub.CaptureEnvelope(Envelope.FromLog(log)); + } +} diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index 401a0fa6f0..cabb8334a0 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -279,6 +279,15 @@ public void Dispose() /// public static bool IsEnabled { [DebuggerStepThrough] get => CurrentHub.IsEnabled; } + /// + /// Creates and sends logs to Sentry. + /// + //TODO: add to IHub or ISentryClient + // adding to interfaces is breaking, perhaps via a DIM but what about netstandard2.0 runtimes + // or are these interfaces intended to be extended as user code is not meant to implement them + [Experimental(DiagnosticId.ExperimentalSentryLogs, UrlFormat = UrlFormats.ExperimentalSentryLogs)] + public static SentryLogger Logger { get; } = new SentryLogger(); + /// /// Creates a new scope that will terminate when disposed. /// diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.verify.cs b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.verify.cs index b9f4250999..afc7ed5a5d 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.verify.cs +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.verify.cs @@ -155,7 +155,7 @@ public void ShouldIgnoreAllErrorAndExceptionIds() foreach (var field in eventIds) { var eventId = (EventId)field.GetValue(null)!; - var isEfExceptionMessage = SentryLogger.IsEfExceptionMessage(eventId); + var isEfExceptionMessage = Sentry.Extensions.Logging.SentryLogger.IsEfExceptionMessage(eventId); var name = field.Name; if (name.EndsWith("Exception") || name.EndsWith("Error") ||