diff --git a/unity-sdk/Runtime/Providers/ConfidenceApiClient.cs b/unity-sdk/Runtime/Providers/ConfidenceApiClient.cs index 310bea5..aae04f7 100644 --- a/unity-sdk/Runtime/Providers/ConfidenceApiClient.cs +++ b/unity-sdk/Runtime/Providers/ConfidenceApiClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Text; using System.Threading; @@ -9,7 +10,9 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; using UnityEngine.Scripting; +using UnityOpenFeature.Telemetry; using Object = UnityEngine.Object; +using Debug = UnityEngine.Debug; namespace UnityOpenFeature.Providers { @@ -41,6 +44,9 @@ public class ConfidenceApiClient : MonoBehaviour private float checkpointTimer = 0f; private const float CHECKPOINT_INTERVAL = 10f; // 10 seconds + private const string TelemetryHeaderName = "X-CONFIDENCE-TELEMETRY"; + internal Telemetry.Telemetry Telemetry { get; private set; } + // Private constructor - use Create() method instead private ConfidenceApiClient() { } @@ -53,6 +59,7 @@ public static ConfidenceApiClient Create(string clientSecret) // Add the client as a component ConfidenceApiClient client = clientGO.AddComponent(); client.clientSecret = clientSecret; + client.Telemetry = new Telemetry.Telemetry(Platform.DotNet, client.sdkVersion); return client; } @@ -103,12 +110,32 @@ public async Task ResolveFlagsAsync(List flagKeys, Dictionary flagKeys, Dictionary flagKeys, Dictionary ResolveObjectValue(string flagKey, T defaultValue { var value = ResolveValueByDotNotation(flagKey); if (value == null) - return ResolutionDetails.Error(flagKey, defaultValue, ErrorCode.FlagNotFound, $"Flag '{flagKey}' not found"); + { + var errorResult = ResolutionDetails.Error(flagKey, defaultValue, ErrorCode.FlagNotFound, $"Flag '{flagKey}' not found"); + TrackEvaluation(null, errorResult.ErrorMessage); + return errorResult; + } var rootFlagKey = flagKey.Split('.')[0]; var resolvedFlag = GetResolvedFlag(rootFlagKey); @@ -315,6 +320,8 @@ public ResolutionDetails ResolveObjectValue(string flagKey, T defaultValue catch (Exception ex) { details = ResolutionDetails.Error(flagKey, defaultValue, ErrorCode.ParseError, $"Cannot parse: {ex.Message}"); + TrackEvaluation(resolvedFlag?.reason, details.ErrorMessage); + return details; } if (resolvedFlag != null) @@ -325,6 +332,7 @@ public ResolutionDetails ResolveObjectValue(string flagKey, T defaultValue tryApply(resolvedFlag, rootFlagKey); } + TrackEvaluation(resolvedFlag?.reason, null); return details; } @@ -397,6 +405,22 @@ private Reason MapResolveReasonToReason(string resolveReason) }; } + private void TrackEvaluation(string apiReason, string errorMessage) + { + try + { + if (apiClient?.Telemetry != null) + { + var (reason, errorCode) = Telemetry.Telemetry.MapEvaluationReason(apiReason, errorMessage); + apiClient.Telemetry.TrackEvaluation(reason, errorCode); + } + } + catch (Exception ex) + { + Debug.Log($"Telemetry eval tracking error (best-effort): {ex.Message}"); + } + } + private Dictionary GetEvaluationContext() { var context = OpenFeatureAPI.Instance.EvaluationContext; diff --git a/unity-sdk/Runtime/Telemetry/ProtobufEncoder.cs b/unity-sdk/Runtime/Telemetry/ProtobufEncoder.cs new file mode 100644 index 0000000..bd27696 --- /dev/null +++ b/unity-sdk/Runtime/Telemetry/ProtobufEncoder.cs @@ -0,0 +1,175 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace UnityOpenFeature.Telemetry +{ + internal static class ProtobufEncoder + { + private const int WireTypeVarint = 0; + private const int WireTypeLengthDelimited = 2; + + public static byte[] EncodeMonitoring( + Library library, + string sdkVersion, + Platform platform, + IReadOnlyList resolveTraces, + IReadOnlyList evalTraces) + { + using (var ms = new MemoryStream()) + { + var libraryTracesBytes = EncodeLibraryTraces(library, sdkVersion, resolveTraces, evalTraces); + if (libraryTracesBytes.Length > 0) + { + WriteLengthDelimited(ms, 1, libraryTracesBytes); + } + + if (platform != Platform.Unspecified) + { + WriteTag(ms, 2, WireTypeVarint); + WriteVarint(ms, (ulong)platform); + } + + return ms.ToArray(); + } + } + + private static byte[] EncodeLibraryTraces( + Library library, + string sdkVersion, + IReadOnlyList resolveTraces, + IReadOnlyList evalTraces) + { + using (var ms = new MemoryStream()) + { + if (library != Library.Unspecified) + { + WriteTag(ms, 1, WireTypeVarint); + WriteVarint(ms, (ulong)library); + } + + if (!string.IsNullOrEmpty(sdkVersion)) + { + var versionBytes = Encoding.UTF8.GetBytes(sdkVersion); + WriteLengthDelimited(ms, 2, versionBytes); + } + + foreach (var trace in resolveTraces) + { + var traceBytes = EncodeResolveTrace(trace); + WriteLengthDelimited(ms, 3, traceBytes); + } + + foreach (var trace in evalTraces) + { + var traceBytes = EncodeEvaluationTrace(trace); + WriteLengthDelimited(ms, 3, traceBytes); + } + + return ms.ToArray(); + } + } + + private static byte[] EncodeResolveTrace(ResolveLatencyTraceData data) + { + using (var ms = new MemoryStream()) + { + WriteTag(ms, 1, WireTypeVarint); + WriteVarint(ms, (ulong)TraceId.ResolveLatency); + + var requestTraceBytes = EncodeRequestTrace(data); + if (requestTraceBytes.Length > 0) + { + WriteLengthDelimited(ms, 3, requestTraceBytes); + } + + return ms.ToArray(); + } + } + + private static byte[] EncodeRequestTrace(ResolveLatencyTraceData data) + { + using (var ms = new MemoryStream()) + { + if (data.MillisecondDuration > 0) + { + WriteTag(ms, 1, WireTypeVarint); + WriteVarint(ms, data.MillisecondDuration); + } + + if (data.Status != RequestStatus.Unspecified) + { + WriteTag(ms, 2, WireTypeVarint); + WriteVarint(ms, (ulong)data.Status); + } + + return ms.ToArray(); + } + } + + private static byte[] EncodeEvaluationTrace(EvaluationTraceData data) + { + using (var ms = new MemoryStream()) + { + WriteTag(ms, 1, WireTypeVarint); + WriteVarint(ms, (ulong)TraceId.EvaluationOutcome); + + var evalTraceBytes = EncodeEvalTraceBody(data); + if (evalTraceBytes.Length > 0) + { + WriteLengthDelimited(ms, 5, evalTraceBytes); + } + + return ms.ToArray(); + } + } + + private static byte[] EncodeEvalTraceBody(EvaluationTraceData data) + { + using (var ms = new MemoryStream()) + { + if (data.Reason != EvaluationReason.Unspecified) + { + WriteTag(ms, 1, WireTypeVarint); + WriteVarint(ms, (ulong)data.Reason); + } + + if (data.ErrorCode != EvaluationErrorCode.Unspecified) + { + WriteTag(ms, 2, WireTypeVarint); + WriteVarint(ms, (ulong)data.ErrorCode); + } + + return ms.ToArray(); + } + } + + private static void WriteTag(Stream stream, int fieldNumber, int wireType) + { + WriteVarint(stream, (ulong)((fieldNumber << 3) | wireType)); + } + + private static void WriteVarint(Stream stream, ulong value) + { + do + { + var b = (byte)(value & 0x7F); + value >>= 7; + if (value != 0) + { + b |= 0x80; + } + + stream.WriteByte(b); + } + while (value != 0); + } + + private static void WriteLengthDelimited(Stream stream, int fieldNumber, byte[] data) + { + WriteTag(stream, fieldNumber, WireTypeLengthDelimited); + WriteVarint(stream, (ulong)data.Length); + stream.Write(data, 0, data.Length); + } + } +} diff --git a/unity-sdk/Runtime/Telemetry/Telemetry.cs b/unity-sdk/Runtime/Telemetry/Telemetry.cs new file mode 100644 index 0000000..78ba092 --- /dev/null +++ b/unity-sdk/Runtime/Telemetry/Telemetry.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; + +namespace UnityOpenFeature.Telemetry +{ + internal class EvaluationTraceData + { + public EvaluationReason Reason { get; } + public EvaluationErrorCode ErrorCode { get; } + + public EvaluationTraceData(EvaluationReason reason, EvaluationErrorCode errorCode) + { + Reason = reason; + ErrorCode = errorCode; + } + } + + internal class ResolveLatencyTraceData + { + public ulong MillisecondDuration { get; } + public RequestStatus Status { get; } + + public ResolveLatencyTraceData(ulong millisecondDuration, RequestStatus status) + { + MillisecondDuration = millisecondDuration; + Status = status; + } + } + + internal sealed class Telemetry + { + private const int MaxTraces = 100; + + private readonly object _lock = new object(); + private readonly Platform _platform; + private readonly string _sdkVersion; + private List _evalTraces = new List(); + private List _resolveTraces = new List(); + private volatile Library _currentLibrary = Library.OpenFeature; + + internal Library CurrentLibrary + { + get { return _currentLibrary; } + set { _currentLibrary = value; } + } + + internal Telemetry(Platform platform, string sdkVersion) + { + _platform = platform; + _sdkVersion = sdkVersion; + } + + internal void TrackEvaluation(EvaluationReason reason, EvaluationErrorCode errorCode) + { + lock (_lock) + { + if (_evalTraces.Count < MaxTraces) + { + _evalTraces.Add(new EvaluationTraceData(reason, errorCode)); + } + } + } + + internal void TrackResolveLatency(ulong durationMs, RequestStatus status) + { + lock (_lock) + { + if (_resolveTraces.Count < MaxTraces) + { + _resolveTraces.Add(new ResolveLatencyTraceData(durationMs, status)); + } + } + } + + internal string EncodedHeaderValue() + { + List evalSnapshot; + List resolveSnapshot; + + lock (_lock) + { + if (_evalTraces.Count == 0 && _resolveTraces.Count == 0) + { + return null; + } + + evalSnapshot = _evalTraces; + resolveSnapshot = _resolveTraces; + _evalTraces = new List(); + _resolveTraces = new List(); + } + + var bytes = ProtobufEncoder.EncodeMonitoring( + _currentLibrary, + _sdkVersion, + _platform, + resolveSnapshot, + evalSnapshot); + + return Convert.ToBase64String(bytes); + } + + internal static (EvaluationReason reason, EvaluationErrorCode errorCode) MapEvaluationReason( + string apiReason, + string errorMessage) + { + if (errorMessage != null) + { + var errorCode = MapErrorMessageToCode(errorMessage); + return (EvaluationReason.Error, errorCode); + } + + EvaluationReason reason; + switch (apiReason) + { + case "RESOLVE_REASON_MATCH": + reason = EvaluationReason.Match; + break; + case "RESOLVE_REASON_UNSPECIFIED": + reason = EvaluationReason.Unspecified_; + break; + case "RESOLVE_REASON_NO_SEGMENT_MATCH": + case "RESOLVE_REASON_NO_TREATMENT_MATCH": + reason = EvaluationReason.NoSegmentMatch; + break; + case "RESOLVE_REASON_FLAG_ARCHIVED": + reason = EvaluationReason.Archived; + break; + case "RESOLVE_REASON_TARGETING_KEY_ERROR": + reason = EvaluationReason.TargetingKeyError; + break; + case "ERROR": + reason = EvaluationReason.Error; + break; + default: + reason = EvaluationReason.Unspecified; + break; + } + + return (reason, EvaluationErrorCode.Unspecified); + } + + private static EvaluationErrorCode MapErrorMessageToCode(string errorMessage) + { + var lower = errorMessage.ToLowerInvariant(); + + if (lower.Contains("not found")) + return EvaluationErrorCode.FlagNotFound; + + if (lower.Contains("parse") || lower.Contains("type mismatch") || lower.Contains("cannot convert")) + return EvaluationErrorCode.ParseError; + + return EvaluationErrorCode.GeneralError; + } + } +} diff --git a/unity-sdk/Runtime/Telemetry/TelemetryEnums.cs b/unity-sdk/Runtime/Telemetry/TelemetryEnums.cs new file mode 100644 index 0000000..06565f2 --- /dev/null +++ b/unity-sdk/Runtime/Telemetry/TelemetryEnums.cs @@ -0,0 +1,55 @@ +namespace UnityOpenFeature.Telemetry +{ + internal enum Platform + { + Unspecified = 0, + DotNet = 12, + } + + internal enum Library + { + Unspecified = 0, + Confidence = 1, + OpenFeature = 2, + } + + internal enum TraceId + { + Unspecified = 0, + ResolveLatency = 1, + EvaluationOutcome = 2, + } + + internal enum RequestStatus + { + Unspecified = 0, + Ok = 1, + Timeout = 2, + Error = 3, + } + + internal enum EvaluationReason + { + Unspecified = 0, + Match = 1, + Unspecified_ = 2, + NoSegmentMatch = 3, + Archived = 4, + TargetingKeyError = 5, + ProviderNotReady = 6, + DefaultValue = 7, + Error = 8, + } + + internal enum EvaluationErrorCode + { + Unspecified = 0, + ProviderNotReady = 1, + FlagNotFound = 2, + ParseError = 3, + TypeMismatch = 4, + GeneralError = 5, + InvalidContext = 6, + TargetingKeyMissing = 7, + } +}