diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4a1b4d4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,35 @@ +name: build +on: + - push +jobs: + build: + runs-on: ubuntu-22.04 + name: Build + steps: + - uses: actions/checkout@v4 + - name: Build + run: ./build.sh build + release: + if: startsWith(github.ref, 'refs/tags/v') + name: Release + needs: build + runs-on: ubuntu-22.04 + permissions: + contents: write + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Release + run: ./build.sh release + - name: Release binary artifact + uses: ncipollo/release-action@v1 + with: + bodyFile: dist/release-notes.md + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebe7d20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +tmp/ +*.sln diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7189ed5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,19 @@ +{ + "cSpell.words": [ + "ASPNETCORE", + "Edsger", + "Heemstra", + "opentelemetry", + "opsi", + "OTEL", + "otlp", + "Petroski", + "Pravin", + "Sipley", + "Skroob", + "Spaceballs", + "traceparent", + "tracestate", + "wireshark" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f4280c --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# About + +[![Build](https://github.com/rgl/opentelemetry-dotnet-playground/actions/workflows/build.yml/badge.svg)](https://github.com/rgl/opentelemetry-dotnet-playground/actions/workflows/build.yml) + +This is a [OpenTelemetry .NET](https://github.com/open-telemetry/opentelemetry-dotnet) playground. + +The following components are used: + +![components](components.png) + +# Usage (Ubuntu 22.04) + +```bash +# create the environment defined in docker-compose.yml +# and leave it running in the background. +docker compose up -d --build + +# show running containers. +docker compose ps + +# show logs. +docker compose logs + +# open a container network interface in wireshark. +./wireshark.sh quotes + +# open the quotes service swagger. +xdg-open http://localhost:8000/swagger + +# make a request. +http \ + --verbose \ + http://localhost:8000/quote + +# make a failing request. +http \ + --verbose \ + http://localhost:8000/quote?opsi=opsi + +# make a request that includes a parent trace. +# NB the dotnet trace id will be set to the traceparent trace id. +# NB the tracestate does not seem to be stored or propagated anywhere. +http \ + --verbose \ + http://localhost:8000/quote \ + traceparent:00-f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1-2f2f2f2f2f2f2f2f-01 \ + tracestate:x.client.state=example + +# open aspire dashboard (metrics/traces/logs). +xdg-open http://localhost:18888 + +# destroy the environment. +docker compose down --remove-orphans --volumes --timeout=0 +``` + +# Notes + +* .NET uses the [Activity class](https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.activity?view=net-8.0) to encapsulate the [W3C Trace Context](https://www.w3.org/TR/trace-context/). + * The Activity `Id` property contains to the [W3C `traceparent` header value](https://www.w3.org/TR/trace-context/#traceparent-header). + * It looks alike `00-98d483b6d0e3a6d012b11e23737faa50-6ac18089ab13c12e-01`. + * It has four fields: `version`, `trace-id`, `parent-id` (aka `span-id`), and `trace-flags`. + +# Reference + +* [W3C Trace Context](https://www.w3.org/TR/trace-context/). +* [opentelemetry-dotnet repository](https://github.com/open-telemetry/opentelemetry-dotnet). +* [OpenTelemetry in .NET documentation](https://opentelemetry.io/docs/languages/net/). +* [OpenTelemetry.Exporter.OpenTelemetryProtocol Environment Variables](https://github.com/open-telemetry/opentelemetry-dotnet/tree/main/src/OpenTelemetry.Exporter.OpenTelemetryProtocol#environment-variables). diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..e0cf168 --- /dev/null +++ b/build.sh @@ -0,0 +1,76 @@ +#!/bin/bash +set -euxo pipefail + +GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-rgl/opentelemetry-dotnet-playground}" + +QUOTES_SOURCE_URL="https://github.com/$GITHUB_REPOSITORY" +if [[ "${GITHUB_REF:-v0.0.0-dev}" =~ \/v([0-9]+(\.[0-9]+)+(-.+)?) ]]; then + QUOTES_VERSION="${BASH_REMATCH[1]}" +else + QUOTES_VERSION='0.0.0-dev' +fi +QUOTES_REVISION="${GITHUB_SHA:-0000000000000000000000000000000000000000}" +QUOTES_TITLE="$(basename "$GITHUB_REPOSITORY")" +QUOTES_DESCRIPTION="$(perl -ne 'print $1 if /\(.+)\<\/Description\>/' ).+(\),\1$QUOTES_VERSION\2,g" quotes/Quotes.csproj +} + +function build { + set-metadata + docker compose build +} + +function release { + set-metadata + pushd quotes + local image="ghcr.io/$GITHUB_REPOSITORY:$QUOTES_VERSION" + local image_created="$(date --utc '+%Y-%m-%dT%H:%M:%S.%NZ')" + docker build \ + --label "org.opencontainers.image.created=$image_created" \ + --label "org.opencontainers.image.source=$QUOTES_SOURCE_URL" \ + --label "org.opencontainers.image.version=$QUOTES_VERSION" \ + --label "org.opencontainers.image.revision=$QUOTES_REVISION" \ + --label "org.opencontainers.image.title=$QUOTES_TITLE" \ + --label "org.opencontainers.image.description=$QUOTES_DESCRIPTION" \ + --label "org.opencontainers.image.licenses=$QUOTES_LICENSE" \ + --label "org.opencontainers.image.vendor=$QUOTES_VENDOR" \ + --label "org.opencontainers.image.authors=$QUOTES_AUTHOR_NAME" \ + -t "$image" \ + . + docker push "$image" + popd + install -d dist + cat >dist/release-notes.md < + + 10 + + UMLClass + + 350 + 280 + 190 + 110 + + <<dashboard>> +<<metrics>> +<<traces>> +<<logs>> +aspire-dashboard + + + + UMLClass + + 350 + 410 + 190 + 70 + + <<service>> +quotes + + + + Relation + + 150 + 410 + 220 + 40 + + r1=http://localhost:8000 +lt=()- + 10.0;20.0;200.0;20.0 + + + Relation + + 150 + 280 + 220 + 40 + + r1=http://localhost:18888 +lt=()- + 10.0;20.0;200.0;20.0 + + + Relation + + 530 + 350 + 330 + 110 + + lt=-( + 10.0;90.0;310.0;90.0;310.0;20.0;290.0;20.0 + + + Relation + + 530 + 350 + 290 + 40 + + r2=http://aspire-dashboard:18889 +lt=-() + 10.0;20.0;270.0;20.0 + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e27f5e4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +# see https://github.com/compose-spec/compose-spec/blob/master/spec.md +# see https://github.com/opencontainers/image-spec/blob/master/annotations.md +services: + aspire-dashboard: + # see https://mcr.microsoft.com/product/dotnet/nightly/aspire-dashboard/about + # see https://github.com/dotnet/dotnet-docker/issues/5128 + # see https://github.com/dotnet/aspire/issues/2248#issuecomment-1947902486 + image: mcr.microsoft.com/dotnet/nightly/aspire-dashboard:8.0.0-preview.4 + ports: + # web ui. + - 18888:18888 + # otlp grpc. + #- 18889:18889 + restart: on-failure + quotes: + build: quotes + environment: + - OTEL_EXPORTER_OTLP_ENDPOINT=http://aspire-dashboard:18889 + - OTEL_EXPORTER_OTLP_PROTOCOL=grpc + - ASPNETCORE_URLS=http://+:8000 + ports: + # http api. + # http://localhost:8000 + - 8000:8000 + restart: on-failure diff --git a/quotes/.dockerignore b/quotes/.dockerignore new file mode 100644 index 0000000..8f5c356 --- /dev/null +++ b/quotes/.dockerignore @@ -0,0 +1,4 @@ +bin/ +obj/ +out/ +.*/ diff --git a/quotes/Controllers/QuoteController.cs b/quotes/Controllers/QuoteController.cs new file mode 100644 index 0000000..b6357d7 --- /dev/null +++ b/quotes/Controllers/QuoteController.cs @@ -0,0 +1,87 @@ +namespace Quotes.Controllers; + +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; + +[ApiController] +[Route("[controller]")] +public class QuoteController : ControllerBase +{ + private static readonly Quote[] Quotes = + [ + new Quote + ( + text: "To alcohol! The cause of... and solution to... all of life's problems.", + author: "Homer Simpson", + url: "https://en.wikipedia.org/wiki/Homer_vs._the_Eighteenth_Amendment" + ), + new Quote + ( + text: "You got to help me. I don't know what to do. I can't make decisions. I'm a president!", + author: "President Skroob, Spaceballs", + url: "https://en.wikipedia.org/wiki/Spaceballs" + ), + new Quote + ( + text: "Beware of he who would deny you access to information, for in his heart he dreams himself your master.", + author: "Pravin Lal", + url: "https://alphacentauri.gamepedia.com/Peacekeeping_Forces" + ), + new Quote + ( + text: "About the use of language: it is impossible to sharpen a pencil with a blunt axe. It is equally vain to try to do it with ten blunt axes instead.", + author: "Edsger W. Dijkstra", + url: "https://www.cs.utexas.edu/users/EWD/transcriptions/EWD04xx/EWD498.html" + ), + new Quote + ( + text: "Those hours of practice, and failure, are a necessary part of the learning process.", + author: "Gina Sipley", + url: null + ), + new Quote + ( + text: "Engineering is achieving function while avoiding failure.", + author: "Henry Petroski", + url: null + ), + new Quote + ( + text: "Leadership is defined by what you do, not what you're called.", + author: "Jen Heemstra", + url: "https://twitter.com/jenheemstra/status/1260186699021287424" + ), + new Quote + ( + text: "Don't only practice your art, but force your way into its secrets; art deserves that, for it and knowledge can raise man to the Divine.", + author: "Ludwig van Beethoven", + url: null + ), + ]; + + private readonly ILogger _logger; + + public QuoteController(ILogger logger) + { + _logger = logger; + } + + [HttpGet(Name = "GetRandomQuote")] + public Quote GetRandomQuote([FromQuery] string? opsi) + { + _logger.LogInformation("At GetRandomQuote"); + + var activity = HttpContext.Features.Get()?.Activity; + + activity?.SetTag("x.foo", "bar"); + + _logger.LogInformation("Current Activity Id={activityId} TraceId={traceId} SpanId={spanId}", activity?.Id, activity?.TraceId, activity?.SpanId); + + if (opsi != null) + { + throw new ApplicationException(opsi); + } + + return Quotes[Random.Shared.Next(0, Quotes.Length)]; + } +} diff --git a/quotes/Dockerfile b/quotes/Dockerfile new file mode 100644 index 0000000..6d3a51c --- /dev/null +++ b/quotes/Dockerfile @@ -0,0 +1,13 @@ +# syntax=docker/dockerfile:1.6 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS builder +WORKDIR /app +COPY *.csproj ./ +RUN dotnet restore +COPY . ./ +RUN dotnet publish -c Release + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +COPY --from=builder /app/bin/Release/net8.0/publish ./ +ENTRYPOINT ["/app/Quotes"] diff --git a/quotes/Program.cs b/quotes/Program.cs new file mode 100644 index 0000000..1f6a37d --- /dev/null +++ b/quotes/Program.cs @@ -0,0 +1,55 @@ +using System.Reflection; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.ResourceDetectors.Container; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// configure telemetry. +if (Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") != null) +{ + var assembly = Assembly.GetExecutingAssembly(); + var serviceVersion = assembly.GetCustomAttribute()?.InformationalVersion; + if (string.IsNullOrEmpty(serviceVersion)) + { + throw new ApplicationException("cannot get the service version"); + } + builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource + .AddService( + serviceName: builder.Environment.ApplicationName, + serviceVersion: serviceVersion) + .AddDetector(new ContainerResourceDetector())) + .WithMetrics(metrics => metrics + .AddRuntimeInstrumentation() + .AddAspNetCoreInstrumentation() + .AddOtlpExporter()) + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() + .AddOtlpExporter() + .AddConsoleExporter()); + builder.Logging + .AddOpenTelemetry(options => options.AddOtlpExporter()); +} + +builder.Logging.Configure(options => + { + Console.WriteLine($"Logging ActivityTrackingOptions: {options.ActivityTrackingOptions}"); + } +); + +var app = builder.Build(); + +// enable the swagger document endpoint and swagger ui. +app.UseSwagger(); // make the swagger available at /swagger/v1/swagger.json +app.UseSwaggerUI(); // make the swagger UI available at /swagger + +app.MapControllers(); + +app.Run(); diff --git a/quotes/Properties/launchSettings.json b/quotes/Properties/launchSettings.json new file mode 100644 index 0000000..a02cbfa --- /dev/null +++ b/quotes/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "Quotes": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:8000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/quotes/Quote.cs b/quotes/Quote.cs new file mode 100644 index 0000000..67e3c2f --- /dev/null +++ b/quotes/Quote.cs @@ -0,0 +1,8 @@ +namespace Quotes; + +public class Quote(string text, string author, string? url) +{ + public string Text { get; } = text; + public string Author { get; } = author; + public string? Url { get; } = url; +} diff --git a/quotes/Quotes.csproj b/quotes/Quotes.csproj new file mode 100644 index 0000000..501d519 --- /dev/null +++ b/quotes/Quotes.csproj @@ -0,0 +1,21 @@ + + + 0.0.0-dev + Example OpenTelemetry .NET Quotes service + ruilopes.com + ruilopes.com + net8.0 + enable + enable + true + + + + + + + + + + + \ No newline at end of file diff --git a/quotes/appsettings.Development.json b/quotes/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/quotes/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/quotes/appsettings.json b/quotes/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/quotes/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/wireshark.sh b/wireshark.sh new file mode 100755 index 0000000..d0c93a7 --- /dev/null +++ b/wireshark.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -euo pipefail + +script_directory_path="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script_path="$script_directory_path/$(basename "${BASH_SOURCE[0]}")" + +container_name="${1:-quotes}"; shift || true +container_id="$(docker compose ps --no-trunc --format '{{.ID}}' "$container_name")" +container_br="br-$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.NetworkID}}{{end}}' "$container_id" | cut -c1-12)" +container_mac="$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.MacAddress}}{{end}}' "$container_id")" + +# download the otlp protobuf files. +otlp_version='1.1.0' +otlp_path="$PWD/tmp/opentelemetry-proto-$otlp_version" +if [ ! -d "$otlp_path/opentelemetry/proto" ]; then + otlp_parent_path="$(dirname "$otlp_path")" + install -d "$otlp_parent_path" + wget -qO- "https://github.com/open-telemetry/opentelemetry-proto/archive/refs/tags/v$otlp_version.tar.gz" \ + | tar xzf - -C "$otlp_parent_path" +fi + +# configure the wireshark proto search path. +# TODO instead of always overriding the file, modify it in-place when required. +# see https://wiki.wireshark.org/gRPC +# see https://wiki.wireshark.org/Protobuf +# see https://github.com/open-telemetry/opentelemetry-proto/tree/main/opentelemetry/proto +# NB wireshark searches for proto files at the directories configured in the +# ~/.config/wireshark/protobuf_search_paths file. +# NB wireshark preferences are loaded from the ~/.config/wireshark/preferences +# file. +cat >~/.config/wireshark/protobuf_search_paths <