using System; using System.Collections.Generic; using System.IO; using System.Threading; using Newtonsoft.Json; using UnityEngine; #if UNITY_EDITOR using System.Threading.Tasks; #endif namespace Unity.Pipeline.Telemetry { /// /// One appended line of eval-usage telemetry. Serialized as a single compact JSON object per line /// (JSONL). The raw is omitted unless the local /// opt-in is set, so by default only shape/fingerprint data is persisted (AUTHAPI-29). /// [Serializable] class EvalUsageRecord { [JsonProperty("time")] public string Time { get; set; } /// The command that ran the source: "eval" or "eval_file". [JsonProperty("command")] public string Command { get; set; } [JsonProperty("success")] public bool Success { get; set; } /// Short error category on failure (e.g. "Compilation Failed"); omitted on success. [JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)] public string Error { get; set; } /// Character length of the eval body. [JsonProperty("payloadLength")] public int PayloadLength { get; set; } /// Newline-delimited line count of the eval body. [JsonProperty("lineCount")] public int LineCount { get; set; } [JsonProperty("executionTimeMs")] public long ExecutionTimeMs { get; set; } /// "single-expression" | "statements" — see . [JsonProperty("classification")] public string Classification { get; set; } /// Top-level API member-access paths extracted from the syntax tree. [JsonProperty("fingerprints")] public List Fingerprints { get; set; } = new List(); /// Raw eval source. Present ONLY when the explicit opt-in is set; omitted otherwise. [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] public string Source { get; set; } } /// /// Local-first, privacy-first instrumentation of eval/eval_file (AUTHAPI-29). Per /// invocation it appends one to /// <project>/Library/Pipeline/eval-usage.jsonl. There is NO off-machine transmission: /// disabling stops even local recording, which satisfies "analytics opt-out /// fully disables any off-machine transmission" trivially — nothing ever leaves the machine. /// /// RECORDING IS EDITOR-ONLY. Eval telemetry exists to steer the editor command-surface migration, /// so / compile to no-ops outside the editor /// (#if UNITY_EDITOR). This also keeps player builds from writing a "Library/Pipeline" /// folder next to Application.dataPath (the APK path on Android, inside the .app bundle on /// macOS) — a location players may not even be able to write. Release players cannot eval at all /// (the compiler stub blocks it); development players can, but are deliberately not instrumented. /// The class itself stays compiled in players so shared code links, and the read side returns /// empty results there. /// /// The production hook is fire-and-forget: snapshots the eval /// outcome plus the current configuration on the calling (main) thread and runs the Roslyn parse, /// fingerprint extraction, and file append on a thread-pool task, so recording adds no latency to /// the eval response. /// /// Modelled on PipelineTransactionLog: a lock serialises the append (evals can overlap on /// the /api/progress path) and the reader takes the same lock, all I/O is best-effort and swallows /// errors so telemetry never breaks a command, and a directory-parameterised seam makes it /// hermetically testable. Unlike the debug transaction log, the JSONL is meant to ACCUMULATE /// across sessions so the ranked report has data, so it is bounded by a byte-size cap with /// rotation rather than reset per session; merges the rotated ".old" /// file back in, bounding readable history to roughly 2× . /// static class EvalUsageTelemetry { /// /// Byte-size cap for the active JSONL before it rotates to the ".old" file (default 5 MB). /// A settable field (not a const) so tests can force the size-cap rotation path cheaply. /// public static long MaxFileBytes = 5L * 1024 * 1024; /// /// Upper bound, in characters, on the raw source persisted per record when the /// opt-in is set (64 K). Longer bodies are cut at this bound and /// is appended so truncation is explicit; /// still reports the full original length. /// public const int MaxStoredSourceChars = 64 * 1024; /// Marker appended to a stored that was truncated. public const string SourceTruncationMarker = "…[truncated]"; private const string FileName = "eval-usage.jsonl"; private const string OldFileName = "eval-usage.old.jsonl"; /// Master switch. When false, is a no-op (no local write, no transmission). public static bool Enabled = true; /// Opt-in: persist the raw eval source in each record. Off by default (privacy-first). public static bool StoreSource = false; /// /// Overrides the storage directory. Null uses <project>/Library/Pipeline. Tests set /// this to a temp directory so they never touch the real project telemetry file. /// public static string OverrideDirectory; private static readonly object s_AppendGate = new object(); /// /// Number of background records queued by and not yet /// flushed. Explicitly initialised: in player builds the recording path compiles away and the /// field is only ever read (by ), which would otherwise /// raise CS0649. /// private static int s_PendingRecords = 0; #if UNITY_EDITOR /// Serialises background writes in submission order (see ). /// Editor-only like the recording path itself: Task is not even imported in player builds. private static readonly object s_WriteChainGate = new object(); private static Task s_WriteChain = Task.CompletedTask; #endif /// Main-thread-resolved cache of the default (dataPath-derived) storage directory. private static string s_CachedDefaultDirectory; /// /// Record one eval invocation synchronously on the calling thread. Editor-only: compiles to a /// no-op in player builds (see class doc). Best-effort: any failure (parse, I/O) is swallowed /// with a warning so instrumentation can never break the eval it is measuring. The production /// eval hook uses so the eval response is never delayed; this /// synchronous seam remains for tests and direct callers that need the write completed on /// return. /// public static void Record(string command, string code, bool success, string error, long executionTimeMs) { #if UNITY_EDITOR if (!Enabled) return; TryWriteRecord(ResolveDirectory(), DateTime.UtcNow, command, code, success, error, executionTimeMs, StoreSource); #endif } /// /// Record one eval invocation without blocking the calling thread. Editor-only: compiles to a /// no-op in player builds (see class doc). Everything the write needs — the eval primitives /// plus a snapshot of the current / configuration /// and the resolved directory (which touches Unity API and must be read on the main thread) — /// is captured here on the calling thread; the Roslyn parse, fingerprint extraction, and /// lock-serialised append then run on a thread-pool task, so telemetry adds zero latency to /// the eval response. Failures on the background task are swallowed with a warning /// (Debug.LogWarning is thread-safe). A domain reload can at worst abandon one in-flight /// record; its partial trailing line is skipped by . /// public static void RecordInBackground(string command, string code, bool success, string error, long executionTimeMs) { #if UNITY_EDITOR if (!Enabled) return; // Snapshot on the calling thread: config statics can be flipped live by the inspector, // and ResolveDirectory may touch Application.dataPath (main-thread-only Unity API). var directory = ResolveDirectory(); var storeSource = StoreSource; var timeUtc = DateTime.UtcNow; Interlocked.Increment(ref s_PendingRecords); // Chain onto the previous write instead of racing independent Task.Runs: back-to-back // evals (batch, tight agent loops) must land in submission order — ReadRecords // documents the log as chronological — and the chain also means at most one telemetry // write competes with the pool at a time. lock (s_WriteChainGate) { s_WriteChain = s_WriteChain.ContinueWith(_ => { try { TryWriteRecord(directory, timeUtc, command, code, success, error, executionTimeMs, storeSource); } finally { Interlocked.Decrement(ref s_PendingRecords); } }, System.Threading.CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); } #endif } /// /// Block until every background record queued by has been /// flushed, or elapses (returns false on timeout). Recording is /// fire-and-forget, so tests (and teardown paths that delete the storage directory) drain the /// queue with this before asserting on / removing the JSONL. /// public static bool WaitForPendingRecords(int timeoutMs = 5000) { var stopwatch = System.Diagnostics.Stopwatch.StartNew(); while (Volatile.Read(ref s_PendingRecords) > 0) { if (stopwatch.ElapsedMilliseconds >= timeoutMs) return false; Thread.Sleep(1); } return true; } #if UNITY_EDITOR /// /// , with the shared best-effort guard: telemetry must never affect /// eval behavior (or crash a background task), so any failure (parse, I/O) is swallowed with /// a warning instead of propagating. Shared by 's synchronous call and /// 's queued continuation. /// private static void TryWriteRecord( string directory, DateTime timeUtc, string command, string code, bool success, string error, long executionTimeMs, bool storeSource) { try { WriteRecord(directory, timeUtc, command, code, success, error, executionTimeMs, storeSource); } catch (Exception ex) { Debug.LogWarning($"Pipeline eval telemetry write failed: {ex.Message}"); } } /// /// Build and append one record from an already-captured snapshot. Runs on whichever thread the /// caller chose; deliberately touches no Unity API (the fingerprinter is pure Roslyn and the /// directory was resolved by the caller), so it is safe on a thread-pool task. /// private static void WriteRecord( string directory, DateTime timeUtc, string command, string code, bool success, string error, long executionTimeMs, bool storeSource) { var analysis = EvalUsageFingerprinter.Analyze(code); var record = new EvalUsageRecord { Time = timeUtc.ToString("o"), Command = command, Success = success, Error = success ? null : error, PayloadLength = code?.Length ?? 0, LineCount = CountLines(code), ExecutionTimeMs = executionTimeMs, Classification = analysis.Classification, Fingerprints = new List(analysis.Fingerprints), Source = storeSource ? BoundStoredSource(code) : null }; Append(directory, record); } /// /// Bound the opt-in raw source at , appending /// when cut, so one giant eval_file body can't balloon /// a single record (the JSONL size cap only rotates between appends). /// private static string BoundStoredSource(string code) { if (string.IsNullOrEmpty(code) || code.Length <= MaxStoredSourceChars) return code; return code.Substring(0, MaxStoredSourceChars) + SourceTruncationMarker; } #endif /// /// Append one record to the JSONL in an explicit directory. Testable seam behind /// . Rotates first if the active file has reached the size cap, so each /// file stays bounded to roughly plus one record. /// public static void Append(string directory, EvalUsageRecord record) { lock (s_AppendGate) { var root = Path.GetFullPath(directory); Directory.CreateDirectory(root); var path = DataFilePath(root, FileName); if (File.Exists(path) && new FileInfo(path).Length >= MaxFileBytes) Rotate(root); File.AppendAllText(path, JsonConvert.SerializeObject(record, Formatting.None) + "\n"); } } /// /// Canonical absolute path of one of this class's fixed data files inside the telemetry /// directory, with containment re-checked after normalization. The directory is the only /// dynamic path segment (a local editor setting or the test override — never protocol /// input) and the file names are compile-time constants, so this is an explicit invariant /// at the IO boundary: any input that would resolve outside its canonical directory is /// rejected instead of opened. /// private static string DataFilePath(string directory, string fileName) { if (!TryResolveDataFilePath(directory, fileName, out var full)) throw new InvalidOperationException( $"Telemetry data file path '{full}' escapes its directory '{directory}'."); return full; } /// /// The single canonicalize-and-contain implementation behind both the throwing /// (writer sites) and the silently-skipping reader — one place /// to fix normalization, per review. /// private static bool TryResolveDataFilePath(string directory, string fileName, out string fullPath) { var root = Path.GetFullPath(directory); fullPath = Path.GetFullPath(Path.Combine(root, fileName)); var rootWithSeparator = root.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) ? root : root + Path.DirectorySeparatorChar; return fullPath.StartsWith(rootWithSeparator, StringComparison.Ordinal); } /// /// Move the active JSONL to the ".old" backup, replacing any prior backup, so the active file /// starts fresh. No-op when there is no active file. Exposed as the testable rotation seam. /// Takes the append gate so a rotation can never interleave with a concurrent append or read /// (the lock is re-entrant, so the rotate-inside- path is unaffected). /// public static void Rotate(string directory) { lock (s_AppendGate) { var root = Path.GetFullPath(directory); Directory.CreateDirectory(root); var path = DataFilePath(root, FileName); var oldPath = DataFilePath(root, OldFileName); RotatingFileBackup.RotateToBackup(path, oldPath); } } /// /// Read back every record from : the rotated /// eval-usage.old.jsonl first (when present), then the active file, so the report sees /// the full retained history — bounded to roughly 2× — in /// chronological order. Malformed lines are skipped so a partial trailing write can never /// break the report. /// /// Concurrency: takes the same gate as / (this class /// is the only writer, and appends now happen on background tasks), and additionally opens /// each file tolerantly — FileShare.ReadWrite, with I/O errors caught — so a reader /// racing an out-of-process writer, or a file swapped away between the existence check and the /// open (the rotation TOCTOU), degrades to "return what parsed" instead of throwing. /// public static IReadOnlyList ReadRecords(string directory) { var records = new List(); lock (s_AppendGate) { ReadFileInto(directory, OldFileName, records); ReadFileInto(directory, FileName, records); } return records; } /// /// Best-effort JSONL parse of one fixed-name data file into ; see /// . The canonicalize-and-contain check is repeated inline here (not /// only in ) so the validated path and the open are in the same /// method — the same invariant, kept visible at the IO site. /// private static void ReadFileInto(string directory, string fileName, List records) { // Same canonicalize-and-contain invariant as the writer sites, via the one shared // implementation; a reader skips silently where a writer throws. if (!TryResolveDataFilePath(directory, fileName, out var path)) return; // outside the telemetry directory — unreachable for the constant file names try { using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) using (var reader = new StreamReader(stream)) { string line; while ((line = reader.ReadLine()) != null) { if (string.IsNullOrWhiteSpace(line)) continue; try { var record = JsonConvert.DeserializeObject(line); if (record != null) records.Add(record); } catch (JsonException) { // Skip a malformed / partially written line; keep the rest of the log usable. } } } } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { // Covers FileNotFound/DirectoryNotFound (nothing recorded yet, or the file was rotated // away mid-read), sharing violations from external processes, and ACL/AV denials // (UnauthorizedAccessException does NOT derive from IOException): telemetry read-back // is best-effort, so return whatever parsed instead of throwing. } } /// /// Resolve the storage directory: the override if set, else <project>/Library/Pipeline. /// The default derives from Application.dataPath (a main-thread Unity API), so the /// first non-overridden call must happen on the main thread — the editor startup path primes /// it — after which the cached value is safe from any thread (e.g. the off-main-thread /// report_evals command). /// public static string ResolveDirectory() { if (!string.IsNullOrEmpty(OverrideDirectory)) return OverrideDirectory; if (s_CachedDefaultDirectory == null) { #if UNITY_EDITOR var projectRoot = Path.GetDirectoryName(Application.dataPath); s_CachedDefaultDirectory = Path.Combine(projectRoot, "Library", "Pipeline"); #else // Player builds: recording is compiled out and nothing is ever written, but // report_evals (MainThreadRequired=false) can reach this off the main thread // before any main-thread code primed the cache — and Application.dataPath is // main-thread-only. A stable process-temp location keeps the read path safe and // empty, so the command returns the documented zeroed report instead of throwing. s_CachedDefaultDirectory = Path.Combine(Path.GetTempPath(), "UnityPipelineEvalUsage"); #endif } return s_CachedDefaultDirectory; } private static int CountLines(string code) { if (string.IsNullOrEmpty(code)) return 0; var lines = 1; for (var i = 0; i < code.Length; i++) { var c = code[i]; if (c == '\n') lines++; else if (c == '\r' && (i + 1 >= code.Length || code[i + 1] != '\n')) lines++; // lone \r (classic Mac) is a line break; \r\n already counts via \n } return lines; } } }