diff options
| author | Saumit <justsaumit@protonmail.com> | 2025-09-27 02:14:26 +0530 |
|---|---|---|
| committer | Saumit <justsaumit@protonmail.com> | 2025-09-27 02:14:26 +0530 |
| commit | 82e03978b89938219958032efb1448cc76baa181 (patch) | |
| tree | 626f3e54d52ecd49be0ed3bee30abacc0453d081 /src/cart | |
Initial snapshot - OpenTelemetry demo 2.1.3 -f
Diffstat (limited to 'src/cart')
| -rw-r--r-- | src/cart/Directory.Build.props | 11 | ||||
| -rw-r--r-- | src/cart/NuGet.config | 8 | ||||
| -rw-r--r-- | src/cart/README.md | 15 | ||||
| -rw-r--r-- | src/cart/cart.sln | 48 | ||||
| -rw-r--r-- | src/cart/src/.dockerignore | 6 | ||||
| -rw-r--r-- | src/cart/src/Dockerfile | 41 | ||||
| -rw-r--r-- | src/cart/src/Program.cs | 99 | ||||
| -rw-r--r-- | src/cart/src/appsettings.json | 15 | ||||
| -rw-r--r-- | src/cart/src/cart.csproj | 41 | ||||
| -rw-r--r-- | src/cart/src/cartstore/ICartStore.cs | 17 | ||||
| -rw-r--r-- | src/cart/src/cartstore/ValkeyCartStore.cs | 238 | ||||
| -rw-r--r-- | src/cart/src/services/CartService.cs | 101 | ||||
| -rw-r--r-- | src/cart/tests/CartServiceTests.cs | 146 | ||||
| -rw-r--r-- | src/cart/tests/cart.tests.csproj | 21 |
14 files changed, 807 insertions, 0 deletions
diff --git a/src/cart/Directory.Build.props b/src/cart/Directory.Build.props new file mode 100644 index 0000000..bfd1cab --- /dev/null +++ b/src/cart/Directory.Build.props @@ -0,0 +1,11 @@ +<Project> + <PropertyGroup> + <NuGetAudit>true</NuGetAudit> + <NuGetAuditMode>all</NuGetAuditMode> + <NuGetAuditLevel>low</NuGetAuditLevel> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)'=='Release'"> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + </PropertyGroup> +</Project> diff --git a/src/cart/NuGet.config b/src/cart/NuGet.config new file mode 100644 index 0000000..adcb2a9 --- /dev/null +++ b/src/cart/NuGet.config @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<configuration> + <packageSources> + <clear /> + <add key="NuGet" value="https://api.nuget.org/v3/index.json" /> + </packageSources> + <disabledPackageSources /> +</configuration> diff --git a/src/cart/README.md b/src/cart/README.md new file mode 100644 index 0000000..f2b2db6 --- /dev/null +++ b/src/cart/README.md @@ -0,0 +1,15 @@ +# Cart Service + +This service stores user shopping carts in Valkey. + +## Local Build + +Run `dotnet restore` and `dotnet build`. + +## Docker Build + +From the root directory of this repository, run: + +```sh +docker compose build cart +``` diff --git a/src/cart/cart.sln b/src/cart/cart.sln new file mode 100644 index 0000000..6fedf63 --- /dev/null +++ b/src/cart/cart.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26124.0 +MinimumVisualStudioVersion = 15.0.26124.0 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cart", "src\cart.csproj", "{2348C29F-E8D3-4955-916D-D609CBC97FCB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cart.tests", "tests\cart.tests.csproj", "{59825342-CE64-4AFA-8744-781692C0811B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2348C29F-E8D3-4955-916D-D609CBC97FCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2348C29F-E8D3-4955-916D-D609CBC97FCB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2348C29F-E8D3-4955-916D-D609CBC97FCB}.Debug|x64.ActiveCfg = Debug|Any CPU + {2348C29F-E8D3-4955-916D-D609CBC97FCB}.Debug|x64.Build.0 = Debug|Any CPU + {2348C29F-E8D3-4955-916D-D609CBC97FCB}.Debug|x86.ActiveCfg = Debug|Any CPU + {2348C29F-E8D3-4955-916D-D609CBC97FCB}.Debug|x86.Build.0 = Debug|Any CPU + {2348C29F-E8D3-4955-916D-D609CBC97FCB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2348C29F-E8D3-4955-916D-D609CBC97FCB}.Release|Any CPU.Build.0 = Release|Any CPU + {2348C29F-E8D3-4955-916D-D609CBC97FCB}.Release|x64.ActiveCfg = Release|Any CPU + {2348C29F-E8D3-4955-916D-D609CBC97FCB}.Release|x64.Build.0 = Release|Any CPU + {2348C29F-E8D3-4955-916D-D609CBC97FCB}.Release|x86.ActiveCfg = Release|Any CPU + {2348C29F-E8D3-4955-916D-D609CBC97FCB}.Release|x86.Build.0 = Release|Any CPU + {59825342-CE64-4AFA-8744-781692C0811B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59825342-CE64-4AFA-8744-781692C0811B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59825342-CE64-4AFA-8744-781692C0811B}.Debug|x64.ActiveCfg = Debug|Any CPU + {59825342-CE64-4AFA-8744-781692C0811B}.Debug|x64.Build.0 = Debug|Any CPU + {59825342-CE64-4AFA-8744-781692C0811B}.Debug|x86.ActiveCfg = Debug|Any CPU + {59825342-CE64-4AFA-8744-781692C0811B}.Debug|x86.Build.0 = Debug|Any CPU + {59825342-CE64-4AFA-8744-781692C0811B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59825342-CE64-4AFA-8744-781692C0811B}.Release|Any CPU.Build.0 = Release|Any CPU + {59825342-CE64-4AFA-8744-781692C0811B}.Release|x64.ActiveCfg = Release|Any CPU + {59825342-CE64-4AFA-8744-781692C0811B}.Release|x64.Build.0 = Release|Any CPU + {59825342-CE64-4AFA-8744-781692C0811B}.Release|x86.ActiveCfg = Release|Any CPU + {59825342-CE64-4AFA-8744-781692C0811B}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/src/cart/src/.dockerignore b/src/cart/src/.dockerignore new file mode 100644 index 0000000..0224086 --- /dev/null +++ b/src/cart/src/.dockerignore @@ -0,0 +1,6 @@ +**/*.sh +**/*.bat +**/bin/ +**/obj/ +**/out/ +Dockerfile*
\ No newline at end of file diff --git a/src/cart/src/Dockerfile b/src/cart/src/Dockerfile new file mode 100644 index 0000000..9e4df98 --- /dev/null +++ b/src/cart/src/Dockerfile @@ -0,0 +1,41 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# https://mcr.microsoft.com/v2/dotnet/sdk/tags/list +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS builder +ARG TARGETARCH + +WORKDIR /usr/src/app/ + +COPY ./src/cart/ ./ +COPY ./pb/ ./pb/ + +RUN dotnet restore ./src/cart.csproj -r linux-musl-$TARGETARCH + +RUN dotnet publish ./src/cart.csproj -r linux-musl-$TARGETARCH --no-restore -o /cart + +# ----------------------------------------------------------------------------- + +# https://mcr.microsoft.com/v2/dotnet/runtime-deps/tags/list +FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine3.20 + +WORKDIR /usr/src/app/ +COPY --from=builder /cart/ ./ + +ENV DOTNET_HOSTBUILDER__RELOADCONFIGONCHANGE=false + +EXPOSE ${CART_PORT} +ENTRYPOINT [ "./cart" ] diff --git a/src/cart/src/Program.cs b/src/cart/src/Program.cs new file mode 100644 index 0000000..588bcb2 --- /dev/null +++ b/src/cart/src/Program.cs @@ -0,0 +1,99 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 +using System; + +using cart.cartstore; +using cart.services; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Instrumentation.StackExchangeRedis; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using OpenFeature; +using OpenFeature.Contrib.Providers.Flagd; +using OpenFeature.Hooks; + +var builder = WebApplication.CreateBuilder(args); +string valkeyAddress = builder.Configuration["VALKEY_ADDR"]; +if (string.IsNullOrEmpty(valkeyAddress)) +{ + Console.WriteLine("VALKEY_ADDR environment variable is required."); + Environment.Exit(1); +} + +builder.Logging + .AddOpenTelemetry(options => options.AddOtlpExporter()) + .AddConsole(); + +builder.Services.AddSingleton<ICartStore>(x => +{ + var store = new ValkeyCartStore(x.GetRequiredService<ILogger<ValkeyCartStore>>(), valkeyAddress); + store.Initialize(); + return store; +}); + +builder.Services.AddOpenFeature(openFeatureBuilder => +{ + openFeatureBuilder + .AddHostedFeatureLifecycle() + .AddProvider(_ => new FlagdProvider()) + .AddHook<MetricsHook>() + .AddHook<TraceEnricherHook>(); +}); + +builder.Services.AddSingleton(x => + new CartService( + x.GetRequiredService<ICartStore>(), + new ValkeyCartStore(x.GetRequiredService<ILogger<ValkeyCartStore>>(), "badhost:1234"), + x.GetRequiredService<IFeatureClient>() +)); + + +Action<ResourceBuilder> appResourceBuilder = + resource => resource + .AddService(builder.Environment.ApplicationName) + .AddContainerDetector() + .AddHostDetector(); + +builder.Services.AddOpenTelemetry() + .ConfigureResource(appResourceBuilder) + .WithTracing(tracerBuilder => tracerBuilder + .AddSource("OpenTelemetry.Demo.Cart") + .AddRedisInstrumentation( + options => options.SetVerboseDatabaseStatements = true) + .AddAspNetCoreInstrumentation() + .AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation() + .AddOtlpExporter()) + .WithMetrics(meterBuilder => meterBuilder + .AddMeter("OpenTelemetry.Demo.Cart") + .AddMeter("OpenFeature") + .AddProcessInstrumentation() + .AddRuntimeInstrumentation() + .AddAspNetCoreInstrumentation() + .SetExemplarFilter(ExemplarFilterType.TraceBased) + .AddOtlpExporter()); +builder.Services.AddGrpc(); +builder.Services.AddGrpcHealthChecks() + .AddCheck("Sample", () => HealthCheckResult.Healthy()); + +var app = builder.Build(); + +var ValkeyCartStore = (ValkeyCartStore)app.Services.GetRequiredService<ICartStore>(); +app.Services.GetRequiredService<StackExchangeRedisInstrumentation>().AddConnection(ValkeyCartStore.GetConnection()); + +app.MapGrpcService<CartService>(); +app.MapGrpcHealthChecksService(); + +app.MapGet("/", async context => +{ + await context.Response.WriteAsync("Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909"); +}); + +app.Run(); diff --git a/src/cart/src/appsettings.json b/src/cart/src/appsettings.json new file mode 100644 index 0000000..db76fce --- /dev/null +++ b/src/cart/src/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + } + } +}
\ No newline at end of file diff --git a/src/cart/src/cart.csproj b/src/cart/src/cart.csproj new file mode 100644 index 0000000..4092cc1 --- /dev/null +++ b/src/cart/src/cart.csproj @@ -0,0 +1,41 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ProduceReferenceAssembly>false</ProduceReferenceAssembly> + <StaticWebAssetsEnabled>false</StaticWebAssetsEnabled> + <PublishSingleFile>true</PublishSingleFile> + <SelfContained>true</SelfContained> + <PublishTrimmed>false</PublishTrimmed> + <ProtosDir>$(ProjectDir)..\pb</ProtosDir> + </PropertyGroup> + + <PropertyGroup Condition="!Exists('$(ProtosDir)')"> + <ProtosDir>..\..\..\pb</ProtosDir> + </PropertyGroup> + + <ItemGroup> + <!-- Keeping Grpc.AspNetCore* to 2.67 due to https://github.com/grpc/grpc/issues/38538 --> + <PackageReference Include="Grpc.AspNetCore" Version="2.67.0" /> + <PackageReference Include="Grpc.AspNetCore.HealthChecks" Version="2.67.0" /> + <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.11.2" /> + <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.11.2" /> + <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.1" /> + <PackageReference Include="OpenTelemetry.Instrumentation.GrpcNetClient" Version="1.11.0-beta.2" /> + <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.1" /> + <PackageReference Include="OpenTelemetry.Instrumentation.Process" Version="1.11.0-beta.2" /> + <PackageReference Include="OpenTelemetry.Instrumentation.StackExchangeRedis" Version="1.11.0-beta.2" /> + <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.11.1" /> + <PackageReference Include="OpenTelemetry.Resources.Container" Version="1.11.0-beta.2" /> + <PackageReference Include="OpenTelemetry.Resources.Host" Version="1.11.0-beta.2" /> + <PackageReference Include="StackExchange.Redis" Version="2.8.31" /> + <PackageReference Include="OpenFeature.Contrib.Providers.Flagd" Version="0.3.2" /> + <PackageReference Include="OpenFeature" Version="2.7.0" /> + <PackageReference Include="OpenFeature.DependencyInjection" Version="2.7.0" /> + <PackageReference Include="OpenFeature.Hosting" Version="2.7.0" /> + </ItemGroup> + + <ItemGroup> + <Protobuf Include="$(ProtosDir)\**\*.proto" GrpcServices="Both" /> + </ItemGroup> +</Project> diff --git a/src/cart/src/cartstore/ICartStore.cs b/src/cart/src/cartstore/ICartStore.cs new file mode 100644 index 0000000..80e249e --- /dev/null +++ b/src/cart/src/cartstore/ICartStore.cs @@ -0,0 +1,17 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 +using System.Threading.Tasks; + +namespace cart.cartstore; + +public interface ICartStore +{ + void Initialize(); + + Task AddItemAsync(string userId, string productId, int quantity); + Task EmptyCartAsync(string userId); + + Task<Oteldemo.Cart> GetCartAsync(string userId); + + bool Ping(); +} diff --git a/src/cart/src/cartstore/ValkeyCartStore.cs b/src/cart/src/cartstore/ValkeyCartStore.cs new file mode 100644 index 0000000..8b230ba --- /dev/null +++ b/src/cart/src/cartstore/ValkeyCartStore.cs @@ -0,0 +1,238 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 +using System; +using System.Linq; +using System.Threading.Tasks; +using Grpc.Core; +using StackExchange.Redis; +using Google.Protobuf; +using Microsoft.Extensions.Logging; +using System.Diagnostics.Metrics; +using System.Diagnostics; + +namespace cart.cartstore; + +public class ValkeyCartStore : ICartStore +{ + private readonly ILogger _logger; + private const string CartFieldName = "cart"; + private const int RedisRetryNumber = 30; + + private volatile ConnectionMultiplexer _redis; + private volatile bool _isRedisConnectionOpened; + + private readonly object _locker = new(); + private readonly byte[] _emptyCartBytes; + private readonly string _connectionString; + + private static readonly ActivitySource CartActivitySource = new("OpenTelemetry.Demo.Cart"); + private static readonly Meter CartMeter = new Meter("OpenTelemetry.Demo.Cart"); + private static readonly Histogram<double> addItemHistogram = CartMeter.CreateHistogram( + "app.cart.add_item.latency", + unit: "s", + advice: new InstrumentAdvice<double> + { + HistogramBucketBoundaries = [ 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10 ] + }); + private static readonly Histogram<double> getCartHistogram = CartMeter.CreateHistogram( + "app.cart.get_cart.latency", + unit: "s", + advice: new InstrumentAdvice<double> + { + HistogramBucketBoundaries = [ 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10 ] + }); + private readonly ConfigurationOptions _redisConnectionOptions; + + public ValkeyCartStore(ILogger<ValkeyCartStore> logger, string valkeyAddress) + { + _logger = logger; + // Serialize empty cart into byte array. + var cart = new Oteldemo.Cart(); + _emptyCartBytes = cart.ToByteArray(); + _connectionString = $"{valkeyAddress},ssl=false,allowAdmin=true,abortConnect=false"; + + _redisConnectionOptions = ConfigurationOptions.Parse(_connectionString); + + // Try to reconnect multiple times if the first retry fails. + _redisConnectionOptions.ConnectRetry = RedisRetryNumber; + _redisConnectionOptions.ReconnectRetryPolicy = new ExponentialRetry(1000); + + _redisConnectionOptions.KeepAlive = 180; + } + + public ConnectionMultiplexer GetConnection() + { + EnsureRedisConnected(); + return _redis; + } + + public void Initialize() + { + EnsureRedisConnected(); + } + + private void EnsureRedisConnected() + { + if (_isRedisConnectionOpened) + { + return; + } + + // Connection is closed or failed - open a new one but only at the first thread + lock (_locker) + { + if (_isRedisConnectionOpened) + { + return; + } + + _logger.LogDebug("Connecting to Redis: {_connectionString}", _connectionString); + _redis = ConnectionMultiplexer.Connect(_redisConnectionOptions); + + if (_redis == null || !_redis.IsConnected) + { + _logger.LogError("Wasn't able to connect to redis"); + + // We weren't able to connect to Redis despite some retries with exponential backoff. + throw new ApplicationException("Wasn't able to connect to redis"); + } + + _logger.LogInformation("Successfully connected to Redis"); + var cache = _redis.GetDatabase(); + + _logger.LogDebug("Performing small test"); + cache.StringSet("cart", "OK" ); + object res = cache.StringGet("cart"); + _logger.LogDebug("Small test result: {res}", res); + + _redis.InternalError += (_, e) => { Console.WriteLine(e.Exception); }; + _redis.ConnectionRestored += (_, _) => + { + _isRedisConnectionOpened = true; + _logger.LogInformation("Connection to redis was restored successfully."); + }; + _redis.ConnectionFailed += (_, _) => + { + _logger.LogInformation("Connection failed. Disposing the object"); + _isRedisConnectionOpened = false; + }; + + _isRedisConnectionOpened = true; + } + } + + public async Task AddItemAsync(string userId, string productId, int quantity) + { + var stopwatch = Stopwatch.StartNew(); + _logger.LogInformation($"AddItemAsync called with userId={userId}, productId={productId}, quantity={quantity}"); + + try + { + EnsureRedisConnected(); + + var db = _redis.GetDatabase(); + + // Access the cart from the cache + var value = await db.HashGetAsync(userId, CartFieldName); + + Oteldemo.Cart cart; + if (value.IsNull) + { + cart = new Oteldemo.Cart + { + UserId = userId + }; + cart.Items.Add(new Oteldemo.CartItem { ProductId = productId, Quantity = quantity }); + } + else + { + cart = Oteldemo.Cart.Parser.ParseFrom(value); + var existingItem = cart.Items.SingleOrDefault(i => i.ProductId == productId); + if (existingItem == null) + { + cart.Items.Add(new Oteldemo.CartItem { ProductId = productId, Quantity = quantity }); + } + else + { + existingItem.Quantity += quantity; + } + } + + await db.HashSetAsync(userId, new[]{ new HashEntry(CartFieldName, cart.ToByteArray()) }); + await db.KeyExpireAsync(userId, TimeSpan.FromMinutes(60)); + } + catch (Exception ex) + { + throw new RpcException(new Status(StatusCode.FailedPrecondition, $"Can't access cart storage. {ex}")); + } + finally + { + addItemHistogram.Record(stopwatch.Elapsed.TotalSeconds); + } + } + + public async Task EmptyCartAsync(string userId) + { + _logger.LogInformation($"EmptyCartAsync called with userId={userId}"); + + try + { + EnsureRedisConnected(); + var db = _redis.GetDatabase(); + + // Update the cache with empty cart for given user + await db.HashSetAsync(userId, new[] { new HashEntry(CartFieldName, _emptyCartBytes) }); + await db.KeyExpireAsync(userId, TimeSpan.FromMinutes(60)); + } + catch (Exception ex) + { + throw new RpcException(new Status(StatusCode.FailedPrecondition, $"Can't access cart storage. {ex}")); + } + } + + public async Task<Oteldemo.Cart> GetCartAsync(string userId) + { + var stopwatch = Stopwatch.StartNew(); + _logger.LogInformation($"GetCartAsync called with userId={userId}"); + + try + { + EnsureRedisConnected(); + + var db = _redis.GetDatabase(); + + // Access the cart from the cache + var value = await db.HashGetAsync(userId, CartFieldName); + + if (!value.IsNull) + { + return Oteldemo.Cart.Parser.ParseFrom(value); + } + + // We decided to return empty cart in cases when user wasn't in the cache before + return new Oteldemo.Cart(); + } + catch (Exception ex) + { + throw new RpcException(new Status(StatusCode.FailedPrecondition, $"Can't access cart storage. {ex}")); + } + finally + { + getCartHistogram.Record(stopwatch.Elapsed.TotalSeconds); + } + } + + public bool Ping() + { + try + { + var cache = _redis.GetDatabase(); + var res = cache.Ping(); + return res != TimeSpan.Zero; + } + catch (Exception) + { + return false; + } + } +} diff --git a/src/cart/src/services/CartService.cs b/src/cart/src/services/CartService.cs new file mode 100644 index 0000000..5578f45 --- /dev/null +++ b/src/cart/src/services/CartService.cs @@ -0,0 +1,101 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 +using System.Diagnostics; +using System.Threading.Tasks; +using System; +using Grpc.Core; +using cart.cartstore; +using OpenFeature; +using Oteldemo; + +namespace cart.services; + +public class CartService : Oteldemo.CartService.CartServiceBase +{ + private static readonly Empty Empty = new(); + private readonly Random random = new Random(); + private readonly ICartStore _badCartStore; + private readonly ICartStore _cartStore; + private readonly IFeatureClient _featureFlagHelper; + + public CartService(ICartStore cartStore, ICartStore badCartStore, IFeatureClient featureFlagService) + { + _badCartStore = badCartStore; + _cartStore = cartStore; + _featureFlagHelper = featureFlagService; + } + + public override async Task<Empty> AddItem(AddItemRequest request, ServerCallContext context) + { + var activity = Activity.Current; + activity?.SetTag("app.user.id", request.UserId); + activity?.SetTag("app.product.id", request.Item.ProductId); + activity?.SetTag("app.product.quantity", request.Item.Quantity); + + try + { + await _cartStore.AddItemAsync(request.UserId, request.Item.ProductId, request.Item.Quantity); + + return Empty; + } + catch (RpcException ex) + { + activity?.AddException(ex); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + } + + public override async Task<Cart> GetCart(GetCartRequest request, ServerCallContext context) + { + var activity = Activity.Current; + activity?.SetTag("app.user.id", request.UserId); + activity?.AddEvent(new("Fetch cart")); + + try + { + var cart = await _cartStore.GetCartAsync(request.UserId); + var totalCart = 0; + foreach (var item in cart.Items) + { + totalCart += item.Quantity; + } + activity?.SetTag("app.cart.items.count", totalCart); + + return cart; + } + catch (RpcException ex) + { + activity?.AddException(ex); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + } + + public override async Task<Empty> EmptyCart(EmptyCartRequest request, ServerCallContext context) + { + var activity = Activity.Current; + activity?.SetTag("app.user.id", request.UserId); + activity?.AddEvent(new("Empty cart")); + + try + { + if (await _featureFlagHelper.GetBooleanValueAsync("cartFailure", false)) + { + await _badCartStore.EmptyCartAsync(request.UserId); + } + else + { + await _cartStore.EmptyCartAsync(request.UserId); + } + } + catch (RpcException ex) + { + Activity.Current?.AddException(ex); + Activity.Current?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + + return Empty; + } +} diff --git a/src/cart/tests/CartServiceTests.cs b/src/cart/tests/CartServiceTests.cs new file mode 100644 index 0000000..45173d1 --- /dev/null +++ b/src/cart/tests/CartServiceTests.cs @@ -0,0 +1,146 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 +using System; +using System.Threading.Tasks; +using Grpc.Net.Client; +using Oteldemo; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Hosting; +using Xunit; +using static Oteldemo.CartService; + +namespace cart.tests; + +public class CartServiceTests +{ + private readonly IHostBuilder _host; + + public CartServiceTests() + { + _host = new HostBuilder().ConfigureWebHost(webBuilder => + { + webBuilder + // .UseStartup<Startup>() + .UseTestServer(); + }); + } + + [Fact(Skip = "See https://github.com/open-telemetry/opentelemetry-demo/pull/746#discussion_r1107931240")] + public async Task GetItem_NoAddItemBefore_EmptyCartReturned() + { + // Setup test server and client + using var server = await _host.StartAsync(); + var httpClient = server.GetTestClient(); + + string userId = Guid.NewGuid().ToString(); + + // Create a GRPC communication channel between the client and the server + var channel = GrpcChannel.ForAddress(httpClient.BaseAddress, new GrpcChannelOptions + { + HttpClient = httpClient + }); + + var cartClient = new CartServiceClient(channel); + + var request = new GetCartRequest + { + UserId = userId, + }; + + var cart = await cartClient.GetCartAsync(request); + Assert.NotNull(cart); + + // All grpc objects implement IEquitable, so we can compare equality with by-value semantics + Assert.Equal(new Cart(), cart); + } + + [Fact(Skip = "See https://github.com/open-telemetry/opentelemetry-demo/pull/746#discussion_r1107931240")] + public async Task AddItem_ItemExists_Updated() + { + // Setup test server and client + using var server = await _host.StartAsync(); + var httpClient = server.GetTestClient(); + + string userId = Guid.NewGuid().ToString(); + + // Create a GRPC communication channel between the client and the server + var channel = GrpcChannel.ForAddress(httpClient.BaseAddress, new GrpcChannelOptions + { + HttpClient = httpClient + }); + + var client = new CartServiceClient(channel); + var request = new AddItemRequest + { + UserId = userId, + Item = new CartItem + { + ProductId = "1", + Quantity = 1 + } + }; + + // First add - nothing should fail + await client.AddItemAsync(request); + + // Second add of existing product - quantity should be updated + await client.AddItemAsync(request); + + var getCartRequest = new GetCartRequest + { + UserId = userId + }; + var cart = await client.GetCartAsync(getCartRequest); + Assert.NotNull(cart); + Assert.Equal(userId, cart.UserId); + Assert.Single(cart.Items); + Assert.Equal(2, cart.Items[0].Quantity); + + // Cleanup + await client.EmptyCartAsync(new EmptyCartRequest { UserId = userId }); + } + + [Fact(Skip = "See https://github.com/open-telemetry/opentelemetry-demo/pull/746#discussion_r1107931240")] + public async Task AddItem_New_Inserted() + { + // Setup test server and client + using var server = await _host.StartAsync(); + var httpClient = server.GetTestClient(); + + string userId = Guid.NewGuid().ToString(); + + // Create a GRPC communication channel between the client and the server + var channel = GrpcChannel.ForAddress(httpClient.BaseAddress, new GrpcChannelOptions + { + HttpClient = httpClient + }); + + // Create a proxy object to work with the server + var client = new CartServiceClient(channel); + + var request = new AddItemRequest + { + UserId = userId, + Item = new CartItem + { + ProductId = "1", + Quantity = 1 + } + }; + + await client.AddItemAsync(request); + + var getCartRequest = new GetCartRequest + { + UserId = userId + }; + var cart = await client.GetCartAsync(getCartRequest); + Assert.NotNull(cart); + Assert.Equal(userId, cart.UserId); + Assert.Single(cart.Items); + + await client.EmptyCartAsync(new EmptyCartRequest { UserId = userId }); + cart = await client.GetCartAsync(getCartRequest); + Assert.Empty(cart.Items); + } +} diff --git a/src/cart/tests/cart.tests.csproj b/src/cart/tests/cart.tests.csproj new file mode 100644 index 0000000..8c32df7 --- /dev/null +++ b/src/cart/tests/cart.tests.csproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Grpc.Net.Client" Version="2.70.0" /> + <PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.13" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" /> + <PackageReference Include="xunit" Version="2.9.3" /> + <PackageReference Include="xunit.runner.visualstudio" Version="3.0.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\src\cart.csproj" /> + </ItemGroup> +</Project> |
