From a4b5c33ae3c7dc78ef690fedd69aa3ecca527e2a Mon Sep 17 00:00:00 2001 From: Russ Cam Date: Mon, 26 Oct 2020 17:53:16 +1000 Subject: [PATCH] Introduce annotations on Agent types (#990) This commit introduces annotations to agent types that detail the APM server spec constraints. - Rename to TruncateJsonConverter - Use MaxLengthAttribute to specify maxLength constraint - Rename SanitizationAttribute to SanitizeAttribute - Move Truncate to StringExtensions - Add RequiredAttribute - Move SpanCount into Api namespace and add as a property on ITransaction. --- src/Elastic.Apm/Api/CapturedException.cs | 6 +-- .../Api/Constraints/MaxLengthAttribute.cs | 34 +++++++++++++++ .../Api/Constraints/RequiredAttribute.cs | 15 +++++++ .../Constraints/SanitizeAttribute.cs} | 29 ++++++------- src/Elastic.Apm/Api/Container.cs | 6 +-- src/Elastic.Apm/Api/Database.cs | 5 +-- src/Elastic.Apm/Api/Destination.cs | 11 +++-- src/Elastic.Apm/Api/Http.cs | 3 +- src/Elastic.Apm/Api/IExecutionSegment.cs | 4 ++ src/Elastic.Apm/Api/IMetricSet.cs | 2 + src/Elastic.Apm/Api/ITransaction.cs | 9 ++++ .../Api/Kubernetes/KubernetesMetadata.cs | 5 +-- src/Elastic.Apm/Api/Kubernetes/Node.cs | 5 +-- src/Elastic.Apm/Api/Kubernetes/Pod.cs | 7 ++-- src/Elastic.Apm/Api/Node.cs | 4 +- src/Elastic.Apm/Api/Request.cs | 23 ++++++----- src/Elastic.Apm/Api/Service.cs | 23 +++++------ src/Elastic.Apm/{Model => Api}/SpanCount.cs | 20 ++++++--- src/Elastic.Apm/Api/System.cs | 5 ++- src/Elastic.Apm/Api/User.cs | 8 ++-- src/Elastic.Apm/Helpers/StringExtensions.cs | 14 +++++++ src/Elastic.Apm/Model/Error.cs | 14 +++---- src/Elastic.Apm/Model/Span.cs | 18 ++++---- src/Elastic.Apm/Model/Transaction.cs | 14 +++---- .../ElasticApmContractResolver.cs | 25 ++++++++--- .../HeaderDictionarySanitizerConverter.cs | 2 +- .../Serialization/LabelsJsonConverter.cs | 5 ++- .../Serialization/SerializationUtils.cs | 24 ----------- ...nConverter.cs => TruncateJsonConverter.cs} | 12 ++++-- .../SpanCountDto.cs | 1 + test/Elastic.Apm.Tests/SerializationTests.cs | 41 +++++++++++-------- 31 files changed, 242 insertions(+), 152 deletions(-) create mode 100644 src/Elastic.Apm/Api/Constraints/MaxLengthAttribute.cs create mode 100644 src/Elastic.Apm/Api/Constraints/RequiredAttribute.cs rename src/Elastic.Apm/{Report/Serialization/SanitizationAttribute.cs => Api/Constraints/SanitizeAttribute.cs} (74%) rename src/Elastic.Apm/{Model => Api}/SpanCount.cs (52%) delete mode 100644 src/Elastic.Apm/Report/Serialization/SerializationUtils.cs rename src/Elastic.Apm/Report/Serialization/{TrimmedStringJsonConverter.cs => TruncateJsonConverter.cs} (66%) diff --git a/src/Elastic.Apm/Api/CapturedException.cs b/src/Elastic.Apm/Api/CapturedException.cs index ab7110235..96484f32f 100644 --- a/src/Elastic.Apm/Api/CapturedException.cs +++ b/src/Elastic.Apm/Api/CapturedException.cs @@ -3,15 +3,15 @@ // See the LICENSE file in the project root for more information using System.Collections.Generic; +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Helpers; -using Elastic.Apm.Report.Serialization; using Newtonsoft.Json; namespace Elastic.Apm.Api { public class CapturedException { - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Code { get; set; } public bool Handled { get; set; } @@ -21,7 +21,7 @@ public class CapturedException [JsonProperty("stacktrace")] public List StackTrace { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Type { get; set; } public override string ToString() => new ToStringBuilder(nameof(CapturedException)) diff --git a/src/Elastic.Apm/Api/Constraints/MaxLengthAttribute.cs b/src/Elastic.Apm/Api/Constraints/MaxLengthAttribute.cs new file mode 100644 index 000000000..fae0f00c6 --- /dev/null +++ b/src/Elastic.Apm/Api/Constraints/MaxLengthAttribute.cs @@ -0,0 +1,34 @@ +// Licensed to Elasticsearch B.V under +// one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; + +namespace Elastic.Apm.Api.Constraints +{ + /// + /// Specifies the maximum length of string data allowed in a property, based on the APM server specification. + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class MaxLengthAttribute : Attribute + { + /// + /// The maximum length. + /// + public int Length { get; } + + /// + /// Initializes a new instance of + /// with a maximum length of + /// + public MaxLengthAttribute() : this(Consts.PropertyMaxLength) + { + } + + /// + /// Initializes a new instance of with a given maximum length + /// + public MaxLengthAttribute(int length) => Length = length; + } +} diff --git a/src/Elastic.Apm/Api/Constraints/RequiredAttribute.cs b/src/Elastic.Apm/Api/Constraints/RequiredAttribute.cs new file mode 100644 index 000000000..54f2ddb75 --- /dev/null +++ b/src/Elastic.Apm/Api/Constraints/RequiredAttribute.cs @@ -0,0 +1,15 @@ +// Licensed to Elasticsearch B.V under +// one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; + +namespace Elastic.Apm.Api.Constraints +{ + /// + /// Specifies that a data field value is required. + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class RequiredAttribute : Attribute { } +} diff --git a/src/Elastic.Apm/Report/Serialization/SanitizationAttribute.cs b/src/Elastic.Apm/Api/Constraints/SanitizeAttribute.cs similarity index 74% rename from src/Elastic.Apm/Report/Serialization/SanitizationAttribute.cs rename to src/Elastic.Apm/Api/Constraints/SanitizeAttribute.cs index fe7c0c1ee..027391797 100644 --- a/src/Elastic.Apm/Report/Serialization/SanitizationAttribute.cs +++ b/src/Elastic.Apm/Api/Constraints/SanitizeAttribute.cs @@ -1,14 +1,15 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using System; - -namespace Elastic.Apm.Report.Serialization -{ - /// - /// An attribute to mark fields for sanitization. This attribute is known to and it applies a Converter - /// to sanitize field(s) accordingly. - /// - internal class SanitizationAttribute : Attribute { } -} +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using Elastic.Apm.Report.Serialization; + +namespace Elastic.Apm.Api.Constraints +{ + /// + /// An attribute to mark fields for sanitization. This attribute is known to and it applies a Converter + /// to sanitize field(s) accordingly. + /// + internal sealed class SanitizeAttribute : Attribute { } +} diff --git a/src/Elastic.Apm/Api/Container.cs b/src/Elastic.Apm/Api/Container.cs index 3965806eb..0f2b26ac6 100644 --- a/src/Elastic.Apm/Api/Container.cs +++ b/src/Elastic.Apm/Api/Container.cs @@ -2,14 +2,14 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using Elastic.Apm.Report.Serialization; -using Newtonsoft.Json; +using Elastic.Apm.Api.Constraints; namespace Elastic.Apm.Api { public class Container { - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [Required] + [MaxLength] public string Id { get; set; } } } diff --git a/src/Elastic.Apm/Api/Database.cs b/src/Elastic.Apm/Api/Database.cs index 1e117db29..355e87f94 100644 --- a/src/Elastic.Apm/Api/Database.cs +++ b/src/Elastic.Apm/Api/Database.cs @@ -2,8 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using Elastic.Apm.Report.Serialization; -using Newtonsoft.Json; +using Elastic.Apm.Api.Constraints; namespace Elastic.Apm.Api { @@ -18,7 +17,7 @@ public class Database public string Instance { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter), 10_000)] + [MaxLength(10_000)] public string Statement { get; set; } public string Type { get; set; } diff --git a/src/Elastic.Apm/Api/Destination.cs b/src/Elastic.Apm/Api/Destination.cs index dab128346..f0a4bb8a5 100644 --- a/src/Elastic.Apm/Api/Destination.cs +++ b/src/Elastic.Apm/Api/Destination.cs @@ -2,8 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using Elastic.Apm.Report.Serialization; -using Newtonsoft.Json; +using Elastic.Apm.Api.Constraints; namespace Elastic.Apm.Api { @@ -24,7 +23,7 @@ public class Destination /// (for example or ). /// Explicitly setting this property to null will prohibit this automatic deduction. /// - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Address { get => _address.Value; @@ -72,20 +71,20 @@ public class DestinationService /// /// Identifier for the destination service (e.g. 'http://elastic.co', 'elasticsearch', 'rabbitmq')" /// - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Name { get; set; } /// /// Identifier for the destination service resource being operated on (e.g. 'http://elastic.co:80', 'elasticsearch', /// 'rabbitmq/queue_name') /// - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Resource { get; set; } /// /// Type of the destination service (e.g. 'db', 'elasticsearch'). Should typically be the same as span.type. /// - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Type { get; set; } } diff --git a/src/Elastic.Apm/Api/Http.cs b/src/Elastic.Apm/Api/Http.cs index b83c98171..b239cd44c 100644 --- a/src/Elastic.Apm/Api/Http.cs +++ b/src/Elastic.Apm/Api/Http.cs @@ -4,6 +4,7 @@ using System; using System.Net.Http; +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Report.Serialization; using Newtonsoft.Json; @@ -18,7 +19,7 @@ public class Http private Uri _originalUrl; private string _url; - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Method { get; set; } /// diff --git a/src/Elastic.Apm/Api/IExecutionSegment.cs b/src/Elastic.Apm/Api/IExecutionSegment.cs index 91186fc72..fb7867e3c 100644 --- a/src/Elastic.Apm/Api/IExecutionSegment.cs +++ b/src/Elastic.Apm/Api/IExecutionSegment.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; +using Elastic.Apm.Api.Constraints; namespace Elastic.Apm.Api { @@ -21,11 +22,13 @@ public interface IExecutionSegment /// is automatically calculated when is called. /// /// The duration. + [Required] double? Duration { get; set; } /// /// The id of the item. /// + [Required] string Id { get; } /// @@ -65,6 +68,7 @@ public interface IExecutionSegment /// /// Hex encoded 128 random bits ID of the correlated trace. /// + [Required] string TraceId { get; } /// diff --git a/src/Elastic.Apm/Api/IMetricSet.cs b/src/Elastic.Apm/Api/IMetricSet.cs index c2d60b230..c1a35a5a3 100644 --- a/src/Elastic.Apm/Api/IMetricSet.cs +++ b/src/Elastic.Apm/Api/IMetricSet.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.Collections.Generic; +using Elastic.Apm.Api.Constraints; namespace Elastic.Apm.Api { @@ -14,6 +15,7 @@ public interface IMetricSet /// /// List of captured metrics as key - value pairs /// + [Required] IEnumerable Samples { get; set; } // TODO: Rename to Timestamp for consistency? diff --git a/src/Elastic.Apm/Api/ITransaction.cs b/src/Elastic.Apm/Api/ITransaction.cs index b7a8a1f37..328c20609 100644 --- a/src/Elastic.Apm/Api/ITransaction.cs +++ b/src/Elastic.Apm/Api/ITransaction.cs @@ -3,6 +3,8 @@ // See the LICENSE file in the project root for more information using System.Collections.Generic; +using Elastic.Apm.Api.Constraints; +using Elastic.Apm.Model; namespace Elastic.Apm.Api { @@ -35,8 +37,15 @@ public interface ITransaction : IExecutionSegment /// The type of the transaction. /// Example: 'request' /// + [Required] string Type { get; set; } + /// + /// The total number of correlated spans, including started and dropped + /// + [Required] + SpanCount SpanCount { get; } + /// /// If the transaction does not have a ParentId yet, calling this method generates a new ID, sets it as the ParentId of /// this transaction, diff --git a/src/Elastic.Apm/Api/Kubernetes/KubernetesMetadata.cs b/src/Elastic.Apm/Api/Kubernetes/KubernetesMetadata.cs index 62f0f5e33..316fd3d38 100644 --- a/src/Elastic.Apm/Api/Kubernetes/KubernetesMetadata.cs +++ b/src/Elastic.Apm/Api/Kubernetes/KubernetesMetadata.cs @@ -2,15 +2,14 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Helpers; -using Elastic.Apm.Report.Serialization; -using Newtonsoft.Json; namespace Elastic.Apm.Api.Kubernetes { public class KubernetesMetadata { - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Namespace { get; set; } public Node Node { get; set; } diff --git a/src/Elastic.Apm/Api/Kubernetes/Node.cs b/src/Elastic.Apm/Api/Kubernetes/Node.cs index 24a9285e6..df1e57af4 100644 --- a/src/Elastic.Apm/Api/Kubernetes/Node.cs +++ b/src/Elastic.Apm/Api/Kubernetes/Node.cs @@ -2,15 +2,14 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Helpers; -using Elastic.Apm.Report.Serialization; -using Newtonsoft.Json; namespace Elastic.Apm.Api.Kubernetes { public class Node { - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Name { get; set; } public override string ToString() => new ToStringBuilder(nameof(Node)) { { nameof(Name), Name } }.ToString(); diff --git a/src/Elastic.Apm/Api/Kubernetes/Pod.cs b/src/Elastic.Apm/Api/Kubernetes/Pod.cs index e34b50441..ccca159e7 100644 --- a/src/Elastic.Apm/Api/Kubernetes/Pod.cs +++ b/src/Elastic.Apm/Api/Kubernetes/Pod.cs @@ -2,18 +2,17 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Helpers; -using Elastic.Apm.Report.Serialization; -using Newtonsoft.Json; namespace Elastic.Apm.Api.Kubernetes { public class Pod { - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Name { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Uid { get; set; } public override string ToString() => new ToStringBuilder(nameof(Pod)) { { nameof(Name), Name }, { nameof(Uid), Uid } }.ToString(); diff --git a/src/Elastic.Apm/Api/Node.cs b/src/Elastic.Apm/Api/Node.cs index 3ee95b0d4..8774a7efc 100644 --- a/src/Elastic.Apm/Api/Node.cs +++ b/src/Elastic.Apm/Api/Node.cs @@ -2,8 +2,8 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Helpers; -using Elastic.Apm.Report.Serialization; using Newtonsoft.Json; namespace Elastic.Apm.Api @@ -11,7 +11,7 @@ namespace Elastic.Apm.Api public class Node { [JsonProperty("configured_name")] - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string ConfiguredName { get; set; } public override string ToString() => new ToStringBuilder(nameof(Node)) { { nameof(ConfiguredName), ConfiguredName } }.ToString(); diff --git a/src/Elastic.Apm/Api/Request.cs b/src/Elastic.Apm/Api/Request.cs index 6c942a509..75fb7f8b3 100644 --- a/src/Elastic.Apm/Api/Request.cs +++ b/src/Elastic.Apm/Api/Request.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.Collections.Generic; +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Helpers; using Elastic.Apm.Report.Serialization; using Newtonsoft.Json; @@ -18,21 +19,23 @@ public class Request { public Request(string method, Url url) => (Method, Url) = (method, url); - [SanitizationAttribute] + [Sanitize] public object Body { get; set; } - [SanitizationAttribute] + [Sanitize] public Dictionary Headers { get; set; } [JsonProperty("http_version")] - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string HttpVersion { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] + [Required] public string Method { get; set; } public Socket Socket { get; set; } + [Required] public Url Url { get; set; } public override string ToString() => @@ -55,25 +58,25 @@ public class Url private string _full; private string _raw; - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Full { get => _full; set => _full = Http.Sanitize(value, out var newValue) ? newValue : value; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] [JsonProperty("hostname")] public string HostName { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] [JsonProperty("pathname")] public string PathName { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Protocol { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Raw { get => _raw; @@ -84,7 +87,7 @@ public string Raw /// The search describes the query string of the request. /// It is expected to have values delimited by ampersands. /// - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] [JsonProperty("search")] public string Search { get; set; } diff --git a/src/Elastic.Apm/Api/Service.cs b/src/Elastic.Apm/Api/Service.cs index f08d748a3..599497c3b 100644 --- a/src/Elastic.Apm/Api/Service.cs +++ b/src/Elastic.Apm/Api/Service.cs @@ -4,11 +4,10 @@ using System; using System.Reflection; +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Config; using Elastic.Apm.Helpers; using Elastic.Apm.Logging; -using Elastic.Apm.Report.Serialization; -using Newtonsoft.Json; namespace Elastic.Apm.Api { @@ -18,20 +17,20 @@ private Service() { } public AgentC Agent { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Environment { get; set; } public Framework Framework { get; set; } public Language Language { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Name { get; set; } public Node Node { get; set; } public Runtime Runtime { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Version { get; set; } public override string ToString() => new ToStringBuilder(nameof(Service)) @@ -77,10 +76,10 @@ internal static Service GetDefaultService(IConfigurationReader configurationRead public class AgentC { - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Name { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Version { get; set; } public override string ToString() => @@ -90,10 +89,10 @@ public override string ToString() => public class Framework { - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Name { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Version { get; set; } public override string ToString() => @@ -102,7 +101,7 @@ public override string ToString() => public class Language { - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Name { get; set; } public override string ToString() => new ToStringBuilder(nameof(Language)) { { nameof(Name), Name } }.ToString(); @@ -118,10 +117,10 @@ public class Runtime internal const string DotNetFullFrameworkName = ".NET Framework"; internal const string MonoName = "Mono"; - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Name { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Version { get; set; } public override string ToString() => new ToStringBuilder(nameof(Runtime)) { { nameof(Name), Name }, { nameof(Version), Version } }.ToString(); diff --git a/src/Elastic.Apm/Model/SpanCount.cs b/src/Elastic.Apm/Api/SpanCount.cs similarity index 52% rename from src/Elastic.Apm/Model/SpanCount.cs rename to src/Elastic.Apm/Api/SpanCount.cs index cffe2fb72..af53b4167 100644 --- a/src/Elastic.Apm/Model/SpanCount.cs +++ b/src/Elastic.Apm/Api/SpanCount.cs @@ -3,23 +3,33 @@ // See the LICENSE file in the project root for more information using System.Threading; +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Helpers; -namespace Elastic.Apm.Model +namespace Elastic.Apm.Api { - internal class SpanCount + public class SpanCount { private int _dropped; private int _started; private int _total; + + /// + /// Number of spans that have been dropped by the agent recording the transaction + /// public int Dropped => _dropped; + + /// + /// Number of correlated spans that are recorded + /// + [Required] public int Started => _started; - public void IncrementStarted() => Interlocked.Increment(ref _started); + internal void IncrementStarted() => Interlocked.Increment(ref _started); - public void IncrementDropped() => Interlocked.Increment(ref _dropped); + internal void IncrementDropped() => Interlocked.Increment(ref _dropped); - public int IncrementTotal() => Interlocked.Increment(ref _total); + internal int IncrementTotal() => Interlocked.Increment(ref _total); public override string ToString() => new ToStringBuilder(nameof(SpanCount)) { { nameof(Started), Started }, { nameof(Dropped), Dropped } }.ToString(); diff --git a/src/Elastic.Apm/Api/System.cs b/src/Elastic.Apm/Api/System.cs index 8d17e86d2..0f93e2af3 100644 --- a/src/Elastic.Apm/Api/System.cs +++ b/src/Elastic.Apm/Api/System.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Api.Kubernetes; using Elastic.Apm.Helpers; using Elastic.Apm.Report.Serialization; @@ -16,12 +17,12 @@ public class System public Container Container { get; set; } + [MaxLength] [JsonProperty("detected_hostname")] - [JsonConverter(typeof(TrimmedStringJsonConverter))] public string DetectedHostName { get; set; } + [MaxLength] [JsonProperty("hostname")] - [JsonConverter(typeof(TrimmedStringJsonConverter))] public string HostName { get => _hostName ??= DetectedHostName; diff --git a/src/Elastic.Apm/Api/User.cs b/src/Elastic.Apm/Api/User.cs index 2a3903bbb..8b87d3c1a 100644 --- a/src/Elastic.Apm/Api/User.cs +++ b/src/Elastic.Apm/Api/User.cs @@ -2,21 +2,21 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Helpers; -using Elastic.Apm.Report.Serialization; using Newtonsoft.Json; namespace Elastic.Apm.Api { public class User { - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Email { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Id { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] [JsonProperty("username")] public string UserName { get; set; } diff --git a/src/Elastic.Apm/Helpers/StringExtensions.cs b/src/Elastic.Apm/Helpers/StringExtensions.cs index 18765b7fc..e8f56b191 100644 --- a/src/Elastic.Apm/Helpers/StringExtensions.cs +++ b/src/Elastic.Apm/Helpers/StringExtensions.cs @@ -32,5 +32,19 @@ internal static bool ContainsOrdinalIgnoreCase(this string thisObj, string subSt thisObj.IndexOf(subStr, StringComparison.OrdinalIgnoreCase) >= 0; internal static string ToLog(this string thisObj) => "`" + thisObj + "'"; + + /// + /// Truncates the string to a given length, if longer than the length + /// + internal static string Truncate(this string input, int length = Consts.PropertyMaxLength) + { + input.ThrowIfArgumentNull(nameof(input)); + + if (input.Length <= length) return input; + + if (length <= 5) return input.Substring(0, length); + + return $"{input.Substring(0, length - 3)}..."; + } } } diff --git a/src/Elastic.Apm/Model/Error.cs b/src/Elastic.Apm/Model/Error.cs index 83e9d8a85..eb1cce509 100644 --- a/src/Elastic.Apm/Model/Error.cs +++ b/src/Elastic.Apm/Model/Error.cs @@ -3,9 +3,9 @@ // See the LICENSE file in the project root for more information using Elastic.Apm.Api; +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Helpers; using Elastic.Apm.Logging; -using Elastic.Apm.Report.Serialization; using Newtonsoft.Json; namespace Elastic.Apm.Model @@ -53,15 +53,15 @@ private Error(string culprit, CapturedException capturedException, string id, st /// public Context Context { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Culprit { get; set; } public CapturedException Exception { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Id { get; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] [JsonProperty("parent_id")] public string ParentId { get; set; } @@ -70,13 +70,13 @@ private Error(string culprit, CapturedException capturedException, string id, st /// public long Timestamp { get; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] [JsonProperty("trace_id")] public string TraceId { get; set; } public TransactionData Transaction { get; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] [JsonProperty("transaction_id")] public string TransactionId { get; set; } @@ -105,7 +105,7 @@ internal TransactionData(bool isSampled, string type) [JsonProperty("sampled")] public bool IsSampled { get; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Type { get; } public override string ToString() => diff --git a/src/Elastic.Apm/Model/Span.cs b/src/Elastic.Apm/Model/Span.cs index 31cfe6796..a1d6eaf03 100644 --- a/src/Elastic.Apm/Model/Span.cs +++ b/src/Elastic.Apm/Model/Span.cs @@ -7,11 +7,11 @@ using System.Diagnostics; using System.Threading.Tasks; using Elastic.Apm.Api; +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Config; using Elastic.Apm.Helpers; using Elastic.Apm.Logging; using Elastic.Apm.Report; -using Elastic.Apm.Report.Serialization; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -104,7 +104,7 @@ public Span( private bool _isEnded; - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Action { get; set; } [JsonIgnore] @@ -125,7 +125,7 @@ public Span( /// The duration. public double? Duration { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Id { get; set; } internal InstrumentationFlag InstrumentationFlag { get; } @@ -136,7 +136,7 @@ public Span( [JsonIgnore] public Dictionary Labels => Context.Labels; - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Name { get; set; } /// @@ -155,7 +155,7 @@ public Span( ShouldBeSentToApmServer ? Id : TransactionId, IsSampled); - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] [JsonProperty("parent_id")] public string ParentId { get; set; } @@ -165,7 +165,7 @@ public Span( [JsonProperty("stacktrace")] public List StackTrace { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Subtype { get; set; } //public decimal Start { get; set; } @@ -175,15 +175,15 @@ public Span( /// public long Timestamp { get; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] [JsonProperty("trace_id")] public string TraceId { get; set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] [JsonProperty("transaction_id")] public string TransactionId => _enclosingTransaction.Id; - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Type { get; set; } /// diff --git a/src/Elastic.Apm/Model/Transaction.cs b/src/Elastic.Apm/Model/Transaction.cs index eb7d01e12..99c9dcd1c 100644 --- a/src/Elastic.Apm/Model/Transaction.cs +++ b/src/Elastic.Apm/Model/Transaction.cs @@ -7,11 +7,11 @@ using System.Diagnostics; using System.Threading.Tasks; using Elastic.Apm.Api; +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Config; using Elastic.Apm.Helpers; using Elastic.Apm.Logging; using Elastic.Apm.Report; -using Elastic.Apm.Report.Serialization; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -223,7 +223,7 @@ void StartActivity() [JsonIgnore] internal bool HasCustomName { get; private set; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Id { get; } [JsonIgnore] @@ -235,7 +235,7 @@ void StartActivity() [JsonIgnore] public Dictionary Labels => Context.Labels; - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Name { get => _name; @@ -258,7 +258,7 @@ public string Name [JsonIgnore] public DistributedTracingData OutgoingDistributedTracingData => new DistributedTracingData(TraceId, Id, IsSampled, _traceState); - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] [JsonProperty("parent_id")] public string ParentId { get; set; } @@ -268,7 +268,7 @@ public string Name /// This is typically the HTTP status code, or e.g. "success" for a background task. /// /// The result. - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Result { get; set; } internal Service Service; @@ -281,11 +281,11 @@ public string Name /// public long Timestamp { get; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] [JsonProperty("trace_id")] public string TraceId { get; } - [JsonConverter(typeof(TrimmedStringJsonConverter))] + [MaxLength] public string Type { get; set; } public string EnsureParentId() diff --git a/src/Elastic.Apm/Report/Serialization/ElasticApmContractResolver.cs b/src/Elastic.Apm/Report/Serialization/ElasticApmContractResolver.cs index 2784dcf1c..9bbb63d50 100644 --- a/src/Elastic.Apm/Report/Serialization/ElasticApmContractResolver.cs +++ b/src/Elastic.Apm/Report/Serialization/ElasticApmContractResolver.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Reflection; using Elastic.Apm.Api; +using Elastic.Apm.Api.Constraints; using Elastic.Apm.Config; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -16,6 +17,9 @@ internal class ElasticApmContractResolver : DefaultContractResolver { private readonly HeaderDictionarySanitizerConverter _headerDictionarySanitizerConverter; + private readonly TruncateJsonConverter _defaultTruncateJsonConverter = + new TruncateJsonConverter(Consts.PropertyMaxLength); + public ElasticApmContractResolver(IConfigurationReader configurationReader) { NamingStrategy = new CamelCaseNamingStrategy { ProcessDictionaryKeys = true, OverrideSpecifiedNames = true }; @@ -26,11 +30,22 @@ protected override JsonProperty CreateProperty(MemberInfo member, MemberSerializ { var property = base.CreateProperty(member, memberSerialization); - if (member.MemberType != MemberTypes.Property || !(member is PropertyInfo propInfo) - || propInfo.CustomAttributes.All(n => n.AttributeType != typeof(SanitizationAttribute))) return property; - - if (propInfo.PropertyType == typeof(Dictionary)) - property.Converter = _headerDictionarySanitizerConverter; + if (property.PropertyType == typeof(string)) + { + var maxLengthAttribute = member.GetCustomAttribute(); + if (maxLengthAttribute != null) + { + property.Converter = maxLengthAttribute.Length == Consts.PropertyMaxLength + ? _defaultTruncateJsonConverter + : new TruncateJsonConverter(maxLengthAttribute.Length); + } + } + else if (property.PropertyType == typeof(Dictionary)) + { + var sanitizeAttribute = member.GetCustomAttribute(); + if (sanitizeAttribute != null) + property.Converter = _headerDictionarySanitizerConverter; + } return property; } diff --git a/src/Elastic.Apm/Report/Serialization/HeaderDictionarySanitizerConverter.cs b/src/Elastic.Apm/Report/Serialization/HeaderDictionarySanitizerConverter.cs index ae9f5e292..93517681c 100644 --- a/src/Elastic.Apm/Report/Serialization/HeaderDictionarySanitizerConverter.cs +++ b/src/Elastic.Apm/Report/Serialization/HeaderDictionarySanitizerConverter.cs @@ -25,7 +25,7 @@ public override void WriteJson(JsonWriter writer, Dictionary hea writer.WriteStartObject(); foreach (var keyValue in headers) { - writer.WritePropertyName(SerializationUtils.TrimToPropertyMaxLength(keyValue.Key)); + writer.WritePropertyName(keyValue.Key.Truncate()); if (keyValue.Value != null) { diff --git a/src/Elastic.Apm/Report/Serialization/LabelsJsonConverter.cs b/src/Elastic.Apm/Report/Serialization/LabelsJsonConverter.cs index 9a7099762..3956b13d1 100644 --- a/src/Elastic.Apm/Report/Serialization/LabelsJsonConverter.cs +++ b/src/Elastic.Apm/Report/Serialization/LabelsJsonConverter.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using Elastic.Apm.Helpers; using Newtonsoft.Json; namespace Elastic.Apm.Report.Serialization @@ -16,13 +17,13 @@ public override void WriteJson(JsonWriter writer, Dictionary lab foreach (var keyValue in labels) { // Labels are trimmed and also de dotted in order to satisfy the Intake API - writer.WritePropertyName(SerializationUtils.TrimToPropertyMaxLength(keyValue.Key) + writer.WritePropertyName(keyValue.Key.Truncate() .Replace('.', '_') .Replace('*', '_') .Replace('"', '_')); if (keyValue.Value != null) - writer.WriteValue(SerializationUtils.TrimToPropertyMaxLength(keyValue.Value)); + writer.WriteValue(keyValue.Value.Truncate()); else writer.WriteNull(); } diff --git a/src/Elastic.Apm/Report/Serialization/SerializationUtils.cs b/src/Elastic.Apm/Report/Serialization/SerializationUtils.cs deleted file mode 100644 index b6372cb9b..000000000 --- a/src/Elastic.Apm/Report/Serialization/SerializationUtils.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using Elastic.Apm.Helpers; - -namespace Elastic.Apm.Report.Serialization -{ - internal static class SerializationUtils - { - internal static string TrimToLength(string input, int maxLength) - { - input.ThrowIfArgumentNull(nameof(input)); - - if (input.Length <= maxLength) return input; - - if (maxLength <= 5) return input.Substring(0, maxLength); - - return $"{input.Substring(0, maxLength - 3)}..."; - } - - internal static string TrimToPropertyMaxLength(string input) => TrimToLength(input, Consts.PropertyMaxLength); - } -} diff --git a/src/Elastic.Apm/Report/Serialization/TrimmedStringJsonConverter.cs b/src/Elastic.Apm/Report/Serialization/TruncateJsonConverter.cs similarity index 66% rename from src/Elastic.Apm/Report/Serialization/TrimmedStringJsonConverter.cs rename to src/Elastic.Apm/Report/Serialization/TruncateJsonConverter.cs index 58c77d44a..089473eff 100644 --- a/src/Elastic.Apm/Report/Serialization/TrimmedStringJsonConverter.cs +++ b/src/Elastic.Apm/Report/Serialization/TruncateJsonConverter.cs @@ -3,22 +3,26 @@ // See the LICENSE file in the project root for more information using System; +using Elastic.Apm.Helpers; using Newtonsoft.Json; namespace Elastic.Apm.Report.Serialization { - internal class TrimmedStringJsonConverter : JsonConverter + /// + /// Truncates a string to a given length + /// + internal class TruncateJsonConverter : JsonConverter { private readonly int _maxLength; // ReSharper disable once UnusedMember.Global - public TrimmedStringJsonConverter() : this(Consts.PropertyMaxLength) { } + public TruncateJsonConverter() : this(Consts.PropertyMaxLength) { } // ReSharper disable once MemberCanBePrivate.Global - public TrimmedStringJsonConverter(int maxLength) => _maxLength = maxLength; + public TruncateJsonConverter(int maxLength) => _maxLength = maxLength; public override void WriteJson(JsonWriter writer, string value, JsonSerializer serializer) => - writer.WriteValue(SerializationUtils.TrimToLength(value, _maxLength)); + writer.WriteValue(value.Truncate(_maxLength)); public override string ReadJson(JsonReader reader, Type objectType, string existingValue, bool hasExistingValue, JsonSerializer serializer) => reader.Value as string; diff --git a/test/Elastic.Apm.Tests.MockApmServer/SpanCountDto.cs b/test/Elastic.Apm.Tests.MockApmServer/SpanCountDto.cs index 92999a064..d04ae325e 100644 --- a/test/Elastic.Apm.Tests.MockApmServer/SpanCountDto.cs +++ b/test/Elastic.Apm.Tests.MockApmServer/SpanCountDto.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Elastic.Apm.Api; using Elastic.Apm.Helpers; using Elastic.Apm.Model; diff --git a/test/Elastic.Apm.Tests/SerializationTests.cs b/test/Elastic.Apm.Tests/SerializationTests.cs index 48c53c30d..f9706fbf1 100644 --- a/test/Elastic.Apm.Tests/SerializationTests.cs +++ b/test/Elastic.Apm.Tests/SerializationTests.cs @@ -21,6 +21,11 @@ namespace Elastic.Apm.Tests /// public class SerializationTests { + private readonly PayloadItemSerializer _payloadItemSerializer; + + public SerializationTests() => + _payloadItemSerializer = new PayloadItemSerializer(new MockConfigSnapshot()); + // ReSharper disable once MemberCanBePrivate.Global public static TheoryData SerializationUtilsTrimToPropertyMaxLengthVariantsToTest => new TheoryData { @@ -32,7 +37,7 @@ public class SerializationTests }; /// - /// Tests the . It serializes a transaction with Transaction.Name.Length > 1024. + /// Tests the . It serializes a transaction with Transaction.Name.Length > 1024. /// Makes sure that the Transaction.Name is truncated correctly. /// [Fact] @@ -101,8 +106,8 @@ public void LabelsTruncation() // In Intake API the property is still named `tags' // See https://github.com/elastic/apm-server/blob/6.5/docs/spec/context.json#L43 const string intakeApiLabelsPropertyName = "tags"; - Assert.Equal(Consts.PropertyMaxLength, deserializedContext[intakeApiLabelsPropertyName].Value()["foo"]?.Value()?.Length); - Assert.Equal("...", deserializedContext[intakeApiLabelsPropertyName].Value()["foo"].Value().Substring(1021, 3)); + Assert.Equal(Consts.PropertyMaxLength, deserializedContext[intakeApiLabelsPropertyName]["foo"].Value().Length); + Assert.Equal("...", deserializedContext[intakeApiLabelsPropertyName]["foo"].Value().Substring(1021, 3)); } /// @@ -126,8 +131,8 @@ public void LabelsTruncationSpanContext() // In Intake API the property is still named `tags' // See https://github.com/elastic/apm-server/blob/6.5/docs/spec/spans/common_span.json#L50 const string intakeApiLabelsPropertyName = "tags"; - Assert.Equal(Consts.PropertyMaxLength, deserializedContext[intakeApiLabelsPropertyName].Value()["foo"]?.Value()?.Length); - Assert.Equal("...", deserializedContext[intakeApiLabelsPropertyName].Value()["foo"].Value().Substring(1021, 3)); + Assert.Equal(Consts.PropertyMaxLength, deserializedContext[intakeApiLabelsPropertyName]["foo"].Value().Length); + Assert.Equal("...", deserializedContext[intakeApiLabelsPropertyName]["foo"].Value().Substring(1021, 3)); } /// @@ -177,12 +182,12 @@ public void DictionaryNoTruncateAttributeTest() dummyInstance.DictionaryProp["foo"] = str; var json = SerializePayloadItem(dummyInstance); - var deserializedDummyInstance = JsonConvert.DeserializeObject(json) as JObject; + var deserializedDummyInstance = JsonConvert.DeserializeObject(json); Assert.NotNull(deserializedDummyInstance); - Assert.Equal(str.Length, deserializedDummyInstance["dictionaryProp"].Value()["foo"]?.Value()?.Length); - Assert.Equal(str, deserializedDummyInstance["dictionaryProp"].Value()["foo"].Value()); + Assert.Equal(str.Length, deserializedDummyInstance["dictionaryProp"]["foo"].Value().Length); + Assert.Equal(str, deserializedDummyInstance["dictionaryProp"]["foo"].Value()); } /// @@ -259,13 +264,13 @@ public void TransactionContextShouldBeSerializedOnlyWhenSampled() nonSampledTransaction.Context.Request = sampledTransaction.Context.Request; var serializedSampledTransaction = SerializePayloadItem(sampledTransaction); - var deserializedSampledTransaction = JsonConvert.DeserializeObject(serializedSampledTransaction) as JObject; + var deserializedSampledTransaction = JsonConvert.DeserializeObject(serializedSampledTransaction); var serializedNonSampledTransaction = SerializePayloadItem(nonSampledTransaction); - var deserializedNonSampledTransaction = JsonConvert.DeserializeObject(serializedNonSampledTransaction) as JObject; + var deserializedNonSampledTransaction = JsonConvert.DeserializeObject(serializedNonSampledTransaction); // ReSharper disable once PossibleNullReferenceException deserializedSampledTransaction["sampled"].Value().Should().BeTrue(); - deserializedSampledTransaction["context"].Value()["request"].Value()["url"].Value()["full"] + deserializedSampledTransaction["context"]["request"]["url"]["full"] .Value() .Should() .Be("https://elastic.co"); @@ -294,15 +299,15 @@ public void TransactionContextShouldBeSerializedOnlyWhenSampled() [InlineData("ABCDEFG", 6, "ABC...")] [InlineData("ABCDEFGH", 6, "ABC...")] [InlineData("ABCDEFGH", 7, "ABCD...")] - public void SerializationUtilsTrimToLengthTests(string original, int maxLength, string expectedTrimmed) => - SerializationUtils.TrimToLength(original, maxLength).Should().Be(expectedTrimmed); + public void SerializationUtilsTruncateTests(string original, int maxLength, string expectedTrimmed) => + original.Truncate(maxLength).Should().Be(expectedTrimmed); [Theory] [MemberData(nameof(SerializationUtilsTrimToPropertyMaxLengthVariantsToTest))] - public void SerializationUtilsTrimToPropertyMaxLengthTests(string original, string expectedTrimmed) + public void SerializationUtilsTruncateToPropertyMaxLengthTests(string original, string expectedTrimmed) { Consts.PropertyMaxLength.Should().BeGreaterThan(3); - SerializationUtils.TrimToPropertyMaxLength(original).Should().Be(expectedTrimmed); + original.Truncate().Should().Be(expectedTrimmed); } /// @@ -343,7 +348,7 @@ public void LabelDeDotting() json = SerializePayloadItem(context); json.Should().Be("{\"tags\":{\"a_b\":\"labelValue1\",\"a_b_c\":\"labelValue2\"}}"); } - + /// /// Makes sure that keys in custom are de dotted. /// @@ -410,8 +415,8 @@ public void MetricSet_Serializes_And_Deserializes() } } - private static string SerializePayloadItem(object item) => - new PayloadItemSerializer(new MockConfigSnapshot()).SerializeObject(item); + private string SerializePayloadItem(object item) => + _payloadItemSerializer.SerializeObject(item); /// /// A dummy type for tests.