diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 79c0de5f706..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.Ordinal) + ? new Dictionary(ActionLookupKeyComparer.Instance) : null; var depth = 0; // We are running at the start of a job @@ -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); } @@ -858,7 +864,7 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, // Convert to action reference var actionReferences = actions - .GroupBy(x => GetDownloadInfoLookupKey(x)) + .GroupBy(x => GetDownloadInfoLookupKey(x), ActionLookupKeyComparer.Instance) .Where(x => !string.IsNullOrEmpty(x.Key)) .Select(x => { @@ -877,7 +883,7 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, // Nothing to resolve? if (actionReferences.Count == 0) { - return new Dictionary(); + return new Dictionary(ActionLookupKeyComparer.Instance); } // Resolve download info @@ -953,7 +959,7 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, } } - return actionDownloadInfos.Actions; + return new Dictionary(actionDownloadInfos.Actions, ActionLookupKeyComparer.Instance); } /// @@ -963,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.Ordinal); + var pendingKeys = new HashSet(ActionLookupKeyComparer.Instance); foreach (var action in actions) { var lookupKey = GetDownloadInfoLookupKey(action); @@ -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) @@ -1367,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)) 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/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index 501402a88b4..7d404380cdd 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -1449,6 +1449,113 @@ 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); + + // 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 + { + Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null); + Teardown(); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] 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(); + } + } + } +}