Skip to content

Commit 52bdc11

Browse files
authored
feat: Add support for the EnyimMemcachedCore client. (#2781)
1 parent f032a9c commit 52bdc11

File tree

21 files changed

+893
-3
lines changed

21 files changed

+893
-3
lines changed

FullAgent.sln

+10-3
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestSerializationHelpers.Te
215215
EndProject
216216
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AwsSdk", "src\Agent\NewRelic\Agent\Extensions\Providers\Wrapper\AwsSdk\AwsSdk.csproj", "{37262C22-6A3A-4AD7-AB78-3853D2B2931D}"
217217
EndProject
218-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureFunction", "src\Agent\NewRelic\Agent\Extensions\Providers\Wrapper\AzureFunction\AzureFunction.csproj", "{338AD83A-ED68-438A-8FB1-E93A3AE87EA8}"
218+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzureFunction", "src\Agent\NewRelic\Agent\Extensions\Providers\Wrapper\AzureFunction\AzureFunction.csproj", "{338AD83A-ED68-438A-8FB1-E93A3AE87EA8}"
219219
EndProject
220-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PublicApiChangeTests", "tests\Agent\UnitTests\PublicApiChangeTests\PublicApiChangeTests.csproj", "{A8F6EFEA-1C31-4461-A7B4-25C30D954EE2}"
220+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PublicApiChangeTests", "tests\Agent\UnitTests\PublicApiChangeTests\PublicApiChangeTests.csproj", "{A8F6EFEA-1C31-4461-A7B4-25C30D954EE2}"
221+
EndProject
222+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Memcached", "src\Agent\NewRelic\Agent\Extensions\Providers\Wrapper\Memcached\Memcached.csproj", "{5D74E5C5-9BA3-423B-86F7-14C2D1A14661}"
221223
EndProject
222224
Global
223225
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -461,6 +463,10 @@ Global
461463
{A8F6EFEA-1C31-4461-A7B4-25C30D954EE2}.Debug|Any CPU.Build.0 = Debug|Any CPU
462464
{A8F6EFEA-1C31-4461-A7B4-25C30D954EE2}.Release|Any CPU.ActiveCfg = Release|Any CPU
463465
{A8F6EFEA-1C31-4461-A7B4-25C30D954EE2}.Release|Any CPU.Build.0 = Release|Any CPU
466+
{5D74E5C5-9BA3-423B-86F7-14C2D1A14661}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
467+
{5D74E5C5-9BA3-423B-86F7-14C2D1A14661}.Debug|Any CPU.Build.0 = Debug|Any CPU
468+
{5D74E5C5-9BA3-423B-86F7-14C2D1A14661}.Release|Any CPU.ActiveCfg = Release|Any CPU
469+
{5D74E5C5-9BA3-423B-86F7-14C2D1A14661}.Release|Any CPU.Build.0 = Release|Any CPU
464470
EndGlobalSection
465471
GlobalSection(SolutionProperties) = preSolution
466472
HideSolutionNode = FALSE
@@ -531,10 +537,11 @@ Global
531537
{37262C22-6A3A-4AD7-AB78-3853D2B2931D} = {5E86E10A-C38F-48CB-ADE9-67B22BB2F50A}
532538
{338AD83A-ED68-438A-8FB1-E93A3AE87EA8} = {5E86E10A-C38F-48CB-ADE9-67B22BB2F50A}
533539
{A8F6EFEA-1C31-4461-A7B4-25C30D954EE2} = {E5B988C0-5D19-407E-8210-71FFB90C579A}
540+
{5D74E5C5-9BA3-423B-86F7-14C2D1A14661} = {5E86E10A-C38F-48CB-ADE9-67B22BB2F50A}
534541
EndGlobalSection
535542
GlobalSection(ExtensibilityGlobals) = postSolution
536-
SolutionGuid = {D8B98070-6B8E-403C-A07F-A3F2E4A3A3D0}
537543
EnterpriseLibraryConfigurationToolBinariesPath = packages\Unity.2.1.505.2\lib\NET35
544+
SolutionGuid = {D8B98070-6B8E-403C-A07F-A3F2E4A3A3D0}
538545
EndGlobalSection
539546
GlobalSection(TestCaseManagementSettings) = postSolution
540547
CategoryFile = FullAgent.vsmdi

build/ArtifactBuilder/CoreAgentComponents.cs

+2
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ protected override void CreateAgentComponents()
5959
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.AwsLambda.dll",
6060
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.AwsSdk.dll",
6161
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.AzureFunction.dll",
62+
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.Memcached.dll",
6263
};
6364

6465
var wrapperXmls = new[]
@@ -86,6 +87,7 @@ protected override void CreateAgentComponents()
8687
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.AwsLambda.Instrumentation.xml",
8788
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.AwsSdk.Instrumentation.xml",
8889
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.AzureFunction.Instrumentation.xml",
90+
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.Memcached.Instrumentation.xml",
8991
};
9092

9193
ExtensionXsd = $@"{SourceHomeBuilderPath}\extensions\extension.xsd";

build/ArtifactBuilder/FrameworkAgentComponents.cs

+2
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ protected override void CreateAgentComponents()
6666
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.Bedrock.dll",
6767
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.AwsSdk.dll",
6868
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.AzureFunction.dll",
69+
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.Memcached.dll",
6970
};
7071

7172
var wrapperXmls = new[]
@@ -107,6 +108,7 @@ protected override void CreateAgentComponents()
107108
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.Bedrock.Instrumentation.xml",
108109
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.AwsSdk.Instrumentation.xml",
109110
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.AzureFunction.Instrumentation.xml",
111+
$@"{SourceHomeBuilderPath}\extensions\NewRelic.Providers.Wrapper.Memcached.Instrumentation.xml",
110112
};
111113

112114
ExtensionXsd = $@"{SourceHomeBuilderPath}\extensions\extension.xsd";

src/Agent/MsiInstaller/Installer/Product.wxs

+12
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,9 @@ SPDX-License-Identifier: Apache-2.0
401401
<Component Id="AzureFunctionWrapperComponent" Guid="{949893C5-0212-447A-88A3-DDE3638DDC6E}">
402402
<File Id="AzureFunctionWrapperFile" Name="NewRelic.Providers.Wrapper.AzureFunction.dll" KeyPath="yes" Source="$(var.HomeFolderPath)\extensions\NewRelic.Providers.Wrapper.AzureFunction.dll" />
403403
</Component>
404+
<Component Id="MemcachedWrapperComponent" Guid="{2FF15179-BBEB-460C-A145-10F20C0CAD07}">
405+
<File Id="MemcachednWrapperFile" Name="NewRelic.Providers.Wrapper.Memcached.dll" KeyPath="yes" Source="$(var.HomeFolderPath)\extensions\NewRelic.Providers.Wrapper.Memcached.dll" />
406+
</Component>
404407
</ComponentGroup>
405408

406409
<ComponentGroup Id="CoreNewRelic.Agent.Extensions" Directory="CoreProgramFilesExtensionsFolder">
@@ -476,6 +479,9 @@ SPDX-License-Identifier: Apache-2.0
476479
<Component Id="CoreAzureFunctionWrapperComponent" Guid="{6DC23A07-4063-462E-B43A-532D6F810AFE}">
477480
<File Id="CoreAzureFunctionWrapperFile" Name="NewRelic.Providers.Wrapper.AzureFunction.dll" KeyPath="yes" Source="$(var.HomeFolderPath)_coreclr\extensions\NewRelic.Providers.Wrapper.AzureFunction.dll" />
478481
</Component>
482+
<Component Id="CoreMemcachedWrapperComponent" Guid="{1D7D04A1-24D5-4716-B7CB-EACB21D66D7D}">
483+
<File Id="CoreMemcachedWrapperFile" Name="NewRelic.Providers.Wrapper.Memcached.dll" KeyPath="yes" Source="$(var.HomeFolderPath)_coreclr\extensions\NewRelic.Providers.Wrapper.Memcached.dll" />
484+
</Component>
479485
</ComponentGroup>
480486

481487
<!-- Wrapper Instrumentation Files-->
@@ -591,6 +597,9 @@ SPDX-License-Identifier: Apache-2.0
591597
<Component Id="AzureFunctionInstrumentationComponent" Guid="{0D802289-3B47-4D94-8907-285FFD2779AB}">
592598
<File Id="AzureFunctionInstrumentationFile" Name="NewRelic.Providers.Wrapper.AzureFunction.Instrumentation.xml" KeyPath="yes" Source="$(var.HomeFolderPath)\extensions\NewRelic.Providers.Wrapper.AzureFunction.Instrumentation.xml" />
593599
</Component>
600+
<Component Id="MemcachedInstrumentationComponent" Guid="{065F899F-4942-43C6-9589-538C432E3E4D}">
601+
<File Id="MemcachedInstrumentationFile" Name="NewRelic.Providers.Wrapper.Memcached.Instrumentation.xml" KeyPath="yes" Source="$(var.HomeFolderPath)\extensions\NewRelic.Providers.Wrapper.Memcached.Instrumentation.xml" />
602+
</Component>
594603
</ComponentGroup>
595604

596605
<ComponentGroup Id="CoreNewRelic.Agent.Extensions.Instrumentation" Directory="CoreExtensionsFolder">
@@ -663,6 +672,9 @@ SPDX-License-Identifier: Apache-2.0
663672
<Component Id="CoreAzureFunctionInstrumentationComponent" Guid="{BF3BEE66-A563-4A59-ADEC-65C3381C582E}">
664673
<File Id="CoreAzureFunctionInstrumentationFile" Name="NewRelic.Providers.Wrapper.AzureFunction.Instrumentation.xml" KeyPath="yes" Source="$(var.HomeFolderPath)_coreclr\extensions\NewRelic.Providers.Wrapper.AzureFunction.Instrumentation.xml"/>
665674
</Component>
675+
<Component Id="CoreMemcachedInstrumentationComponent" Guid="{5A78488A-837C-4CA5-BD20-4A1ED734C085}">
676+
<File Id="CoreMemcachedInstrumentationFile" Name="NewRelic.Providers.Wrapper.Memcached.Instrumentation.xml" KeyPath="yes" Source="$(var.HomeFolderPath)_coreclr\extensions\NewRelic.Providers.Wrapper.Memcached.Instrumentation.xml"/>
677+
</Component>
666678
</ComponentGroup>
667679

668680
<!-- Extensions XSD-->
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright 2020 New Relic, Inc. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
using System;
5+
using System.Linq;
6+
using System.Threading.Tasks;
7+
using NewRelic.Agent.Api;
8+
using NewRelic.Agent.Extensions.Parsing;
9+
using NewRelic.Agent.Extensions.Providers.Wrapper;
10+
11+
namespace NewRelic.Providers.Wrapper.Memcached
12+
{
13+
public class EnyimMemcachedCoreWrapper : IWrapper
14+
{
15+
private const string ModelName = "cache";
16+
private const string WrapperName = "EnyimMemcachedCoreWrapper";
17+
18+
public bool IsTransactionRequired => true;
19+
20+
public CanWrapResponse CanWrap(InstrumentedMethodInfo methodInfo)
21+
{
22+
return new CanWrapResponse(WrapperName.Equals(methodInfo.RequestedWrapperName));
23+
}
24+
25+
public AfterWrappedMethodDelegate BeforeWrappedMethod(InstrumentedMethodCall instrumentedMethodCall, IAgent agent, ITransaction transaction)
26+
{
27+
if (instrumentedMethodCall.IsAsync)
28+
{
29+
transaction.AttachToAsync();
30+
}
31+
32+
// Internally, the key is used to determine what server to read from in a multi-server environment.
33+
// Without a key, we can't determine the server, so we can't determine the connection info.
34+
35+
ParsedSqlStatement parsedStatement;
36+
string key;
37+
38+
// Operation is the first argument in all cases, Key is the second argument
39+
if (instrumentedMethodCall.MethodCall.Method.MethodName.Equals("PerformStore")
40+
|| instrumentedMethodCall.MethodCall.Method.MethodName.Equals("PerformStoreAsync")
41+
|| instrumentedMethodCall.MethodCall.Method.MethodName.Equals("PerformMutate")
42+
|| instrumentedMethodCall.MethodCall.Method.MethodName.Equals("PerformMutateAsync")
43+
|| instrumentedMethodCall.MethodCall.Method.MethodName.Equals("PerformConcatenate"))
44+
{
45+
key = instrumentedMethodCall.MethodCall.MethodArguments[1].ToString();
46+
parsedStatement = new ParsedSqlStatement(DatastoreVendor.Memcached,
47+
ModelName,
48+
instrumentedMethodCall.MethodCall.MethodArguments[0].ToString());
49+
}
50+
// Operation is always Get, Key is the first argument
51+
else if (instrumentedMethodCall.MethodCall.Method.MethodName.Equals("PerformTryGet")
52+
|| instrumentedMethodCall.MethodCall.Method.MethodName.Equals("PerformGet")
53+
|| instrumentedMethodCall.MethodCall.Method.MethodName.Equals("GetAsync"))
54+
{
55+
key = instrumentedMethodCall.MethodCall.MethodArguments[0].ToString();
56+
parsedStatement = new ParsedSqlStatement(DatastoreVendor.Memcached,
57+
ModelName,
58+
"Get");
59+
}
60+
// Operation is always Remove, Key is the first argument
61+
else if (instrumentedMethodCall.MethodCall.Method.MethodName.Equals("Remove")
62+
|| instrumentedMethodCall.MethodCall.Method.MethodName.Equals("RemoveAsync"))
63+
{
64+
key = instrumentedMethodCall.MethodCall.MethodArguments[0].ToString();
65+
parsedStatement = new ParsedSqlStatement(DatastoreVendor.Memcached,
66+
ModelName,
67+
"Remove");
68+
}
69+
// Should not happen
70+
else
71+
{
72+
return Delegates.NoOp;
73+
}
74+
75+
var connectionInfo = MemcachedHelpers.GetConnectionInfo(
76+
key,
77+
instrumentedMethodCall.MethodCall.InvocationTarget,
78+
agent);
79+
80+
var segment = transaction.StartDatastoreSegment(instrumentedMethodCall.MethodCall, parsedStatement, connectionInfo, isLeaf: true);
81+
82+
if (instrumentedMethodCall.IsAsync)
83+
{
84+
return Delegates.GetAsyncDelegateFor<Task>(
85+
agent,
86+
segment);
87+
}
88+
89+
return Delegates.GetDelegateFor(
90+
onFailure: (ex) => segment.End(ex),
91+
onComplete: () => segment.End()
92+
);
93+
}
94+
}
95+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<!--
3+
Copyright 2020 New Relic Corporation. All rights reserved.
4+
SPDX-License-Identifier: Apache-2.0
5+
-->
6+
<extension xmlns="urn:newrelic-extension">
7+
<instrumentation>
8+
9+
<!--Supports all MemcachedClient and "DistributedCache" methods, except MultiX methods.-->
10+
<tracerFactory name="EnyimMemcachedCoreWrapper">
11+
12+
<!--MemcachedClient<T> calls into MemcachedClient-->
13+
<!--DistributedCache is just MemcachedClient, not a separate class-->
14+
<match assemblyName="EnyimMemcachedCore" className="Enyim.Caching.MemcachedClient">
15+
<!--Add/Async, Set/Async, Replace/Async, Store/Async, Cas-->
16+
<exactMethodMatcher methodName="PerformStore" parameters="Enyim.Caching.Memcached.StoreMode,System.String,System.Object,System.UInt32,System.UInt64&amp;,System.Int32&amp;" />
17+
<exactMethodMatcher methodName="PerformStoreAsync" />
18+
19+
<!--Get > TryGet > PerformTryGet-->
20+
<!--GetWithCas > TryGetWithCas > PerformTryGet-->
21+
<!--GetWithCas<T> > TryGetWithCas > PerformTryGet-->
22+
<exactMethodMatcher methodName="PerformTryGet" />
23+
24+
<!--Get<T>-->
25+
<exactMethodMatcher methodName="PerformGet" />
26+
27+
<!--GetValueAsync-->
28+
<exactMethodMatcher methodName="GetAsync" parameters="System.String" />
29+
30+
<!--Increment, Decrement, CasMutate-->
31+
<exactMethodMatcher methodName="PerformMutate" parameters="Enyim.Caching.Memcached.MutationMode,System.String,System.UInt64,System.UInt64,System.UInt32,System.UInt64&amp;" />
32+
33+
<!--TouchAsync-->
34+
<exactMethodMatcher methodName="PerformMutateAsync" />
35+
36+
<!--Append, Prepend-->
37+
<exactMethodMatcher methodName="PerformConcatenate" />
38+
39+
<exactMethodMatcher methodName="Remove" />
40+
<exactMethodMatcher methodName="RemoveAsync" />
41+
</match>
42+
</tracerFactory>
43+
44+
</instrumentation>
45+
</extension>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk" ToolsVersion="15.0">
2+
<PropertyGroup>
3+
<TargetFrameworks>net462;netstandard2.0</TargetFrameworks>
4+
<RootNamespace>NewRelic.Providers.Wrapper.Memcached</RootNamespace>
5+
<AssemblyName>NewRelic.Providers.Wrapper.Memcached</AssemblyName>
6+
<Description>Memcached Wrapper Provider for New Relic .NET Agent</Description>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<ProjectReference Include="..\..\..\NewRelic.Agent.Extensions\NewRelic.Agent.Extensions.csproj" />
11+
</ItemGroup>
12+
13+
<ItemGroup>
14+
<Content Include="Instrumentation.xml">
15+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
16+
</Content>
17+
</ItemGroup>
18+
</Project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright 2020 New Relic, Inc. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
using System;
5+
using NewRelic.Agent.Api;
6+
using NewRelic.Agent.Extensions.Parsing;
7+
using NewRelic.Agent.Extensions.Providers.Wrapper;
8+
using NewRelic.Reflection;
9+
10+
namespace NewRelic.Providers.Wrapper.Memcached
11+
{
12+
public class MemcachedHelpers
13+
{
14+
private static bool _hasGetServerFailed = false;
15+
private const string AssemblyName = "EnyimMemcachedCore";
16+
private static Func<object, object> _transformerGetter;
17+
private static Func<object, string, string> _transformMethod;
18+
private static Func<object, object> _poolGetter;
19+
private static Func<object, string, object> _locateMethod;
20+
private static Func<object, object> _endpointGetter;
21+
private static Func<object, object> _addressGetter;
22+
private static Func<object, int> _portGetter;
23+
24+
// To get the ConnectionInfo we need to call the same Transform method that the library calls to get the node.
25+
// This is deterministic based reviewing the code at different versions.
26+
public static ConnectionInfo GetConnectionInfo(string key, object target, IAgent agent)
27+
{
28+
if (_hasGetServerFailed)
29+
{
30+
return new ConnectionInfo(DatastoreVendor.Memcached.ToKnownName(), null, -1, null);
31+
}
32+
33+
try
34+
{
35+
var targetType = target.GetType();
36+
_transformerGetter ??= VisibilityBypasser.Instance.GeneratePropertyAccessor<object>(targetType, "KeyTransformer");
37+
var transformer = _transformerGetter(target);
38+
39+
_transformMethod ??= VisibilityBypasser.Instance.GenerateOneParameterMethodCaller<string, string>(AssemblyName, transformer.GetType().FullName, "Transform");
40+
var hashedKey = _transformMethod(transformer, key);
41+
42+
_poolGetter ??= VisibilityBypasser.Instance.GeneratePropertyAccessor<object>(targetType, "Pool");
43+
var pool = _poolGetter(target);
44+
45+
_locateMethod ??= VisibilityBypasser.Instance.GenerateOneParameterMethodCaller<string, object>(AssemblyName, pool.GetType().FullName, "Enyim.Caching.Memcached.IServerPool.Locate");
46+
var node = _locateMethod(pool, hashedKey);
47+
48+
_endpointGetter ??= VisibilityBypasser.Instance.GeneratePropertyAccessor<object>(node.GetType(), "EndPoint");
49+
var endpoint = _endpointGetter(node);
50+
51+
var endpointType = endpoint.GetType();
52+
if (endpointType.Name == "DnsEndPoint") // v2.X
53+
{
54+
_addressGetter ??= VisibilityBypasser.Instance.GeneratePropertyAccessor<object>(endpointType, "Host");
55+
}
56+
else if (endpointType.Name == "IPEndPoint") // v3.X
57+
{
58+
_addressGetter ??= VisibilityBypasser.Instance.GeneratePropertyAccessor<object>(endpointType, "Address");
59+
}
60+
else
61+
{
62+
throw new Exception("EndPoint type, "+ endpointType.Name + ", did not match supported types.");
63+
}
64+
65+
var address = _addressGetter(endpoint).ToString();
66+
67+
_portGetter ??= VisibilityBypasser.Instance.GeneratePropertyAccessor<int>(endpointType, "Port");
68+
int? port = _portGetter(endpoint);
69+
70+
return new ConnectionInfo(DatastoreVendor.Memcached.ToKnownName(), address, port.HasValue ? port.Value : -1, null);
71+
}
72+
catch (Exception exception)
73+
{
74+
agent.Logger.Warn(exception, "Unable to get Memcached server address/port, likely to due to type differences. Server address/port will not be available.");
75+
_hasGetServerFailed = true;
76+
return new ConnectionInfo(DatastoreVendor.Memcached.ToKnownName(), null, -1, null);
77+
}
78+
}
79+
}
80+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Copyright 2020 New Relic, Inc. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
namespace MemcachedTestApp
5+
{
6+
public class BlogPost
7+
{
8+
public string Title { get; set; }
9+
public string Body { get; set; }
10+
}
11+
}

0 commit comments

Comments
 (0)