diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe1152b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e42ddbf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,243 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Don't use tabs for indentation. +[*] +indent_style = space +charset = utf-8 +insert_final_newline = true +# (Please don't specify an indent_size here; that has too many unintended consequences.) + +# Code files +[*.{cs,csx,vb,vbx}] +indent_size = 4 + +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 4 + +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 4 + +# JSON files +[*.json] +indent_size = 4 + +# Powershell files +[*.ps1] +indent_size = 4 + +# Shell script files +[*.sh] +end_of_line = lf +indent_size = 4 + +# Dotnet code style settings: +[*.{cs,vb}] +# IDE0055: Fix formatting +dotnet_diagnostic.IDE0055.severity = warning + +# Sort using and Import directives with System.* appearing first +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +# Avoid "this." and "Me." if not necessary +dotnet_style_qualification_for_field = false:refactoring +dotnet_style_qualification_for_property = true:refactoring +dotnet_style_qualification_for_method = true:refactoring +dotnet_style_qualification_for_event = true:refactoring + +# Use language keywords instead of framework type names for type references +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Suggest more modern language features when available +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion + +# accessibility +dotnet_style_require_accessibility_modifiers = always + +# Whitespace options +dotnet_style_allow_multiple_blank_lines_experimental = false + +# Non-private static fields are PascalCase +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = warning +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style + +dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_static_fields.required_modifiers = static + +dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case + +# Non-private readonly fields are PascalCase +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.severity = warning +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.symbols = non_private_readonly_fields +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.style = non_private_readonly_field_style + +dotnet_naming_symbols.non_private_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_readonly_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_readonly_fields.required_modifiers = readonly + +dotnet_naming_style.non_private_readonly_field_style.capitalization = pascal_case + +# Constants are PascalCase +dotnet_naming_rule.constants_should_be_pascal_case.severity = warning +dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants +dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style + +dotnet_naming_symbols.constants.applicable_kinds = field, local +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_style.constant_style.capitalization = pascal_case + +# Static fields are PascalCase +dotnet_naming_rule.static_fields_should_be_camel_case.severity = warning +dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields +dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style + +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static + +dotnet_naming_style.static_field_style.capitalization = pascal_case + +# Instance fields are camelCase and start with _ +dotnet_naming_rule.instance_fields_should_be_camel_case.severity = warning +dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields +dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style + +dotnet_naming_symbols.instance_fields.applicable_kinds = field + +dotnet_naming_style.instance_field_style.capitalization = camel_case +dotnet_naming_style.instance_field_style.required_prefix = _ + +# Locals and parameters are camelCase +dotnet_naming_rule.locals_should_be_camel_case.severity = warning +dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters +dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style + +dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local + +dotnet_naming_style.camel_case_style.capitalization = camel_case + +# Local functions are PascalCase +dotnet_naming_rule.local_functions_should_be_pascal_case.severity = warning +dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function + +dotnet_naming_style.local_function_style.capitalization = pascal_case + +# By default, name items with PascalCase +dotnet_naming_rule.members_should_be_pascal_case.severity = warning +dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members +dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.all_members.applicable_kinds = * + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# error RS2008: Enable analyzer release tracking for the analyzer project containing rule '{0}' +dotnet_diagnostic.RS2008.severity = none + +# IDE0035: Remove unreachable code +dotnet_diagnostic.IDE0035.severity = warning + +# IDE0036: Order modifiers +dotnet_diagnostic.IDE0036.severity = warning + +# IDE0043: Format string contains invalid placeholder +dotnet_diagnostic.IDE0043.severity = warning + +# IDE0044: Make field readonly +dotnet_diagnostic.IDE0044.severity = warning + +# RS0016: Only enable if API files are present +dotnet_public_api_analyzer.require_api_files = true + +# CSharp code style settings: +[*.cs] +# Newline settings +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left + +# Whitespace options +csharp_style_allow_embedded_statements_on_same_line_experimental = false +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false + +# Prefer "var" except for built in type. +csharp_style_var_for_built_in_types = false:none +csharp_style_var_when_type_is_apparent = false:none +csharp_style_var_elsewhere = false:none + +# Prefer method-like constructs to have a block body +csharp_style_expression_bodied_methods = false:none +csharp_style_expression_bodied_constructors = false:suggestion +csharp_style_expression_bodied_operators = false:none + +# Prefer property-like constructs to have an expression-body +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_accessors = when_on_single_line:none + +# Suggest more modern language features when available +csharp_style_pattern_matching_over_is_with_cast_check = false:none +csharp_style_pattern_matching_over_as_with_null_check = false:none +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = do_not_ignore +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Blocks are allowed +csharp_prefer_braces = true:silent +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true +csharp_style_namespace_declarations = file_scoped:error + +csharp_style_implicit_object_creation_when_type_is_apparent = false +csharp_style_prefer_primary_constructors = false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..bc18f00 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml new file mode 100644 index 0000000..7477e03 --- /dev/null +++ b/.github/workflows/build-and-test.yaml @@ -0,0 +1,28 @@ +name: build-and-test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + DOTNET_VERSION: "8.0.x" + DOTNET_NOLOGO: true + +jobs: + build: + name: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore dependencies + run: dotnet restore ./AspNetCore.SignalR.OpenTelemetry.sln + + - name: Build AspNetCore.SignalR.OpenTelemetry.csproj + run: dotnet build ./src/AspNetCore.SignalR.OpenTelemetry/AspNetCore.SignalR.OpenTelemetry.csproj --no-restore diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..92f754e --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,38 @@ +name: release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+*" + +env: + DOTNET_VERSION: "8.0.x" + DOTNET_NOLOGO: true + +jobs: + release: + name: release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Get version from git tag + run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV + + - name: dotnet build + run: dotnet build -c Release -p:Version=${{ env.RELEASE_VERSION }} ./src/AspNetCore.SignalR.OpenTelemetry/AspNetCore.SignalR.OpenTelemetry.csproj + + - name: dotnet pack + run: dotnet pack -c Release --no-build --output ${{ github.workspace }}/artifacts -p:Version=${{ env.RELEASE_VERSION }} ./src/AspNetCore.SignalR.OpenTelemetry/AspNetCore.SignalR.OpenTelemetry.csproj + + - uses: actions/upload-artifact@v3 + with: + name: Packages + path: ${{ github.workspace }}/artifacts + + - name: dotnet nuget push + run: dotnet nuget push ${{ github.workspace }}/artifacts/*.nupkg --skip-duplicate -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d544b6c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "markdown.extension.toc.omittedFromToc": { + "README.md": [ + "## Table of Contents" + ] + } +} diff --git a/AspNetCore.SignalR.OpenTelemetry.sln b/AspNetCore.SignalR.OpenTelemetry.sln new file mode 100644 index 0000000..84d023c --- /dev/null +++ b/AspNetCore.SignalR.OpenTelemetry.sln @@ -0,0 +1,45 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.34309.116 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNetCore.SignalR.OpenTelemetry", "src\AspNetCore.SignalR.OpenTelemetry\AspNetCore.SignalR.OpenTelemetry.csproj", "{E95D38CD-444A-475E-96B9-3328D92F4907}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EB2B2EF9-BB9D-4CD4-8D12-7A962D73A97C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{CF4C3165-FE24-4052-96BD-A07B7042D687}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server", "examples\Server\Server.csproj", "{CBA953AF-96E5-4E06-B0AE-2036192472A3}" +EndProject +Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{ECD4DC7A-3A6D-4A98-8461-C92D6D292991}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E95D38CD-444A-475E-96B9-3328D92F4907}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E95D38CD-444A-475E-96B9-3328D92F4907}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E95D38CD-444A-475E-96B9-3328D92F4907}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E95D38CD-444A-475E-96B9-3328D92F4907}.Release|Any CPU.Build.0 = Release|Any CPU + {CBA953AF-96E5-4E06-B0AE-2036192472A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CBA953AF-96E5-4E06-B0AE-2036192472A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBA953AF-96E5-4E06-B0AE-2036192472A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CBA953AF-96E5-4E06-B0AE-2036192472A3}.Release|Any CPU.Build.0 = Release|Any CPU + {ECD4DC7A-3A6D-4A98-8461-C92D6D292991}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ECD4DC7A-3A6D-4A98-8461-C92D6D292991}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ECD4DC7A-3A6D-4A98-8461-C92D6D292991}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ECD4DC7A-3A6D-4A98-8461-C92D6D292991}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {E95D38CD-444A-475E-96B9-3328D92F4907} = {EB2B2EF9-BB9D-4CD4-8D12-7A962D73A97C} + {CBA953AF-96E5-4E06-B0AE-2036192472A3} = {CF4C3165-FE24-4052-96BD-A07B7042D687} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {1A042681-3BD0-4942-9D92-6A67F0587FAD} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6248d2 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# AspNetCore.SignalR.OpenTelemetry + +This is an [Instrumentation Library](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/glossary.md#instrumentation-library), which instruments ASP.NET Core SignalR and collect metrics and traces about SignalR hub method invocations. + +## Table of Contents + +- [Install](#install) +- [Usage](#usage) +- [Example](#example) + +## Install + +NuGet: [AspNetCore.SignalR.OpenTelemetry](https://www.nuget.org/packages/AspNetCore.SignalR.OpenTelemetry/) + +``` +dotnet add package AspNetCore.SignalR.OpenTelemetry +``` + +## Usage + +```cs +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSignalR(options => +{ + options.AddFilter(); // <- Add this! +}); + +builder.Services.AddOpenTelemetry() + .ConfigureResource(builder => + { + builder.AddService("AspNetCore.SignalR.OpenTelemetry.Example"); + }) + .WithTracing(providerBuilder => + { + providerBuilder + .AddAspNetCoreInstrumentation() + .AddSignalRInstrumentation() // <- Add this! + .AddOtlpExporter(); + }); +``` + +## Example + +The example code architecture is as follows. + +```mermaid +graph LR; + app[ASP.NET Core Server] --> otelc[OpenTelemetry Collector]; + otelc --> Tempo; + Grafana --> Tempo; +``` + +The example code can be quickly executed from Visual Studio. + +![Docker](https://github.com/nenoNaninu/AspNetCore.SignalR.OpenTelemetry/assets/27144255/f03797a8-1d85-48ce-b5df-2da5ea9c2039) + +It can also be quickly executed from the CLI. + +``` +$ docker compose build +$ docker compose up +``` + +- App Server: http://localhost:8080/signalr-dev/index.html +- Grafana: http://localhost:3000/explore + +In Grafana, you can see the SignalR method call trace as follows. + +![Trace](https://github.com/nenoNaninu/AspNetCore.SignalR.OpenTelemetry/assets/27144255/eac66809-56f4-49e9-b09e-d2379805f795) diff --git a/containers/grafana/provisioning/datasources.yml b/containers/grafana/provisioning/datasources.yml new file mode 100644 index 0000000..25ce873 --- /dev/null +++ b/containers/grafana/provisioning/datasources.yml @@ -0,0 +1,14 @@ +apiVersion: 1 + +datasources: +- name: Tempo + type: tempo + access: proxy + orgId: 1 + url: http://tempo:3200 + basicAuth: false + isDefault: true + version: 1 + editable: false + apiVersion: 1 + uid: tempo diff --git a/containers/opentelemetry/otel-collector.yml b/containers/opentelemetry/otel-collector.yml new file mode 100644 index 0000000..f0ad04b --- /dev/null +++ b/containers/opentelemetry/otel-collector.yml @@ -0,0 +1,19 @@ +receivers: + otlp: + protocols: + grpc: + +exporters: + prometheus: + endpoint: "0.0.0.0:8889" + otlp: + endpoint: tempo:4317 + tls: + insecure: true + debug: + +service: + pipelines: + traces: + receivers: [otlp] + exporters: [otlp, debug] diff --git a/containers/tempo/tempo.yml b/containers/tempo/tempo.yml new file mode 100644 index 0000000..edbdc31 --- /dev/null +++ b/containers/tempo/tempo.yml @@ -0,0 +1,43 @@ +server: + http_listen_port: 3200 + +query_frontend: + search: + duration_slo: 5s + throughput_bytes_slo: 1.073741824e+09 + trace_by_id: + duration_slo: 5s + +distributor: + receivers: # this configuration will listen on all ports and protocols that tempo is capable of. + otlp: + protocols: + grpc: + +ingester: + max_block_duration: 5m # cut the headblock when this much time passes. this is being set for demo purposes and should probably be left alone normally + +compactor: + compaction: + block_retention: 1h # overall Tempo trace retention. set for demo purposes + +metrics_generator: + registry: + external_labels: + source: tempo + cluster: docker-compose + storage: + path: /tmp/tempo/generator/wal + +storage: + trace: + backend: local # backend configuration to use + wal: + path: /tmp/tempo/wal # where to store the the wal locally + local: + path: /tmp/tempo/blocks + +overrides: + defaults: + metrics_generator: + processors: [service-graphs, span-metrics] # enables metrics generator diff --git a/docker-compose.dcproj b/docker-compose.dcproj new file mode 100644 index 0000000..e98e2df --- /dev/null +++ b/docker-compose.dcproj @@ -0,0 +1,19 @@ + + + + 2.1 + Linux + False + ecd4dc7a-3a6d-4a98-8461-c92d6d292991 + LaunchBrowser + {Scheme}://{ServiceHost}:{ServicePort}/signalr-dev + server + + + + docker-compose.yml + + + + + \ No newline at end of file diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..6c3d715 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,11 @@ +version: '3.4' + +services: + server: + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_HTTP_PORTS=8080 + ports: + - "8080" + volumes: + - ${APPDATA}/Microsoft/UserSecrets:/home/app/.microsoft/usersecrets:ro \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9d7291d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +version: '3.4' + +services: + server: + image: ${DOCKER_REGISTRY-}server + build: + context: . + dockerfile: examples/Server/Dockerfile + ports: + - "8080:8080" + + otel-collector: + image: otel/opentelemetry-collector:0.89.0 + restart: always + command: ["--config=/etc/otel-collector.yml"] + volumes: + - ./containers/opentelemetry/otel-collector.yml:/etc/otel-collector.yml + ports: + - "4317:4317" # otlp grpc + depends_on: + - tempo + + # To eventually offload to Tempo... + tempo: + image: grafana/tempo:2.3.0 + command: [ "-config.file=/etc/tempo.yml" ] + volumes: + - ./containers/tempo/tempo.yml:/etc/tempo.yml + ports: + - "3200" # tempo + - "4317" # otlp grpc + + grafana: + image: grafana/grafana:10.2.0-ubuntu + volumes: + - ./containers/grafana/provisioning/datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml + ports: + - 3000:3000 + environment: + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_AUTH_DISABLE_LOGIN_FORM=true + - GF_FEATURE_TOGGLES_ENABLE=traceqlEditor + depends_on: + - tempo diff --git a/examples/Server/Controllers/WeatherForecastController.cs b/examples/Server/Controllers/WeatherForecastController.cs new file mode 100644 index 0000000..3d1f7b2 --- /dev/null +++ b/examples/Server/Controllers/WeatherForecastController.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Server.Controllers; +[ApiController] +[Route("[controller]")] +public class WeatherForecastController : ControllerBase +{ + private static readonly string[] Summaries = new[] + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + + private readonly ILogger _logger; + + public WeatherForecastController(ILogger logger) + { + _logger = logger; + } + + [HttpGet(Name = "GetWeatherForecast")] + public IEnumerable Get() + { + return Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = Summaries[Random.Shared.Next(Summaries.Length)] + }) + .ToArray(); + } +} diff --git a/examples/Server/Dockerfile b/examples/Server/Dockerfile new file mode 100644 index 0000000..13c64f8 --- /dev/null +++ b/examples/Server/Dockerfile @@ -0,0 +1,25 @@ +#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +USER app +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["examples/Server/Server.csproj", "examples/Server/"] +RUN dotnet restore "./examples/Server/./Server.csproj" +COPY . . +WORKDIR "/src/examples/Server" +RUN dotnet build "./Server.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./Server.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Server.dll"] \ No newline at end of file diff --git a/examples/Server/Hubs/ChatHub.cs b/examples/Server/Hubs/ChatHub.cs new file mode 100644 index 0000000..bce19e0 --- /dev/null +++ b/examples/Server/Hubs/ChatHub.cs @@ -0,0 +1,126 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.SignalR; +using TypedSignalR.Client; + +namespace Server.Hubs; + +public sealed record Message( + Guid UserId, + string UserName, + string Text, + DateTime DateTime +); + +[Receiver] +public interface IChatHubReceiver +{ + Task OnEnter(string userName); + Task OnMessage(Message message); +} + +[Hub] +public interface IChatHub +{ + Task EnterRoom(Guid roomId, string userName); + Task PostMessage(string text); + Task GetMessages(); + Task Error(); +} + +public class ChatHub : Hub, IChatHub +{ + private readonly IMessageRepository _messageRepository; + + public ChatHub(IMessageRepository messageRepository) + { + _messageRepository = messageRepository; + } + + public async Task EnterRoom(Guid roomId, string userName) + { + var groupName = roomId.ToString(); + + this.ConnectionState = new ChatHubConnectionState(roomId, groupName, Guid.NewGuid(), userName); + + await this.Groups.AddToGroupAsync(this.Context.ConnectionId, groupName, this.Context.ConnectionAborted); + + await this.Clients.Group(groupName).OnEnter(userName); + } + + public async Task PostMessage(string text) + { + ThrowIfStateIsNull(); + + var state = this.ConnectionState; + + var message = new Message(state.UserId, state.UserName, text, DateTime.UtcNow); + + await _messageRepository.AddMessageAsync(state.RoomId, message); + + await this.Clients.Group(state.GroupName).OnMessage(message); + } + + public async Task GetMessages() + { + ThrowIfStateIsNull(); + + var state = this.ConnectionState; + + var messages = await _messageRepository.GetMessagesAsync(state.RoomId); + + return messages; + } + + [MemberNotNull(nameof(ConnectionState))] + private void ThrowIfStateIsNull() + { + if (this.ConnectionState is null) + { + throw new HubException(":cry:"); + } + } + + public Task Error() + { + //throw new InvalidOperationException("ChatHub error"); + throw new HubException("ChatHub error"); + } + + private ChatHubConnectionState? ConnectionState + { + get => this.Context.Items["ConnectionState"] as ChatHubConnectionState; + set => this.Context.Items["ConnectionState"] = value; + } + + private record ChatHubConnectionState(Guid RoomId, string GroupName, Guid UserId, string UserName); +} + +public interface IMessageRepository +{ + ValueTask AddMessageAsync(Guid roomId, Message message); + ValueTask GetMessagesAsync(Guid roomId); +} + +public class MessageRepository : IMessageRepository +{ + private readonly ConcurrentDictionary> _dictionary = new(); + + public ValueTask AddMessageAsync(Guid roomId, Message message) + { + var bag = _dictionary.GetOrAdd(roomId, static _ => new ConcurrentBag()); + bag.Add(message); + + return ValueTask.CompletedTask; + } + + public ValueTask GetMessagesAsync(Guid roomId) + { + if (_dictionary.TryGetValue(roomId, out var bag)) + { + return ValueTask.FromResult(bag.ToArray()); + } + + return ValueTask.FromResult(Array.Empty()); + } +} diff --git a/examples/Server/Program.cs b/examples/Server/Program.cs new file mode 100644 index 0000000..61c1cde --- /dev/null +++ b/examples/Server/Program.cs @@ -0,0 +1,57 @@ +using AspNetCore.SignalR.OpenTelemetry; +using Microsoft.AspNetCore.SignalR; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using Server.Hubs; +using TypedSignalR.Client.DevTools; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Logging.AddSimpleConsole(options => +{ + options.IncludeScopes = true; +}); + +builder.Services.AddControllers(); + +builder.Services.AddSignalR(options => +{ + options.AddFilter(); +}); + +builder.Services.AddOpenTelemetry() + .ConfigureResource(builder => + { + builder.AddService("AspNetCore.SignalR.OpenTelemetry.Example"); + }) + .WithTracing(providerBuilder => + { + providerBuilder + .AddAspNetCoreInstrumentation() + .AddSignalRInstrumentation() + .AddOtlpExporter(); + }); + + +builder.Services.AddSingleton(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSignalRHubSpecification(); + app.UseSignalRHubDevelopmentUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapHub("/hubs/ChatHub"); + +app.MapControllers(); + +app.Run(); diff --git a/examples/Server/Properties/launchSettings.json b/examples/Server/Properties/launchSettings.json new file mode 100644 index 0000000..2b896d6 --- /dev/null +++ b/examples/Server/Properties/launchSettings.json @@ -0,0 +1,16 @@ +{ + "profiles": { + "Docker": { + "commandName": "Docker", + "launchBrowser": true, + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/signalr-dev", + "environmentVariables": { + "ASPNETCORE_HTTPS_PORTS": "8081", + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": true + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json" +} diff --git a/examples/Server/Server.csproj b/examples/Server/Server.csproj new file mode 100644 index 0000000..d7ef30c --- /dev/null +++ b/examples/Server/Server.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + true + c3d55fdb-32d7-45e5-8816-18c67494ea5f + Linux + ..\.. + ..\..\docker-compose.dcproj + + + + + + + + + + + + + + + + diff --git a/examples/Server/Server.http b/examples/Server/Server.http new file mode 100644 index 0000000..4d4b68e --- /dev/null +++ b/examples/Server/Server.http @@ -0,0 +1,6 @@ +@Server_HostAddress = http://localhost:5207 + +GET {{Server_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/examples/Server/WeatherForecast.cs b/examples/Server/WeatherForecast.cs new file mode 100644 index 0000000..cb71736 --- /dev/null +++ b/examples/Server/WeatherForecast.cs @@ -0,0 +1,12 @@ +namespace Server; + +public class WeatherForecast +{ + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 1.5556); + + public string? Summary { get; set; } +} diff --git a/examples/Server/appsettings.Development.json b/examples/Server/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/examples/Server/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/examples/Server/appsettings.json b/examples/Server/appsettings.json new file mode 100644 index 0000000..73752f5 --- /dev/null +++ b/examples/Server/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://otel-collector:4317" +} diff --git a/launchSettings.json b/launchSettings.json new file mode 100644 index 0000000..d6c4bf2 --- /dev/null +++ b/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Docker Compose": { + "commandName": "DockerCompose", + "commandVersion": "1.0", + "serviceActions": { + "server": "StartDebugging" + } + } + } +} \ No newline at end of file diff --git a/src/AspNetCore.SignalR.OpenTelemetry/AspNetCore.SignalR.OpenTelemetry.csproj b/src/AspNetCore.SignalR.OpenTelemetry/AspNetCore.SignalR.OpenTelemetry.csproj new file mode 100644 index 0000000..baf79d5 --- /dev/null +++ b/src/AspNetCore.SignalR.OpenTelemetry/AspNetCore.SignalR.OpenTelemetry.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + + + AspNetCore.SignalR.OpenTelemetry + AspNetCore.SignalR.OpenTelemetry + AspNetCore.SignalR.OpenTelemetry + SignalR instrumentation for OpenTelemetry .NET + nenoNaninu + https://github.com/nenoNaninu/AspNetCore.SignalR.OpenTelemetry + $(RepositoryUrl) + MIT + git + signalr;opentelemetry + (c) nenoNaninu + + + + + + + + + + + diff --git a/src/AspNetCore.SignalR.OpenTelemetry/HubInstrumentationFilter.cs b/src/AspNetCore.SignalR.OpenTelemetry/HubInstrumentationFilter.cs new file mode 100644 index 0000000..0024d88 --- /dev/null +++ b/src/AspNetCore.SignalR.OpenTelemetry/HubInstrumentationFilter.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading.Tasks; +using AspNetCore.SignalR.OpenTelemetry.Internal; +using Microsoft.AspNetCore.Http.Connections; +using Microsoft.AspNetCore.Http.Connections.Features; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +namespace AspNetCore.SignalR.OpenTelemetry; + +public sealed class HubInstrumentationFilter : IHubFilter +{ + private readonly ILogger _logger; + + public HubInstrumentationFilter(ILogger logger) + { + _logger = logger; + } + + public async ValueTask InvokeMethodAsync( + HubInvocationContext invocationContext, + Func> next) + { + var hubName = invocationContext.Hub.GetType().Name; + var methodName = invocationContext.HubMethodName; + + using var scope = HubLogger.BeginHubMethodInvocationScope(_logger, hubName, methodName); + using var activity = HubActivitySource.StartInvocationActivity(hubName, methodName); + + try + { + HubLogger.LogHubMethodInvocation(_logger, hubName, methodName); + + var stopwatch = ValueStopwatch.StartNew(); + + var result = await next(invocationContext); + + var duration = stopwatch.GetElapsedTime(); + + HubLogger.LogHubMethodInvocationDuration(_logger, duration.TotalMilliseconds); + HubActivitySource.StopInvocationActivityOk(activity); + + return result; + } + catch (Exception exception) + { + HubActivitySource.StopInvocationActivityError(activity, exception); + throw; + } + } + + public Task OnConnectedAsync(HubLifetimeContext context, Func next) + { + var hubName = context.Hub.GetType().Name; + + var transport = context.Context.Features.Get(); + HubLogger.LogOnConnected(_logger, hubName, transport?.TransportType ?? HttpTransportType.None); + + return next(context); + } + + public Task OnDisconnectedAsync( + HubLifetimeContext context, + Exception? exception, + Func next) + { + var hubName = context.Hub.GetType().Name; + + if (exception is null) + { + HubLogger.LogOnDisconnected(_logger, hubName); + } + else + { + HubLogger.LogOnDisconnectedWithError(_logger, hubName, exception); + } + + return next(context, exception); + } +} diff --git a/src/AspNetCore.SignalR.OpenTelemetry/Internal/HubActivitySource.cs b/src/AspNetCore.SignalR.OpenTelemetry/Internal/HubActivitySource.cs new file mode 100644 index 0000000..eed6a15 --- /dev/null +++ b/src/AspNetCore.SignalR.OpenTelemetry/Internal/HubActivitySource.cs @@ -0,0 +1,32 @@ +using System; +using System.Diagnostics; + +namespace AspNetCore.SignalR.OpenTelemetry.Internal; + +internal static class HubActivitySource +{ + internal const string Name = "SignalR.Hub"; + + private static readonly ActivitySource ActivitySource = new(Name); + + internal static Activity? StartInvocationActivity(string hubName, string methodName) + { + var activity = ActivitySource.CreateActivity($"{hubName}.{methodName}", ActivityKind.Internal); + + activity?.SetTag("signalr.hub", hubName); + activity?.SetTag("signalr.method", methodName); + + return activity?.Start(); + } + + internal static void StopInvocationActivityOk(Activity? activity) + { + activity?.SetTag("otel.status_code", "OK"); + } + + internal static void StopInvocationActivityError(Activity? activity, Exception exception) + { + activity?.SetTag("otel.status_code", "ERROR"); + activity?.SetTag("signalr.hub.exception", exception.ToString()); + } +} diff --git a/src/AspNetCore.SignalR.OpenTelemetry/Internal/HubLogger.cs b/src/AspNetCore.SignalR.OpenTelemetry/Internal/HubLogger.cs new file mode 100644 index 0000000..3530939 --- /dev/null +++ b/src/AspNetCore.SignalR.OpenTelemetry/Internal/HubLogger.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.AspNetCore.Http.Connections; +using Microsoft.Extensions.Logging; + +namespace AspNetCore.SignalR.OpenTelemetry.Internal; + +internal static partial class HubLogger +{ + [LoggerMessage(8200, LogLevel.Information, "SignalR connection established to {HubName} over {TransportType}")] + public static partial void LogOnConnected(ILogger logger, string hubName, HttpTransportType transportType); + + [LoggerMessage(8201, LogLevel.Information, "Invoking the SignalR hub method {HubName}.{MethodName}")] + public static partial void LogHubMethodInvocation(ILogger logger, string hubName, string methodName); + + [LoggerMessage(8202, LogLevel.Information, "Duration: {Duration}ms")] + public static partial void LogHubMethodInvocationDuration(ILogger logger, double duration); + + [LoggerMessage(8208, LogLevel.Information, "SignalR connection to {HubName} was disconnected")] + public static partial void LogOnDisconnected(ILogger logger, string hubName); + + [LoggerMessage(8209, LogLevel.Information, "SignalR connection to {HubName} was disconnected with exception")] + public static partial void LogOnDisconnectedWithError(ILogger logger, string hubName, Exception exception); + + private static readonly Func BeginHubMethodInvocationScopeCallback + = LoggerMessage.DefineScope("HubName:{HubName}, MethodName:{MethodName}, InvocationId:{InvocationId}"); + + public static IDisposable? BeginHubMethodInvocationScope(ILogger logger, string hubName, string methodName) + { + return BeginHubMethodInvocationScopeCallback(logger, hubName, methodName, Guid.NewGuid()); + } +} diff --git a/src/AspNetCore.SignalR.OpenTelemetry/Internal/ValueStopwatch.cs b/src/AspNetCore.SignalR.OpenTelemetry/Internal/ValueStopwatch.cs new file mode 100644 index 0000000..12ca665 --- /dev/null +++ b/src/AspNetCore.SignalR.OpenTelemetry/Internal/ValueStopwatch.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace AspNetCore.SignalR.OpenTelemetry.Internal; + +internal readonly struct ValueStopwatch +{ + private readonly long _startTimestamp; + + public bool IsActive => _startTimestamp != 0; + + private ValueStopwatch(long startTimestamp) + { + _startTimestamp = startTimestamp; + } + + public static ValueStopwatch StartNew() => new ValueStopwatch(Stopwatch.GetTimestamp()); + + public TimeSpan GetElapsedTime() + { + if (!IsActive) + { + ThrowInvalidOperationException(); + } + + var end = Stopwatch.GetTimestamp(); + + return Stopwatch.GetElapsedTime(_startTimestamp, end); + } + + [DoesNotReturn] + private static void ThrowInvalidOperationException() + { + throw new InvalidOperationException("An uninitialized, or 'default', ValueStopwatch cannot be used to get elapsed time."); + } +} diff --git a/src/AspNetCore.SignalR.OpenTelemetry/TracerProviderBuilderExtensions.cs b/src/AspNetCore.SignalR.OpenTelemetry/TracerProviderBuilderExtensions.cs new file mode 100644 index 0000000..f2c26bc --- /dev/null +++ b/src/AspNetCore.SignalR.OpenTelemetry/TracerProviderBuilderExtensions.cs @@ -0,0 +1,12 @@ +using AspNetCore.SignalR.OpenTelemetry.Internal; +using OpenTelemetry.Trace; + +namespace AspNetCore.SignalR.OpenTelemetry; + +public static class TracerProviderBuilderExtensions +{ + public static TracerProviderBuilder AddSignalRInstrumentation(this TracerProviderBuilder builder) + { + return builder.AddSource(HubActivitySource.Name); + } +}