diff --git a/samples/OcelotOpenTracing/OcelotOpenTracing.csproj b/samples/OcelotOpenTracing/OcelotOpenTracing.csproj new file mode 100644 index 00000000..295fc461 --- /dev/null +++ b/samples/OcelotOpenTracing/OcelotOpenTracing.csproj @@ -0,0 +1,30 @@ + + + + Exe + netcoreapp3.1 + + + + + + + + + + + + + + + true + + + true + + + true + + + + diff --git a/samples/OcelotOpenTracing/Program.cs b/samples/OcelotOpenTracing/Program.cs new file mode 100644 index 00000000..5a2984c4 --- /dev/null +++ b/samples/OcelotOpenTracing/Program.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using System.IO; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using Microsoft.Extensions.Logging; +using Ocelot.Tracing.OpenTracing; +using Jaeger; +using Microsoft.Extensions.DependencyInjection; +using OpenTracing; +using OpenTracing.Util; + +namespace OcelotOpenTracing +{ + internal static class Program + { + private static void Main(string[] args) + { + + Host.CreateDefaultBuilder() + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseKestrel() + .ConfigureAppConfiguration((hostingContext, config) => + { + config + .SetBasePath(hostingContext.HostingEnvironment.ContentRootPath) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .AddJsonFile($"appsettings.{hostingContext.HostingEnvironment.EnvironmentName}.json", + optional: true, reloadOnChange: false) + .AddJsonFile("ocelot.json", optional: false, reloadOnChange: true) + .AddEnvironmentVariables(); + }) + .ConfigureServices((context, services) => + { + services + .AddOcelot() + .AddOpenTracing(); + + services.AddSingleton(sp => + { + var loggerFactory = sp.GetService(); + Configuration config = new Configuration(context.HostingEnvironment.ApplicationName, loggerFactory); + + var tracer = config.GetTracer(); + GlobalTracer.Register(tracer); + return tracer; + }); + + }) + .ConfigureLogging(logging => + { + logging.AddConsole(); + }) + .Configure(app => + { + app.UseOcelot().Wait(); + }); + }) + .Build() + .Run(); + } + } +} diff --git a/samples/OcelotOpenTracing/appsettings.Development.json b/samples/OcelotOpenTracing/appsettings.Development.json new file mode 100644 index 00000000..8983e0fc --- /dev/null +++ b/samples/OcelotOpenTracing/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/samples/OcelotOpenTracing/appsettings.json b/samples/OcelotOpenTracing/appsettings.json new file mode 100644 index 00000000..d9d9a9bf --- /dev/null +++ b/samples/OcelotOpenTracing/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/OcelotOpenTracing/ocelot.json b/samples/OcelotOpenTracing/ocelot.json new file mode 100644 index 00000000..a5a67032 --- /dev/null +++ b/samples/OcelotOpenTracing/ocelot.json @@ -0,0 +1,24 @@ +{ + "ReRoutes": [ + { + "HttpHandlerOptions": { + "UseTracing": true + }, + "DownstreamPathTemplate": "/todos/{id}", + "DownstreamScheme": "https", + "DownstreamHostAndPorts": [ + { + "Host": "jsonplaceholder.typicode.com", + "Port": 443 + } + ], + "UpstreamPathTemplate": "/posts/{id}", + "UpstreamHttpMethod": [ + "Get" + ] + } + ], + "GlobalConfiguration": { + "BaseUrl": "https://localhost:5000" + } +} diff --git a/src/Ocelot.Tracing.OpenTracing/Ocelot.Tracing.OpenTracing.csproj b/src/Ocelot.Tracing.OpenTracing/Ocelot.Tracing.OpenTracing.csproj new file mode 100644 index 00000000..c2a3e9fd --- /dev/null +++ b/src/Ocelot.Tracing.OpenTracing/Ocelot.Tracing.OpenTracing.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp3.1 + 0.0.0-dev + Kjell-Åke Gafvelin + This package provides OpenTracing support to Ocelot. + https://github.com/ThreeMammals/Ocelot + API Gateway;.NET core; OpenTracing + true + + + + + + + + + + + diff --git a/src/Ocelot.Tracing.OpenTracing/OcelotBuilderExtensions.cs b/src/Ocelot.Tracing.OpenTracing/OcelotBuilderExtensions.cs new file mode 100644 index 00000000..d6243d5d --- /dev/null +++ b/src/Ocelot.Tracing.OpenTracing/OcelotBuilderExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using Ocelot.DependencyInjection; +using Ocelot.Logging; +using System; + +namespace Ocelot.Tracing.OpenTracing +{ + public static class OcelotBuilderExtensions + { + public static IOcelotBuilder AddOpenTracing(this IOcelotBuilder builder) + { + builder.Services.AddSingleton(); + + return builder; + } + } +} diff --git a/src/Ocelot.Tracing.OpenTracing/OpenTracingTracer.cs b/src/Ocelot.Tracing.OpenTracing/OpenTracingTracer.cs new file mode 100644 index 00000000..875cd431 --- /dev/null +++ b/src/Ocelot.Tracing.OpenTracing/OpenTracingTracer.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Http; +using OpenTracing; +using OpenTracing.Propagation; +using OpenTracing.Tag; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Ocelot.Tracing.OpenTracing +{ + class OpenTracingTracer : Logging.ITracer + { + private readonly ITracer tracer; + + public OpenTracingTracer(ITracer tracer) + { + this.tracer = tracer ?? throw new ArgumentNullException(nameof(tracer)); + } + + public void Event(HttpContext httpContext, string @event) + { + } + + public async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken, + Action addTraceIdToRepo, Func> baseSendAsync) + { + using (IScope scope = this.tracer.BuildSpan(request.RequestUri.AbsoluteUri).StartActive(finishSpanOnDispose: true)) + { + var span = scope.Span; + + span.SetTag(Tags.SpanKind, Tags.SpanKindClient) + .SetTag(Tags.HttpMethod, request.Method.Method) + .SetTag(Tags.HttpUrl, request.RequestUri.OriginalString); + + addTraceIdToRepo(span.Context.SpanId); + + var headers = new Dictionary(); + + this.tracer.Inject(span.Context, BuiltinFormats.HttpHeaders, new TextMapInjectAdapter(headers)); + + foreach (var item in headers) + { + request.Headers.Add(item.Key, item.Value); + } + + try + { + var response = await baseSendAsync(request, cancellationToken); + + span.SetTag(Tags.HttpStatus, (int)response.StatusCode); + + return response; + } + catch (HttpRequestException ex) + { + Tags.Error.Set(scope.Span, true); + + span.Log(new Dictionary(3) + { + { LogFields.Event, Tags.Error.Key }, + { LogFields.ErrorKind, ex.GetType().Name }, + { LogFields.ErrorObject, ex } + }); + throw; + } + } + } + } +} diff --git a/test/Ocelot.AcceptanceTests/OpenTracingTests.cs b/test/Ocelot.AcceptanceTests/OpenTracingTests.cs new file mode 100644 index 00000000..f4dbaff4 --- /dev/null +++ b/test/Ocelot.AcceptanceTests/OpenTracingTests.cs @@ -0,0 +1,511 @@ +namespace Ocelot.AcceptanceTests +{ + using Butterfly.Client.AspNetCore; + using Ocelot.Configuration.File; + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Hosting; + using Microsoft.AspNetCore.Http; + using OpenTracing; + using OpenTracing.Propagation; + using OpenTracing.Tag; + using Rafty.Infrastructure; + using Shouldly; + using System; + using System.Collections.Generic; + using System.IO; + using System.Net; + using TestStack.BDDfy; + using Xunit; + using Xunit.Abstractions; + + public class OpenTracingTests : IDisposable + { + private IWebHost _serviceOneBuilder; + private IWebHost _serviceTwoBuilder; + private IWebHost _fakeOpenTracing; + private readonly Steps _steps; + private string _downstreamPathOne; + private string _downstreamPathTwo; + private readonly ITestOutputHelper _output; + + public OpenTracingTests(ITestOutputHelper output) + { + _output = output; + _steps = new Steps(); + } + + [Fact] + public void should_forward_tracing_information_from_ocelot_and_downstream_services() + { + int port1 = RandomPortFinder.GetRandomPort(); + int port2 = RandomPortFinder.GetRandomPort(); + var configuration = new FileConfiguration() + { + ReRoutes = new List() + { + new FileReRoute() + { + DownstreamPathTemplate = "/api/values", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new FileHostAndPort + { + Host = "localhost", + Port = port1, + } + }, + UpstreamPathTemplate = "/api001/values", + UpstreamHttpMethod = new List { "Get" }, + HttpHandlerOptions = new FileHttpHandlerOptions + { + UseTracing = true + } + }, + new FileReRoute() + { + DownstreamPathTemplate = "/api/values", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new FileHostAndPort() + { + Host = "localhost", + Port = port2, + } + }, + UpstreamPathTemplate = "/api002/values", + UpstreamHttpMethod = new List { "Get" }, + HttpHandlerOptions = new FileHttpHandlerOptions + { + UseTracing = true + } + } + } + }; + + var tracingPort = RandomPortFinder.GetRandomPort(); + var tracingUrl = $"http://localhost:{tracingPort}"; + + var fakeTracer = new FakeTracer(); + + this.Given(_ => GivenFakeOpenTracing(tracingUrl)) + .And(_ => GivenServiceOneIsRunning($"http://localhost:{port1}", "/api/values", 200, "Hello from Laura", tracingUrl)) + .And(_ => GivenServiceTwoIsRunning($"http://localhost:{port2}", "/api/values", 200, "Hello from Tom", tracingUrl)) + .And(_ => _steps.GivenThereIsAConfiguration(configuration)) + .And(_ => _steps.GivenOcelotIsRunningUsingOpenTracing(fakeTracer)) + .When(_ => _steps.WhenIGetUrlOnTheApiGateway("/api001/values")) + .Then(_ => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(_ => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .When(_ => _steps.WhenIGetUrlOnTheApiGateway("/api002/values")) + .Then(_ => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(_ => _steps.ThenTheResponseBodyShouldBe("Hello from Tom")) + .BDDfy(); + + var commandOnAllStateMachines = Wait.WaitFor(10000).Until(() => fakeTracer.BuildSpanCalled >= 2); + + _output.WriteLine($"fakeTracer.BuildSpanCalled is {fakeTracer.BuildSpanCalled}"); + + commandOnAllStateMachines.ShouldBeTrue(); + } + + [Fact] + public void should_return_tracing_header() + { + int port = RandomPortFinder.GetRandomPort(); + var configuration = new FileConfiguration + { + ReRoutes = new List + { + new FileReRoute + { + DownstreamPathTemplate = "/api/values", + DownstreamScheme = "http", + DownstreamHostAndPorts = new List + { + new FileHostAndPort + { + Host = "localhost", + Port = port, + } + }, + UpstreamPathTemplate = "/api001/values", + UpstreamHttpMethod = new List { "Get" }, + HttpHandlerOptions = new FileHttpHandlerOptions + { + UseTracing = true + }, + DownstreamHeaderTransform = new Dictionary() + { + {"Trace-Id", "{TraceId}"}, + {"Tom", "Laura"} + } + } + } + }; + + var butterflyPort = RandomPortFinder.GetRandomPort(); + var butterflyUrl = $"http://localhost:{butterflyPort}"; + + var fakeTracer = new FakeTracer(); + + this.Given(x => GivenFakeOpenTracing(butterflyUrl)) + .And(x => GivenServiceOneIsRunning($"http://localhost:{port}", "/api/values", 200, "Hello from Laura", butterflyUrl)) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunningUsingOpenTracing(fakeTracer)) + .When(x => _steps.WhenIGetUrlOnTheApiGateway("/api001/values")) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe("Hello from Laura")) + .And(x => _steps.ThenTheTraceHeaderIsSet("Trace-Id")) + .And(x => _steps.ThenTheResponseHeaderIs("Tom", "Laura")) + .BDDfy(); + } + + private void GivenServiceOneIsRunning(string baseUrl, string basePath, int statusCode, string responseBody, string butterflyUrl) + { + _serviceOneBuilder = new WebHostBuilder() + .UseUrls(baseUrl) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .ConfigureServices(services => + { + services.AddButterfly(option => + { + option.CollectorUrl = butterflyUrl; + option.Service = "Service One"; + option.IgnoredRoutesRegexPatterns = new string[0]; + }); + }) + .Configure(app => + { + app.UsePathBase(basePath); + app.Run(async context => + { + _downstreamPathOne = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; + + if (_downstreamPathOne != basePath) + { + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync("downstream path didnt match base path"); + } + else + { + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync(responseBody); + } + }); + }) + .Build(); + + _serviceOneBuilder.Start(); + } + + private void GivenFakeOpenTracing(string baseUrl) + { + _fakeOpenTracing = new WebHostBuilder() + .UseUrls(baseUrl) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .Configure(app => + { + app.Run(async context => + { + await context.Response.WriteAsync("OK..."); + }); + }) + .Build(); + + _fakeOpenTracing.Start(); + } + + private void GivenServiceTwoIsRunning(string baseUrl, string basePath, int statusCode, string responseBody, string butterflyUrl) + { + _serviceTwoBuilder = new WebHostBuilder() + .UseUrls(baseUrl) + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseIISIntegration() + .ConfigureServices(services => + { + services.AddButterfly(option => + { + option.CollectorUrl = butterflyUrl; + option.Service = "Service Two"; + option.IgnoredRoutesRegexPatterns = new string[0]; + }); + }) + .Configure(app => + { + app.UsePathBase(basePath); + app.Run(async context => + { + _downstreamPathTwo = !string.IsNullOrEmpty(context.Request.PathBase.Value) ? context.Request.PathBase.Value : context.Request.Path.Value; + + if (_downstreamPathTwo != basePath) + { + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync("downstream path didnt match base path"); + } + else + { + context.Response.StatusCode = statusCode; + await context.Response.WriteAsync(responseBody); + } + }); + }) + .Build(); + + _serviceTwoBuilder.Start(); + } + + public void Dispose() + { + _serviceOneBuilder?.Dispose(); + _serviceTwoBuilder?.Dispose(); + _fakeOpenTracing?.Dispose(); + _steps.Dispose(); + } + } + + internal class FakeTracer : ITracer + { + public IScopeManager ScopeManager => throw new NotImplementedException(); + + public ISpan ActiveSpan => throw new NotImplementedException(); + + public ISpanBuilder BuildSpan(string operationName) + { + this.BuildSpanCalled++; + + return new FakeSpanBuilder(); + } + + public int BuildSpanCalled { get; set; } + + public ISpanContext Extract(IFormat format, TCarrier carrier) + { + this.ExtractCalled++; + + return null; + } + + public int ExtractCalled { get; set; } + + public void Inject(ISpanContext spanContext, IFormat format, TCarrier carrier) + { + this.InjectCalled++; + } + + public int InjectCalled { get; set; } + } + + internal class FakeSpanBuilder : ISpanBuilder + { + public ISpanBuilder AddReference(string referenceType, ISpanContext referencedContext) + { + throw new NotImplementedException(); + } + + public ISpanBuilder AsChildOf(ISpanContext parent) + { + throw new NotImplementedException(); + } + + public ISpanBuilder AsChildOf(ISpan parent) + { + throw new NotImplementedException(); + } + + public ISpanBuilder IgnoreActiveSpan() + { + throw new NotImplementedException(); + } + + public ISpan Start() + { + throw new NotImplementedException(); + } + + public IScope StartActive() + { + throw new NotImplementedException(); + } + + public IScope StartActive(bool finishSpanOnDispose) + { + return new FakeScope(finishSpanOnDispose); + } + + public ISpanBuilder WithStartTimestamp(DateTimeOffset timestamp) + { + throw new NotImplementedException(); + } + + public ISpanBuilder WithTag(string key, string value) + { + throw new NotImplementedException(); + } + + public ISpanBuilder WithTag(string key, bool value) + { + throw new NotImplementedException(); + } + + public ISpanBuilder WithTag(string key, int value) + { + throw new NotImplementedException(); + } + + public ISpanBuilder WithTag(string key, double value) + { + throw new NotImplementedException(); + } + + public ISpanBuilder WithTag(BooleanTag tag, bool value) + { + throw new NotImplementedException(); + } + + public ISpanBuilder WithTag(IntOrStringTag tag, string value) + { + throw new NotImplementedException(); + } + + public ISpanBuilder WithTag(IntTag tag, int value) + { + throw new NotImplementedException(); + } + + public ISpanBuilder WithTag(StringTag tag, string value) + { + throw new NotImplementedException(); + } + } + + internal class FakeScope : IScope + { + private readonly bool finishSpanOnDispose; + + public FakeScope(bool finishSpanOnDispose) + { + this.finishSpanOnDispose = finishSpanOnDispose; + } + + public ISpan Span { get; } = new FakeSpan(); + + public void Dispose() + { + if (this.finishSpanOnDispose) + { + this.Span.Finish(); + } + } + } + + internal class FakeSpan : ISpan + { + public ISpanContext Context => new FakeSpanContext(); + + public void Finish() + { + } + + public void Finish(DateTimeOffset finishTimestamp) + { + throw new NotImplementedException(); + } + + public string GetBaggageItem(string key) + { + throw new NotImplementedException(); + } + + public ISpan Log(IEnumerable> fields) + { + return this; + } + + public ISpan Log(DateTimeOffset timestamp, IEnumerable> fields) + { + throw new NotImplementedException(); + } + + public ISpan Log(string @event) + { + throw new NotImplementedException(); + } + + public ISpan Log(DateTimeOffset timestamp, string @event) + { + throw new NotImplementedException(); + } + + public ISpan SetBaggageItem(string key, string value) + { + throw new NotImplementedException(); + } + + public ISpan SetOperationName(string operationName) + { + throw new NotImplementedException(); + } + + public ISpan SetTag(string key, string value) + { + return this; + } + + public ISpan SetTag(string key, bool value) + { + return this; + } + + public ISpan SetTag(string key, int value) + { + return this; + } + + public ISpan SetTag(string key, double value) + { + return this; + } + + public ISpan SetTag(BooleanTag tag, bool value) + { + return this; + } + + public ISpan SetTag(IntOrStringTag tag, string value) + { + return this; + } + + public ISpan SetTag(IntTag tag, int value) + { + return this; + } + + public ISpan SetTag(StringTag tag, string value) + { + return this; + } + } + + internal class FakeSpanContext : ISpanContext + { + public static string FakeTraceId = "FakeTraceId"; + + public static string FakeSpanId = "FakeSpanId"; + + public string TraceId => FakeTraceId; + + public string SpanId => FakeSpanId; + + public IEnumerable> GetBaggageItems() + { + throw new NotImplementedException(); + } + } +}