summaryrefslogtreecommitdiff
path: root/src/cart
diff options
context:
space:
mode:
authorSaumit <justsaumit@protonmail.com>2025-09-27 02:14:26 +0530
committerSaumit <justsaumit@protonmail.com>2025-09-27 02:14:26 +0530
commit82e03978b89938219958032efb1448cc76baa181 (patch)
tree626f3e54d52ecd49be0ed3bee30abacc0453d081 /src/cart
Initial snapshot - OpenTelemetry demo 2.1.3 -f
Diffstat (limited to 'src/cart')
-rw-r--r--src/cart/Directory.Build.props11
-rw-r--r--src/cart/NuGet.config8
-rw-r--r--src/cart/README.md15
-rw-r--r--src/cart/cart.sln48
-rw-r--r--src/cart/src/.dockerignore6
-rw-r--r--src/cart/src/Dockerfile41
-rw-r--r--src/cart/src/Program.cs99
-rw-r--r--src/cart/src/appsettings.json15
-rw-r--r--src/cart/src/cart.csproj41
-rw-r--r--src/cart/src/cartstore/ICartStore.cs17
-rw-r--r--src/cart/src/cartstore/ValkeyCartStore.cs238
-rw-r--r--src/cart/src/services/CartService.cs101
-rw-r--r--src/cart/tests/CartServiceTests.cs146
-rw-r--r--src/cart/tests/cart.tests.csproj21
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>