From a78b66a22e46802ed3bdfcf3284eedd2d673398e Mon Sep 17 00:00:00 2001 From: Stefan Penner Date: Tue, 7 Apr 2026 21:59:36 -0600 Subject: [PATCH 1/4] Fix case-sensitive action dedup allowing duplicate downloads The deduplication logic introduced in #4296 used StringComparer.Ordinal, so action references differing only by owner/repo casing (e.g. actions/checkout vs actIONS/checkout) were treated as distinct and downloaded multiple times. Switch to OrdinalIgnoreCase for all dedup collections and the GroupBy in GetDownloadInfoAsync. Fixes #3731 Co-Authored-By: Claude Opus 4.6 --- src/Runner.Worker/ActionManager.cs | 8 +-- src/Test/L0/Worker/ActionManagerL0.cs | 92 +++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 79c0de5f706..c9cf801fe0b 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -84,7 +84,7 @@ public sealed class ActionManager : RunnerService, IActionManager // Stack-local cache: same action (owner/repo@ref) is resolved only once, // even if it appears at multiple depths in a composite tree. var resolvedDownloadInfos = batchActionResolution - ? new Dictionary(StringComparer.Ordinal) + ? new Dictionary(StringComparer.OrdinalIgnoreCase) : null; var depth = 0; // We are running at the start of a job @@ -858,7 +858,7 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, // Convert to action reference var actionReferences = actions - .GroupBy(x => GetDownloadInfoLookupKey(x)) + .GroupBy(x => GetDownloadInfoLookupKey(x), StringComparer.OrdinalIgnoreCase) .Where(x => !string.IsNullOrEmpty(x.Key)) .Select(x => { @@ -953,7 +953,7 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, } } - return actionDownloadInfos.Actions; + return new Dictionary(actionDownloadInfos.Actions, StringComparer.OrdinalIgnoreCase); } /// @@ -963,7 +963,7 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, private async Task ResolveNewActionsAsync(IExecutionContext executionContext, List actions, Dictionary resolvedDownloadInfos) { var actionsToResolve = new List(); - var pendingKeys = new HashSet(StringComparer.Ordinal); + var pendingKeys = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var action in actions) { var lookupKey = GetDownloadInfoLookupKey(action); diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index 501402a88b4..db64734e67d 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -1449,6 +1449,98 @@ public async void PrepareActions_DeduplicatesResolutionAcrossDepthLevels() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_DeduplicatesCaseInsensitiveActionReferences() + { + // Verifies that action references differing only by owner/repo casing + // are deduplicated and resolved only once. + // Regression test for https://github.com/actions/runner/issues/3731 + Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true"); + try + { + //Arrange + Setup(); + // Each action step with pre+post needs 2 IActionRunner instances (pre + post) + for (int i = 0; i < 6; i++) + { + _hc.EnqueueInstance(new Mock().Object); + } + + var allResolvedKeys = new List(); + _jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) => + { + var result = new ActionDownloadInfoCollection { Actions = new Dictionary() }; + foreach (var action in actions.Actions) + { + var key = $"{action.NameWithOwner}@{action.Ref}"; + allResolvedKeys.Add(key); + result.Actions[key] = new ActionDownloadInfo + { + NameWithOwner = action.NameWithOwner, + Ref = action.Ref, + ResolvedNameWithOwner = action.NameWithOwner, + ResolvedSha = $"{action.Ref}-sha", + TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}", + ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}", + }; + } + return Task.FromResult(result); + }); + + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action1", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = "TingluoHuang/runner_L0", + Ref = "RepositoryActionWithWrapperActionfile_Node", + RepositoryType = "GitHub" + } + }, + new Pipelines.ActionStep() + { + Name = "action2", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = "tingluohuang/RUNNER_L0", + Ref = "RepositoryActionWithWrapperActionfile_Node", + RepositoryType = "GitHub" + } + }, + new Pipelines.ActionStep() + { + Name = "action3", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = "TINGLUOHUANG/Runner_L0", + Ref = "RepositoryActionWithWrapperActionfile_Node", + RepositoryType = "GitHub" + } + } + }; + + //Act + await _actionManager.PrepareActionsAsync(_ec.Object, actions); + + //Assert + // All three references should deduplicate to a single resolve call + Assert.Equal(1, allResolvedKeys.Count); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null); + Teardown(); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] From 2b7a815a8e6ce8dbd51f9c5bc4b7592b0384e45c Mon Sep 17 00:00:00 2001 From: Stefan Penner Date: Tue, 7 Apr 2026 22:09:50 -0600 Subject: [PATCH 2/4] Normalize action references after case-insensitive dedup On case-sensitive filesystems (Linux), after dedup merges differently-cased references into one download, PrepareRepositoryActionAsync and LoadAction would look for directories using the original (non-canonical) casing and fail to find the action. Fix by normalizing each action step's repositoryReference.Name to match downloadInfo.NameWithOwner after dedup lookup, in both the batch and legacy code paths. Also makes the empty-dictionary return path in GetDownloadInfoAsync use OrdinalIgnoreCase for consistency. Co-Authored-By: Claude Opus 4.6 --- src/Runner.Worker/ActionManager.cs | 24 +++++++++++++++++++++++- src/Test/L0/Worker/ActionManagerL0.cs | 15 +++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index c9cf801fe0b..0288a99e2b3 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -228,6 +228,9 @@ public sealed class ActionManager : RunnerService, IActionManager { throw new Exception($"Missing download info for {lookupKey}"); } + // Normalize the reference name to match the resolved download info so that + // directory paths are consistent on case-sensitive filesystems (Linux). + NormalizeRepositoryReference(action, downloadInfo); await DownloadRepositoryActionAsync(executionContext, downloadInfo); } @@ -414,6 +417,9 @@ public sealed class ActionManager : RunnerService, IActionManager throw new Exception($"Missing download info for {lookupKey}"); } + // Normalize the reference name to match the resolved download info so that + // directory paths are consistent on case-sensitive filesystems (Linux). + NormalizeRepositoryReference(action, downloadInfo); await DownloadRepositoryActionAsync(executionContext, downloadInfo); } @@ -877,7 +883,7 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, // Nothing to resolve? if (actionReferences.Count == 0) { - return new Dictionary(); + return new Dictionary(StringComparer.OrdinalIgnoreCase); } // Resolve download info @@ -1342,6 +1348,22 @@ private ActionSetupInfo PrepareRepositoryActionAsync(IExecutionContext execution } } + /// + /// After case-insensitive deduplication, the resolved download info may use + /// a different casing than the action step's reference. Normalize the reference + /// so that directory paths (used by DownloadRepositoryActionAsync, + /// PrepareRepositoryActionAsync, and LoadAction) are consistent on + /// case-sensitive filesystems. + /// + private static void NormalizeRepositoryReference(Pipelines.ActionStep action, WebApi.ActionDownloadInfo downloadInfo) + { + if (action.Reference is Pipelines.RepositoryPathReference repoRef && + !string.Equals(repoRef.Name, downloadInfo.NameWithOwner, StringComparison.Ordinal)) + { + repoRef.Name = downloadInfo.NameWithOwner; + } + } + private static string GetDownloadInfoLookupKey(Pipelines.ActionStep action) { if (action.Reference.Type != Pipelines.ActionSourceType.Repository) diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index db64734e67d..7d404380cdd 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -1533,6 +1533,21 @@ public async void PrepareActions_DeduplicatesCaseInsensitiveActionReferences() //Assert // All three references should deduplicate to a single resolve call Assert.Equal(1, allResolvedKeys.Count); + + // Verify all actions are usable: the download went to one directory and + // reference normalization ensures all three steps find the action there. + // The completed watermark proves the download + prepare succeeded. + Assert.True(File.Exists(Path.Combine( + _hc.GetDirectory(WellKnownDirectory.Actions), + "TingluoHuang/runner_L0", + "RepositoryActionWithWrapperActionfile_Node.completed"))); + + // Verify the references were normalized to the canonical casing + foreach (var action in actions) + { + var repoRef = action.Reference as Pipelines.RepositoryPathReference; + Assert.Equal("TingluoHuang/runner_L0", repoRef.Name); + } } finally { From 5593179bb492ab3b328eb19e16d37491cc035e8d Mon Sep 17 00:00:00 2001 From: Stefan Penner Date: Tue, 7 Apr 2026 22:35:38 -0600 Subject: [PATCH 3/4] Use custom comparer to keep ref case-sensitive in dedup keys Replace StringComparer.OrdinalIgnoreCase with ActionLookupKeyComparer that treats the owner/repo portion of "{owner/repo}@{ref}" keys case-insensitively while keeping the ref portion case-sensitive, since git refs are case-sensitive. Co-Authored-By: Claude Opus 4.6 --- src/Runner.Worker/ActionManager.cs | 45 ++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 0288a99e2b3..609c3a18211 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -84,7 +84,7 @@ public sealed class ActionManager : RunnerService, IActionManager // Stack-local cache: same action (owner/repo@ref) is resolved only once, // even if it appears at multiple depths in a composite tree. var resolvedDownloadInfos = batchActionResolution - ? new Dictionary(StringComparer.OrdinalIgnoreCase) + ? new Dictionary(ActionLookupKeyComparer.Instance) : null; var depth = 0; // We are running at the start of a job @@ -864,7 +864,7 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, // Convert to action reference var actionReferences = actions - .GroupBy(x => GetDownloadInfoLookupKey(x), StringComparer.OrdinalIgnoreCase) + .GroupBy(x => GetDownloadInfoLookupKey(x), ActionLookupKeyComparer.Instance) .Where(x => !string.IsNullOrEmpty(x.Key)) .Select(x => { @@ -883,7 +883,7 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, // Nothing to resolve? if (actionReferences.Count == 0) { - return new Dictionary(StringComparer.OrdinalIgnoreCase); + return new Dictionary(ActionLookupKeyComparer.Instance); } // Resolve download info @@ -959,7 +959,7 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, } } - return new Dictionary(actionDownloadInfos.Actions, StringComparer.OrdinalIgnoreCase); + return new Dictionary(actionDownloadInfos.Actions, ActionLookupKeyComparer.Instance); } /// @@ -969,7 +969,7 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, private async Task ResolveNewActionsAsync(IExecutionContext executionContext, List actions, Dictionary resolvedDownloadInfos) { var actionsToResolve = new List(); - var pendingKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var pendingKeys = new HashSet(ActionLookupKeyComparer.Instance); foreach (var action in actions) { var lookupKey = GetDownloadInfoLookupKey(action); @@ -1389,6 +1389,41 @@ private static string GetDownloadInfoLookupKey(Pipelines.ActionStep action) return $"{repositoryReference.Name}@{repositoryReference.Ref}"; } + /// + /// Compares action lookup keys ("{owner/repo}@{ref}") with case-insensitive + /// owner/repo (GitHub names are case-insensitive) and case-sensitive ref + /// (git refs are case-sensitive). + /// + private sealed class ActionLookupKeyComparer : IEqualityComparer + { + public static readonly ActionLookupKeyComparer Instance = new(); + + public bool Equals(string x, string y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; + + var xAt = x.LastIndexOf('@'); + var yAt = y.LastIndexOf('@'); + if (xAt < 0 || yAt < 0) return StringComparer.OrdinalIgnoreCase.Equals(x, y); + + // Name (owner/repo) is case-insensitive; @ref is case-sensitive + return x.AsSpan(0, xAt).Equals(y.AsSpan(0, yAt), StringComparison.OrdinalIgnoreCase) + && x.AsSpan(xAt).Equals(y.AsSpan(yAt), StringComparison.Ordinal); + } + + public int GetHashCode(string obj) + { + if (obj is null) return 0; + var at = obj.LastIndexOf('@'); + if (at < 0) return StringComparer.OrdinalIgnoreCase.GetHashCode(obj); + + return HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Substring(0, at)), + StringComparer.Ordinal.GetHashCode(obj.Substring(at))); + } + } + private AuthenticationHeaderValue CreateAuthHeader(IExecutionContext executionContext, string downloadUrl, string token) { if (string.IsNullOrEmpty(token)) From 6af527542ce7f9891a583e664c155e060617cab1 Mon Sep 17 00:00:00 2001 From: Stefan Penner Date: Tue, 21 Apr 2026 18:10:15 -0600 Subject: [PATCH 4/4] Add optional OTel trace export for step-level execution timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ACTIONS_RUNNER_OTLP_ENDPOINT is set, the runner emits OTel spans for each step as they complete. Spans use deterministic IDs (MD5-based) compatible with otel-explorer's GitHub Actions trace view, allowing runner step spans to merge into workflow traces with zero configuration. Attributes follow OTel CI/CD semantic conventions (cicd.pipeline.*) and include GitHub-specific fields (github.action, github.step_type, etc.). Spans are batched per job and flushed via OTLP/HTTP JSON before job completion is reported to the server. Export is best-effort — failures are silently swallowed to never impact job execution. Co-Authored-By: Claude Opus 4.6 --- src/Runner.Worker/ExecutionContext.cs | 11 + src/Runner.Worker/JobRunner.cs | 6 + src/Runner.Worker/OTelStepTracer.cs | 326 +++++++++++++++++++++++++ src/Test/L0/Worker/OTelStepTracerL0.cs | 137 +++++++++++ 4 files changed, 480 insertions(+) create mode 100644 src/Runner.Worker/OTelStepTracer.cs create mode 100644 src/Test/L0/Worker/OTelStepTracerL0.cs diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 6acb3e385d5..4c679ba9751 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -525,6 +525,17 @@ public TaskResult Complete(TaskResult? result = null, string currentOperation = }); Global.StepsResult.Add(stepResult); + + OTelStepTracer.RecordStepCompletion( + stepName: _record.Name, + stepNumber: _record.Order, + startTime: _record.StartTime, + endTime: _record.FinishTime, + conclusion: _record.Result, + stepType: StepTelemetry?.Type, + actionName: StepTelemetry?.Action, + actionRef: StepTelemetry?.Ref, + context: this); } if (Global.Variables.GetBoolean(Constants.Runner.Features.SendJobLevelAnnotations) ?? false) diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 2ccad0c0ce2..3478e5616d8 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -369,6 +369,9 @@ private async Task CompleteJobAsync(IRunServer runServer, IExecution telemetry = jobContext.Global.JobTelemetry.Select(x => new Telemetry { Type = x.Type.ToString(), Message = x.Message, }).ToList(); } + // Flush OTel step spans before reporting job completion + await OTelStepTracer.FlushAsync(default); + Trace.Info($"Raising job completed against run service"); var completeJobRetryLimit = 5; var exceptions = new List(); @@ -450,6 +453,9 @@ private async Task CompleteJobAsync(IJobServer jobServer, IExecution // Make sure we don't submit secrets as telemetry MaskTelemetrySecrets(jobContext.Global.JobTelemetry); + // Flush OTel step spans before reporting job completion + await OTelStepTracer.FlushAsync(default); + Trace.Info($"Raising job completed event"); var jobCompletedEvent = new JobCompletedEvent(message.RequestId, message.JobId, result, jobContext.JobOutputs, jobContext.ActionsEnvironment, jobContext.Global.StepsTelemetry, jobContext.Global.JobTelemetry); diff --git a/src/Runner.Worker/OTelStepTracer.cs b/src/Runner.Worker/OTelStepTracer.cs new file mode 100644 index 00000000000..22710885a02 --- /dev/null +++ b/src/Runner.Worker/OTelStepTracer.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Sdk; + +namespace GitHub.Runner.Worker +{ + /// + /// Emits OTel trace spans for step execution using deterministic IDs + /// compatible with otel-explorer's GitHub Actions trace view. + /// + /// Enabled by setting ACTIONS_RUNNER_OTLP_ENDPOINT to an OTLP/HTTP base URL. + /// Spans are exported as OTLP JSON to {endpoint}/v1/traces. + /// + /// ID scheme (matches otel-explorer's pkg/githubapi/ids.go): + /// TraceID = MD5("{run_id}-{run_attempt}") + /// ParentID = derived from github.job context key + /// SpanID = MD5("runner-step-{run_id}-{step_name}")[:8] + /// + public static class OTelStepTracer + { + private static string s_endpoint; + private static bool s_initialized; + private static bool s_enabled; + private static HttpClient s_httpClient; + private static readonly object s_lock = new(); + private static readonly List s_pendingSpans = new(); + + private static void EnsureInitialized() + { + if (s_initialized) return; + lock (s_lock) + { + if (s_initialized) return; + s_endpoint = Environment.GetEnvironmentVariable("ACTIONS_RUNNER_OTLP_ENDPOINT")?.TrimEnd('/'); + s_enabled = !string.IsNullOrEmpty(s_endpoint); + if (s_enabled) + { + var insecure = StringUtil.ConvertToBoolean( + Environment.GetEnvironmentVariable("ACTIONS_RUNNER_OTLP_INSECURE")); + var handler = new HttpClientHandler(); + if (insecure) + { + handler.ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; + } + s_httpClient = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(5) + }; + } + s_initialized = true; + } + } + + public static bool IsEnabled + { + get + { + EnsureInitialized(); + return s_enabled; + } + } + + /// + /// Records a completed step as an OTel span. + /// Called from ExecutionContext.Complete() for Task-type records. + /// + public static void RecordStepCompletion( + string stepName, + int? stepNumber, + DateTime? startTime, + DateTime? endTime, + TaskResult? conclusion, + string stepType, + string actionName, + string actionRef, + IExecutionContext context) + { + if (!IsEnabled) return; + + try + { + var runIdStr = context.GetGitHubContext("run_id"); + var runAttemptStr = context.GetGitHubContext("run_attempt") ?? "1"; + var repository = context.GetGitHubContext("repository") ?? ""; + var workflow = context.GetGitHubContext("workflow") ?? ""; + var eventName = context.GetGitHubContext("event_name") ?? ""; + var serverUrl = context.GetGitHubContext("server_url") ?? "https://github.com"; + var jobName = context.GetGitHubContext("job") ?? ""; + + if (string.IsNullOrEmpty(runIdStr) || string.IsNullOrEmpty(stepName)) return; + + long.TryParse(runIdStr, out var runId); + long.TryParse(runAttemptStr, out var runAttempt); + if (runAttempt == 0) runAttempt = 1; + + var traceId = NewTraceID(runId, runAttempt); + var parentSpanId = NewSpanIDFromString(jobName); + var spanId = NewSpanIDFromString($"runner-step-{runId}-{stepName}"); + + var conclusionStr = conclusion?.ToString()?.ToLowerInvariant() ?? "unknown"; + var start = startTime ?? DateTime.UtcNow; + var end = endTime ?? DateTime.UtcNow; + + var span = new OTelSpan + { + TraceId = traceId, + SpanId = spanId, + ParentSpanId = parentSpanId, + Name = stepName, + StartTimeUnixNano = ToUnixNano(start), + EndTimeUnixNano = ToUnixNano(end), + Attributes = new Dictionary + { + ["type"] = "step", + ["source"] = "runner", + ["github.step_number"] = (stepNumber ?? 0).ToString(), + ["github.conclusion"] = NormalizeConclusion(conclusionStr), + ["github.repository"] = repository, + ["github.workflow"] = workflow, + ["github.event_name"] = eventName, + ["github.run_id"] = runIdStr, + ["github.run_attempt"] = runAttemptStr, + ["github.job"] = jobName, + ["cicd.pipeline.task.name"] = stepName, + ["cicd.pipeline.task.run.result"] = ConclusionToSemconv(NormalizeConclusion(conclusionStr)), + ["cicd.pipeline.run.id"] = runIdStr, + ["vcs.repository.url.full"] = $"{serverUrl}/{repository}", + } + }; + + if (!string.IsNullOrEmpty(stepType)) + span.Attributes["github.step_type"] = stepType; + if (!string.IsNullOrEmpty(actionName)) + span.Attributes["github.action"] = actionName; + if (!string.IsNullOrEmpty(actionRef)) + span.Attributes["github.action_ref"] = actionRef; + + lock (s_lock) + { + s_pendingSpans.Add(span); + } + } + catch + { + // OTel export is best-effort; never fail the step + } + } + + /// + /// Flushes all pending spans to the OTLP endpoint. + /// Called from JobRunner.CompleteJobAsync(). + /// + public static async Task FlushAsync(CancellationToken cancellationToken = default) + { + if (!IsEnabled) return; + + List toFlush; + lock (s_lock) + { + if (s_pendingSpans.Count == 0) return; + toFlush = new List(s_pendingSpans); + s_pendingSpans.Clear(); + } + + try + { + var json = BuildOTLPJson(toFlush); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var url = $"{s_endpoint}/v1/traces"; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + await s_httpClient.PostAsync(url, content, cts.Token); + } + catch + { + // Best-effort export + } + } + + internal static string NewTraceID(long runId, long runAttempt) + { + if (runAttempt == 0) runAttempt = 1; + var input = $"{runId}-{runAttempt}"; + var hash = MD5.HashData(Encoding.UTF8.GetBytes(input)); + return BytesToHex(hash); + } + + internal static string NewSpanID(long id) + { + var bytes = new byte[8]; + bytes[0] = (byte)(id >> 56); + bytes[1] = (byte)(id >> 48); + bytes[2] = (byte)(id >> 40); + bytes[3] = (byte)(id >> 32); + bytes[4] = (byte)(id >> 24); + bytes[5] = (byte)(id >> 16); + bytes[6] = (byte)(id >> 8); + bytes[7] = (byte)id; + return BytesToHex(bytes); + } + + internal static string NewSpanIDFromString(string s) + { + var hash = MD5.HashData(Encoding.UTF8.GetBytes(s)); + return BytesToHex(hash, 8); + } + + private static string NormalizeConclusion(string raw) + { + return (raw?.ToLowerInvariant()) switch + { + "succeeded" or "success" => "success", + "failed" or "failure" => "failure", + "cancelled" or "canceled" => "cancelled", + "skipped" => "skipped", + "timed_out" or "timedout" => "timed_out", + _ => raw ?? "unknown" + }; + } + + private static string ConclusionToSemconv(string conclusion) + { + return conclusion switch + { + "success" => "success", + "failure" => "failure", + "cancelled" => "cancellation", + "skipped" => "skip", + "timed_out" => "timeout", + _ => conclusion + }; + } + + private static string BytesToHex(byte[] bytes, int length = 0) + { + if (length <= 0) length = bytes.Length; + var sb = new StringBuilder(length * 2); + for (int i = 0; i < length; i++) + sb.Append(bytes[i].ToString("x2")); + return sb.ToString(); + } + + private static long ToUnixNano(DateTime dt) + { + return (dt.ToUniversalTime().Ticks - 621355968000000000L) * 100; + } + + private static string BuildOTLPJson(List spans) + { + var sb = new StringBuilder(); + sb.Append("{\"resourceSpans\":[{"); + sb.Append("\"resource\":{\"attributes\":["); + sb.Append("{\"key\":\"service.name\",\"value\":{\"stringValue\":\"github-actions-runner\"}}"); + sb.Append("]},"); + sb.Append("\"scopeSpans\":[{"); + sb.Append("\"scope\":{\"name\":\"actions.runner\"},"); + sb.Append("\"spans\":["); + + for (int i = 0; i < spans.Count; i++) + { + if (i > 0) sb.Append(','); + var s = spans[i]; + sb.Append('{'); + sb.Append($"\"traceId\":\"{s.TraceId}\","); + sb.Append($"\"spanId\":\"{s.SpanId}\","); + sb.Append($"\"parentSpanId\":\"{s.ParentSpanId}\","); + sb.Append($"\"name\":\"{JsonEscape(s.Name)}\","); + sb.Append("\"kind\":1,"); + sb.Append($"\"startTimeUnixNano\":\"{s.StartTimeUnixNano}\","); + sb.Append($"\"endTimeUnixNano\":\"{s.EndTimeUnixNano}\","); + sb.Append("\"attributes\":["); + int ai = 0; + foreach (var kv in s.Attributes) + { + if (ai > 0) sb.Append(','); + sb.Append($"{{\"key\":\"{JsonEscape(kv.Key)}\",\"value\":{{\"stringValue\":\"{JsonEscape(kv.Value)}\"}}}}"); + ai++; + } + sb.Append("],\"status\":{}}"); + } + + sb.Append("]}]}]}"); + return sb.ToString(); + } + + private static string JsonEscape(string s) + { + return s?.Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\n", "\\n") + .Replace("\r", "\\r") + .Replace("\t", "\\t") ?? ""; + } + + private class OTelSpan + { + public string TraceId { get; set; } + public string SpanId { get; set; } + public string ParentSpanId { get; set; } + public string Name { get; set; } + public long StartTimeUnixNano { get; set; } + public long EndTimeUnixNano { get; set; } + public Dictionary Attributes { get; set; } + } + + internal static void Reset() + { + lock (s_lock) + { + s_initialized = false; + s_enabled = false; + s_endpoint = null; + s_httpClient?.Dispose(); + s_httpClient = null; + s_pendingSpans.Clear(); + } + } + } +} diff --git a/src/Test/L0/Worker/OTelStepTracerL0.cs b/src/Test/L0/Worker/OTelStepTracerL0.cs new file mode 100644 index 00000000000..0e45ee3f653 --- /dev/null +++ b/src/Test/L0/Worker/OTelStepTracerL0.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Worker; +using Moq; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class OTelStepTracerL0 + { + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void TraceID_MatchesOtelExplorer() + { + // otel-explorer: MD5("99999-1") = known hex value + // Verify determinism and format + var tid1 = OTelStepTracer.NewTraceID(99999, 1); + var tid2 = OTelStepTracer.NewTraceID(99999, 1); + Assert.Equal(tid1, tid2); + Assert.Equal(32, tid1.Length); + + // Different attempt produces different trace ID + var tid3 = OTelStepTracer.NewTraceID(99999, 2); + Assert.NotEqual(tid1, tid3); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SpanID_BigEndian_MatchesOtelExplorer() + { + // otel-explorer: NewSpanID(42) = BigEndian(42) = 000000000000002a + var sid = OTelStepTracer.NewSpanID(42); + Assert.Equal("000000000000002a", sid); + Assert.Equal(16, sid.Length); + + // Deterministic + Assert.Equal(sid, OTelStepTracer.NewSpanID(42)); + + // Different input + Assert.NotEqual(sid, OTelStepTracer.NewSpanID(43)); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SpanIDFromString_Deterministic() + { + var sid1 = OTelStepTracer.NewSpanIDFromString("test-span"); + var sid2 = OTelStepTracer.NewSpanIDFromString("test-span"); + Assert.Equal(sid1, sid2); + Assert.Equal(16, sid1.Length); + + var sid3 = OTelStepTracer.NewSpanIDFromString("other-span"); + Assert.NotEqual(sid1, sid3); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void TraceID_CrossLanguageCompatibility() + { + // Verify that our C# MD5 produces the same trace ID as Go's md5.Sum + // for the same input string. This is critical for trace correlation. + // + // Go code: trace.TraceID(md5.Sum([]byte("99999-1"))) + // We can verify the MD5 hash is deterministic across implementations. + var tid = OTelStepTracer.NewTraceID(99999, 1); + + // MD5("99999-1") should be the same in any language + var expected = System.Security.Cryptography.MD5.HashData( + System.Text.Encoding.UTF8.GetBytes("99999-1")); + var expectedHex = BitConverter.ToString(expected).Replace("-", "").ToLowerInvariant(); + + Assert.Equal(expectedHex, tid); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void DefaultAttemptZero_TreatedAsOne() + { + var tidZero = OTelStepTracer.NewTraceID(99999, 0); + var tidOne = OTelStepTracer.NewTraceID(99999, 1); + Assert.Equal(tidZero, tidOne); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void IsEnabled_FalseByDefault() + { + OTelStepTracer.Reset(); + // Without ACTIONS_RUNNER_OTLP_ENDPOINT set, should be disabled + // (assuming test environment doesn't have it) + var original = Environment.GetEnvironmentVariable("ACTIONS_RUNNER_OTLP_ENDPOINT"); + try + { + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_OTLP_ENDPOINT", null); + OTelStepTracer.Reset(); + Assert.False(OTelStepTracer.IsEnabled); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_OTLP_ENDPOINT", original); + OTelStepTracer.Reset(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void IsEnabled_TrueWhenEndpointSet() + { + var original = Environment.GetEnvironmentVariable("ACTIONS_RUNNER_OTLP_ENDPOINT"); + try + { + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_OTLP_ENDPOINT", "http://localhost:4318"); + OTelStepTracer.Reset(); + Assert.True(OTelStepTracer.IsEnabled); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_RUNNER_OTLP_ENDPOINT", original); + OTelStepTracer.Reset(); + } + } + } +}