mirror of https://github.com/grpc/grpc.git
[Metrics] gRPC Non-Per-Call Metrics framework implementation (#35871)
<!--
If you know who should review your pull request, please assign it to that
person, otherwise the pull request would get assigned randomly.
If your pull request is for a specific language, please add the appropriate
lang label.
-->
Closes #35871
COPYBARA_INTEGRATE_REVIEW=https://github.com/grpc/grpc/pull/35871 from yijiem:grpc-metrics 86ebe484ae
PiperOrigin-RevId: 609533796
pull/35980/head
parent
5838f6840a
commit
fb4c043803
8 changed files with 1065 additions and 0 deletions
@ -0,0 +1,188 @@ |
||||
// Copyright 2024 The gRPC Authors.
|
||||
//
|
||||
// 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.
|
||||
|
||||
#include <grpc/support/port_platform.h> |
||||
|
||||
#include "src/core/lib/channel/metrics.h" |
||||
|
||||
#include "absl/container/flat_hash_map.h" |
||||
|
||||
#include "src/core/lib/gprpp/crash.h" |
||||
|
||||
namespace grpc_core { |
||||
namespace { |
||||
// Uses the Construct-on-First-Use idiom to avoid the static initialization
|
||||
// order fiasco.
|
||||
absl::flat_hash_map<absl::string_view, |
||||
GlobalInstrumentsRegistry::GlobalInstrumentDescriptor>& |
||||
GetInstrumentList() { |
||||
static NoDestruct<absl::flat_hash_map< |
||||
absl::string_view, GlobalInstrumentsRegistry::GlobalInstrumentDescriptor>> |
||||
instruments; |
||||
return *instruments; |
||||
} |
||||
} // namespace
|
||||
|
||||
GlobalInstrumentsRegistry::GlobalUInt64CounterHandle |
||||
GlobalInstrumentsRegistry::RegisterUInt64Counter( |
||||
absl::string_view name, absl::string_view description, |
||||
absl::string_view unit, absl::Span<const absl::string_view> label_keys, |
||||
absl::Span<const absl::string_view> optional_label_keys, |
||||
bool enable_by_default) { |
||||
auto& instruments = GetInstrumentList(); |
||||
if (instruments.find(name) != instruments.end()) { |
||||
Crash(absl::StrFormat("Metric name %s has already been registered.", name)); |
||||
} |
||||
uint32_t index = instruments.size(); |
||||
GPR_ASSERT(index < std::numeric_limits<uint32_t>::max()); |
||||
GlobalInstrumentDescriptor descriptor; |
||||
descriptor.value_type = ValueType::kUInt64; |
||||
descriptor.instrument_type = InstrumentType::kCounter; |
||||
descriptor.index = index; |
||||
descriptor.enable_by_default = enable_by_default; |
||||
descriptor.name = name; |
||||
descriptor.description = description; |
||||
descriptor.unit = unit; |
||||
descriptor.label_keys = {label_keys.begin(), label_keys.end()}; |
||||
descriptor.optional_label_keys = {optional_label_keys.begin(), |
||||
optional_label_keys.end()}; |
||||
instruments.emplace(name, std::move(descriptor)); |
||||
GlobalUInt64CounterHandle handle; |
||||
handle.index = index; |
||||
return handle; |
||||
} |
||||
|
||||
GlobalInstrumentsRegistry::GlobalDoubleCounterHandle |
||||
GlobalInstrumentsRegistry::RegisterDoubleCounter( |
||||
absl::string_view name, absl::string_view description, |
||||
absl::string_view unit, absl::Span<const absl::string_view> label_keys, |
||||
absl::Span<const absl::string_view> optional_label_keys, |
||||
bool enable_by_default) { |
||||
auto& instruments = GetInstrumentList(); |
||||
if (instruments.find(name) != instruments.end()) { |
||||
Crash(absl::StrFormat("Metric name %s has already been registered.", name)); |
||||
} |
||||
uint32_t index = instruments.size(); |
||||
GPR_ASSERT(index < std::numeric_limits<uint32_t>::max()); |
||||
GlobalInstrumentDescriptor descriptor; |
||||
descriptor.value_type = ValueType::kDouble; |
||||
descriptor.instrument_type = InstrumentType::kCounter; |
||||
descriptor.index = index; |
||||
descriptor.enable_by_default = enable_by_default; |
||||
descriptor.name = name; |
||||
descriptor.description = description; |
||||
descriptor.unit = unit; |
||||
descriptor.label_keys = {label_keys.begin(), label_keys.end()}; |
||||
descriptor.optional_label_keys = {optional_label_keys.begin(), |
||||
optional_label_keys.end()}; |
||||
instruments.emplace(name, std::move(descriptor)); |
||||
GlobalDoubleCounterHandle handle; |
||||
handle.index = index; |
||||
return handle; |
||||
} |
||||
|
||||
GlobalInstrumentsRegistry::GlobalUInt64HistogramHandle |
||||
GlobalInstrumentsRegistry::RegisterUInt64Histogram( |
||||
absl::string_view name, absl::string_view description, |
||||
absl::string_view unit, absl::Span<const absl::string_view> label_keys, |
||||
absl::Span<const absl::string_view> optional_label_keys, |
||||
bool enable_by_default) { |
||||
auto& instruments = GetInstrumentList(); |
||||
if (instruments.find(name) != instruments.end()) { |
||||
Crash(absl::StrFormat("Metric name %s has already been registered.", name)); |
||||
} |
||||
uint32_t index = instruments.size(); |
||||
GPR_ASSERT(index < std::numeric_limits<uint32_t>::max()); |
||||
GlobalInstrumentDescriptor descriptor; |
||||
descriptor.value_type = ValueType::kUInt64; |
||||
descriptor.instrument_type = InstrumentType::kHistogram; |
||||
descriptor.index = index; |
||||
descriptor.enable_by_default = enable_by_default; |
||||
descriptor.name = name; |
||||
descriptor.description = description; |
||||
descriptor.unit = unit; |
||||
descriptor.label_keys = {label_keys.begin(), label_keys.end()}; |
||||
descriptor.optional_label_keys = {optional_label_keys.begin(), |
||||
optional_label_keys.end()}; |
||||
instruments.emplace(name, std::move(descriptor)); |
||||
GlobalUInt64HistogramHandle handle; |
||||
handle.index = index; |
||||
return handle; |
||||
} |
||||
|
||||
GlobalInstrumentsRegistry::GlobalDoubleHistogramHandle |
||||
GlobalInstrumentsRegistry::RegisterDoubleHistogram( |
||||
absl::string_view name, absl::string_view description, |
||||
absl::string_view unit, absl::Span<const absl::string_view> label_keys, |
||||
absl::Span<const absl::string_view> optional_label_keys, |
||||
bool enable_by_default) { |
||||
auto& instruments = GetInstrumentList(); |
||||
if (instruments.find(name) != instruments.end()) { |
||||
Crash(absl::StrFormat("Metric name %s has already been registered.", name)); |
||||
} |
||||
uint32_t index = instruments.size(); |
||||
GPR_ASSERT(index < std::numeric_limits<uint32_t>::max()); |
||||
GlobalInstrumentDescriptor descriptor; |
||||
descriptor.value_type = ValueType::kDouble; |
||||
descriptor.instrument_type = InstrumentType::kHistogram; |
||||
descriptor.index = index; |
||||
descriptor.enable_by_default = enable_by_default; |
||||
descriptor.name = name; |
||||
descriptor.description = description; |
||||
descriptor.unit = unit; |
||||
descriptor.label_keys = {label_keys.begin(), label_keys.end()}; |
||||
descriptor.optional_label_keys = {optional_label_keys.begin(), |
||||
optional_label_keys.end()}; |
||||
instruments.emplace(name, std::move(descriptor)); |
||||
GlobalDoubleHistogramHandle handle; |
||||
handle.index = index; |
||||
return handle; |
||||
} |
||||
|
||||
void GlobalInstrumentsRegistry::ForEach( |
||||
absl::FunctionRef<void(const GlobalInstrumentDescriptor&)> f) { |
||||
for (const auto& instrument : GetInstrumentList()) { |
||||
f(instrument.second); |
||||
} |
||||
} |
||||
|
||||
void GlobalInstrumentsRegistry::TestOnlyResetGlobalInstrumentsRegistry() { |
||||
auto& instruments = GetInstrumentList(); |
||||
instruments.clear(); |
||||
} |
||||
|
||||
NoDestruct<Mutex> GlobalStatsPluginRegistry::mutex_; |
||||
NoDestruct<std::vector<std::shared_ptr<StatsPlugin>>> |
||||
GlobalStatsPluginRegistry::plugins_; |
||||
|
||||
void GlobalStatsPluginRegistry::RegisterStatsPlugin( |
||||
std::shared_ptr<StatsPlugin> plugin) { |
||||
MutexLock lock(&*mutex_); |
||||
plugins_->push_back(std::move(plugin)); |
||||
} |
||||
|
||||
GlobalStatsPluginRegistry::StatsPluginGroup |
||||
GlobalStatsPluginRegistry::GetStatsPluginsForChannel( |
||||
const StatsPlugin::ChannelScope& scope) { |
||||
MutexLock lock(&*mutex_); |
||||
StatsPluginGroup group; |
||||
for (const auto& plugin : *plugins_) { |
||||
if (plugin->IsEnabledForChannel(scope)) { |
||||
group.push_back(plugin); |
||||
} |
||||
} |
||||
return group; |
||||
} |
||||
|
||||
} // namespace grpc_core
|
@ -0,0 +1,231 @@ |
||||
// Copyright 2024 The gRPC Authors.
|
||||
//
|
||||
// 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.
|
||||
|
||||
#ifndef GRPC_SRC_CORE_LIB_CHANNEL_METRICS_H |
||||
#define GRPC_SRC_CORE_LIB_CHANNEL_METRICS_H |
||||
|
||||
#include <grpc/support/port_platform.h> |
||||
|
||||
#include <cstdint> |
||||
#include <memory> |
||||
#include <vector> |
||||
|
||||
#include "absl/functional/function_ref.h" |
||||
#include "absl/strings/string_view.h" |
||||
#include "absl/types/span.h" |
||||
|
||||
#include <grpc/support/log.h> |
||||
|
||||
#include "src/core/lib/channel/channel_args.h" |
||||
#include "src/core/lib/gprpp/no_destruct.h" |
||||
#include "src/core/lib/gprpp/sync.h" |
||||
|
||||
namespace grpc_core { |
||||
|
||||
// A global registry of instruments(metrics). This API is designed to be used to
|
||||
// register instruments (Counter and Histogram) as part of program startup,
|
||||
// before the execution of the main function (during dynamic initialization
|
||||
// time). Using this API after the main function begins may result into missing
|
||||
// instruments. This API is thread-unsafe.
|
||||
class GlobalInstrumentsRegistry { |
||||
public: |
||||
enum class ValueType { |
||||
kUndefined, |
||||
kUInt64, |
||||
kDouble, |
||||
}; |
||||
enum class InstrumentType { |
||||
kUndefined, |
||||
kCounter, |
||||
kHistogram, |
||||
}; |
||||
struct GlobalInstrumentDescriptor { |
||||
ValueType value_type; |
||||
InstrumentType instrument_type; |
||||
uint32_t index; |
||||
bool enable_by_default; |
||||
absl::string_view name; |
||||
absl::string_view description; |
||||
absl::string_view unit; |
||||
std::vector<absl::string_view> label_keys; |
||||
std::vector<absl::string_view> optional_label_keys; |
||||
}; |
||||
struct GlobalHandle { |
||||
// This is the index for the corresponding registered instrument that
|
||||
// StatsPlugins can use to uniquely identify an instrument in the current
|
||||
// process. Though this is not guaranteed to be stable between different
|
||||
// runs or between different versions.
|
||||
uint32_t index; |
||||
}; |
||||
struct GlobalUInt64CounterHandle : public GlobalHandle {}; |
||||
struct GlobalDoubleCounterHandle : public GlobalHandle {}; |
||||
struct GlobalUInt64HistogramHandle : public GlobalHandle {}; |
||||
struct GlobalDoubleHistogramHandle : public GlobalHandle {}; |
||||
|
||||
// Creates instrument in the GlobalInstrumentsRegistry.
|
||||
static GlobalUInt64CounterHandle RegisterUInt64Counter( |
||||
absl::string_view name, absl::string_view description, |
||||
absl::string_view unit, absl::Span<const absl::string_view> label_keys, |
||||
absl::Span<const absl::string_view> optional_label_keys, |
||||
bool enable_by_default); |
||||
static GlobalDoubleCounterHandle RegisterDoubleCounter( |
||||
absl::string_view name, absl::string_view description, |
||||
absl::string_view unit, absl::Span<const absl::string_view> label_keys, |
||||
absl::Span<const absl::string_view> optional_label_keys, |
||||
bool enable_by_default); |
||||
static GlobalUInt64HistogramHandle RegisterUInt64Histogram( |
||||
absl::string_view name, absl::string_view description, |
||||
absl::string_view unit, absl::Span<const absl::string_view> label_keys, |
||||
absl::Span<const absl::string_view> optional_label_keys, |
||||
bool enable_by_default); |
||||
static GlobalDoubleHistogramHandle RegisterDoubleHistogram( |
||||
absl::string_view name, absl::string_view description, |
||||
absl::string_view unit, absl::Span<const absl::string_view> label_keys, |
||||
absl::Span<const absl::string_view> optional_label_keys, |
||||
bool enable_by_default); |
||||
static void ForEach( |
||||
absl::FunctionRef<void(const GlobalInstrumentDescriptor&)> f); |
||||
|
||||
static void TestOnlyResetGlobalInstrumentsRegistry(); |
||||
|
||||
GlobalInstrumentsRegistry() = delete; |
||||
}; |
||||
|
||||
// The StatsPlugin interface.
|
||||
class StatsPlugin { |
||||
public: |
||||
class ChannelScope { |
||||
public: |
||||
ChannelScope(absl::string_view target, absl::string_view authority) |
||||
: target_(target), authority_(authority) {} |
||||
|
||||
absl::string_view target() const { return target_; } |
||||
absl::string_view authority() const { return authority_; } |
||||
|
||||
private: |
||||
absl::string_view target_; |
||||
absl::string_view authority_; |
||||
}; |
||||
|
||||
virtual ~StatsPlugin() = default; |
||||
|
||||
virtual bool IsEnabledForChannel(const ChannelScope& scope) const = 0; |
||||
virtual bool IsEnabledForServer(const ChannelArgs& args) const = 0; |
||||
|
||||
virtual void AddCounter( |
||||
GlobalInstrumentsRegistry::GlobalUInt64CounterHandle handle, |
||||
uint64_t value, absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) = 0; |
||||
virtual void AddCounter( |
||||
GlobalInstrumentsRegistry::GlobalDoubleCounterHandle handle, double value, |
||||
absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) = 0; |
||||
virtual void RecordHistogram( |
||||
GlobalInstrumentsRegistry::GlobalUInt64HistogramHandle handle, |
||||
uint64_t value, absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) = 0; |
||||
virtual void RecordHistogram( |
||||
GlobalInstrumentsRegistry::GlobalDoubleHistogramHandle handle, |
||||
double value, absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) = 0; |
||||
// TODO(yijiem): Details pending.
|
||||
// std::unique_ptr<AsyncInstrument> GetObservableGauge(
|
||||
// absl::string_view name, absl::string_view description,
|
||||
// absl::string_view unit);
|
||||
// AsyncInstrument* GetObservableCounter(
|
||||
// absl::string_view name, absl::string_view description,
|
||||
// absl::string_view unit);
|
||||
|
||||
// TODO(yijiem): This is an optimization for the StatsPlugin to create its own
|
||||
// representation of the label_values and use it multiple times. We would
|
||||
// change AddCounter and RecordHistogram to take RefCountedPtr<LabelValueSet>
|
||||
// and also change the StatsPluginsGroup to support this.
|
||||
// Use the StatsPlugin to get a representation of label values that can be
|
||||
// saved for multiple uses later.
|
||||
// virtual RefCountedPtr<LabelValueSet> MakeLabelValueSet(
|
||||
// absl::Span<absl::string_view> label_values) = 0;
|
||||
}; |
||||
|
||||
// A global registry of StatsPlugins. It has shared ownership to the registered
|
||||
// StatsPlugins. This API is supposed to be used during runtime after the main
|
||||
// function begins. This API is thread-safe.
|
||||
class GlobalStatsPluginRegistry { |
||||
public: |
||||
class StatsPluginGroup { |
||||
public: |
||||
void push_back(std::shared_ptr<StatsPlugin> plugin) { |
||||
plugins_.push_back(std::move(plugin)); |
||||
} |
||||
|
||||
void AddCounter(GlobalInstrumentsRegistry::GlobalUInt64CounterHandle handle, |
||||
uint64_t value, |
||||
absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) { |
||||
for (auto& plugin : plugins_) { |
||||
plugin->AddCounter(handle, value, label_values, optional_values); |
||||
} |
||||
} |
||||
void AddCounter(GlobalInstrumentsRegistry::GlobalDoubleCounterHandle handle, |
||||
double value, |
||||
absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) { |
||||
for (auto& plugin : plugins_) { |
||||
plugin->AddCounter(handle, value, label_values, optional_values); |
||||
} |
||||
} |
||||
void RecordHistogram( |
||||
GlobalInstrumentsRegistry::GlobalUInt64HistogramHandle handle, |
||||
uint64_t value, absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) { |
||||
for (auto& plugin : plugins_) { |
||||
plugin->RecordHistogram(handle, value, label_values, optional_values); |
||||
} |
||||
} |
||||
void RecordHistogram( |
||||
GlobalInstrumentsRegistry::GlobalDoubleHistogramHandle handle, |
||||
double value, absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) { |
||||
for (auto& plugin : plugins_) { |
||||
plugin->RecordHistogram(handle, value, label_values, optional_values); |
||||
} |
||||
} |
||||
|
||||
private: |
||||
std::vector<std::shared_ptr<StatsPlugin>> plugins_; |
||||
}; |
||||
|
||||
static void RegisterStatsPlugin(std::shared_ptr<StatsPlugin> plugin); |
||||
// The following two functions can be invoked to get a StatsPluginGroup for
|
||||
// a specified scope.
|
||||
static StatsPluginGroup GetStatsPluginsForChannel( |
||||
const StatsPlugin::ChannelScope& scope); |
||||
// TODO(yijiem): Implement this.
|
||||
// StatsPluginsGroup GetStatsPluginsForServer(ChannelArgs& args);
|
||||
|
||||
static void TestOnlyResetGlobalStatsPluginRegistry() { |
||||
MutexLock lock(&*mutex_); |
||||
plugins_->clear(); |
||||
} |
||||
|
||||
private: |
||||
GlobalStatsPluginRegistry() = default; |
||||
|
||||
static NoDestruct<Mutex> mutex_; |
||||
static NoDestruct<std::vector<std::shared_ptr<StatsPlugin>>> plugins_ |
||||
ABSL_GUARDED_BY(mutex_); |
||||
}; |
||||
|
||||
} // namespace grpc_core
|
||||
|
||||
#endif // GRPC_SRC_CORE_LIB_CHANNEL_METRICS_H
|
@ -0,0 +1,528 @@ |
||||
// Copyright 2024 The gRPC Authors.
|
||||
//
|
||||
// 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.
|
||||
|
||||
#include "src/core/lib/channel/metrics.h" |
||||
|
||||
#include <memory> |
||||
|
||||
#include "absl/container/flat_hash_map.h" |
||||
#include "absl/strings/match.h" |
||||
#include "absl/strings/str_cat.h" |
||||
#include "absl/strings/str_join.h" |
||||
#include "gmock/gmock.h" |
||||
#include "gtest/gtest.h" |
||||
|
||||
#include "test/core/util/test_config.h" |
||||
|
||||
namespace grpc_core { |
||||
namespace { |
||||
|
||||
void AddKeyValuePairs(absl::Span<const absl::string_view> keys, |
||||
absl::Span<const absl::string_view> values, |
||||
std::vector<std::string>* key_value_pairs) { |
||||
GPR_ASSERT(keys.size() == values.size()); |
||||
for (size_t i = 0; i < keys.size(); ++i) { |
||||
key_value_pairs->push_back(absl::StrCat(keys[i], "=", values[i])); |
||||
} |
||||
} |
||||
|
||||
std::string MakeLabelString( |
||||
absl::Span<const absl::string_view> label_keys, |
||||
absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_label_keys, |
||||
absl::Span<const absl::string_view> optional_values) { |
||||
std::vector<std::string> key_value_pairs; |
||||
AddKeyValuePairs(label_keys, label_values, &key_value_pairs); |
||||
AddKeyValuePairs(optional_label_keys, optional_values, &key_value_pairs); |
||||
return absl::StrJoin(key_value_pairs, ","); |
||||
} |
||||
|
||||
// TODO(yijiem): Move this to test/core/util/fake_stats_plugin.h
|
||||
class FakeStatsPlugin : public StatsPlugin { |
||||
public: |
||||
bool IsEnabledForChannel( |
||||
const StatsPlugin::ChannelScope& scope) const override { |
||||
return channel_filter_(scope); |
||||
} |
||||
|
||||
bool IsEnabledForServer(const ChannelArgs& /*args*/) const override { |
||||
return false; |
||||
} |
||||
|
||||
void AddCounter( |
||||
GlobalInstrumentsRegistry::GlobalUInt64CounterHandle handle, |
||||
uint64_t value, absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) override { |
||||
// The problem with this approach is that we initialize uint64_counters_ in
|
||||
// BuildAndRegister by querying the GlobalInstrumentsRegistry at the time.
|
||||
// If the GlobalInstrumentsRegistry has changed since then (which we
|
||||
// currently don't allow), we might not have seen that descriptor nor have
|
||||
// we created an instrument for it. We probably could copy the existing
|
||||
// instruments at build time and for the handle that we haven't seen we will
|
||||
// just ignore it here. This would also prevent us from having to lock the
|
||||
// GlobalInstrumentsRegistry everytime a metric is recorded. But this is not
|
||||
// a concern for now.
|
||||
auto iter = uint64_counters_.find(handle.index); |
||||
if (iter == uint64_counters_.end()) { |
||||
return; |
||||
} |
||||
iter->second.Add(value, label_values, optional_values); |
||||
} |
||||
void AddCounter( |
||||
GlobalInstrumentsRegistry::GlobalDoubleCounterHandle handle, double value, |
||||
absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) override { |
||||
auto iter = double_counters_.find(handle.index); |
||||
if (iter == double_counters_.end()) { |
||||
return; |
||||
} |
||||
iter->second.Add(value, label_values, optional_values); |
||||
} |
||||
void RecordHistogram( |
||||
GlobalInstrumentsRegistry::GlobalUInt64HistogramHandle handle, |
||||
uint64_t value, absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) override { |
||||
auto iter = uint64_histograms_.find(handle.index); |
||||
if (iter == uint64_histograms_.end()) { |
||||
return; |
||||
} |
||||
iter->second.Record(value, label_values, optional_values); |
||||
} |
||||
void RecordHistogram( |
||||
GlobalInstrumentsRegistry::GlobalDoubleHistogramHandle handle, |
||||
double value, absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) override { |
||||
auto iter = double_histograms_.find(handle.index); |
||||
if (iter == double_histograms_.end()) { |
||||
return; |
||||
} |
||||
iter->second.Record(value, label_values, optional_values); |
||||
} |
||||
|
||||
absl::optional<uint64_t> GetCounterValue( |
||||
GlobalInstrumentsRegistry::GlobalUInt64CounterHandle handle, |
||||
absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) { |
||||
auto iter = uint64_counters_.find(handle.index); |
||||
if (iter == uint64_counters_.end()) { |
||||
return absl::nullopt; |
||||
} |
||||
return iter->second.GetValue(label_values, optional_values); |
||||
} |
||||
absl::optional<double> GetCounterValue( |
||||
GlobalInstrumentsRegistry::GlobalDoubleCounterHandle handle, |
||||
absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) { |
||||
auto iter = double_counters_.find(handle.index); |
||||
if (iter == double_counters_.end()) { |
||||
return absl::nullopt; |
||||
} |
||||
return iter->second.GetValue(label_values, optional_values); |
||||
} |
||||
absl::optional<std::vector<uint64_t>> GetHistogramValue( |
||||
GlobalInstrumentsRegistry::GlobalUInt64HistogramHandle handle, |
||||
absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) { |
||||
auto iter = uint64_histograms_.find(handle.index); |
||||
if (iter == uint64_histograms_.end()) { |
||||
return absl::nullopt; |
||||
} |
||||
return iter->second.GetValues(label_values, optional_values); |
||||
} |
||||
absl::optional<std::vector<double>> GetHistogramValue( |
||||
GlobalInstrumentsRegistry::GlobalDoubleHistogramHandle handle, |
||||
absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) { |
||||
auto iter = double_histograms_.find(handle.index); |
||||
if (iter == double_histograms_.end()) { |
||||
return absl::nullopt; |
||||
} |
||||
return iter->second.GetValues(label_values, optional_values); |
||||
} |
||||
|
||||
private: |
||||
friend class FakeStatsPluginBuilder; |
||||
|
||||
explicit FakeStatsPlugin( |
||||
absl::AnyInvocable<bool(const StatsPlugin::ChannelScope& /*scope*/) const> |
||||
channel_filter) |
||||
: channel_filter_(std::move(channel_filter)) { |
||||
GlobalInstrumentsRegistry::ForEach( |
||||
[this](const GlobalInstrumentsRegistry::GlobalInstrumentDescriptor& |
||||
descriptor) { |
||||
if (!descriptor.enable_by_default) { |
||||
return; |
||||
} |
||||
if (descriptor.instrument_type == |
||||
GlobalInstrumentsRegistry::InstrumentType::kCounter) { |
||||
if (descriptor.value_type == |
||||
GlobalInstrumentsRegistry::ValueType::kUInt64) { |
||||
uint64_counters_.emplace(descriptor.index, descriptor); |
||||
} else { |
||||
double_counters_.emplace(descriptor.index, descriptor); |
||||
} |
||||
} else { |
||||
EXPECT_EQ(descriptor.instrument_type, |
||||
GlobalInstrumentsRegistry::InstrumentType::kHistogram); |
||||
if (descriptor.value_type == |
||||
GlobalInstrumentsRegistry::ValueType::kUInt64) { |
||||
uint64_histograms_.emplace(descriptor.index, descriptor); |
||||
} else { |
||||
double_histograms_.emplace(descriptor.index, descriptor); |
||||
} |
||||
} |
||||
}); |
||||
} |
||||
|
||||
template <class T> |
||||
class Counter { |
||||
public: |
||||
explicit Counter(GlobalInstrumentsRegistry::GlobalInstrumentDescriptor u) |
||||
: name_(u.name), |
||||
description_(u.description), |
||||
unit_(u.unit), |
||||
label_keys_(std::move(u.label_keys)), |
||||
optional_label_keys_(std::move(u.optional_label_keys)) {} |
||||
|
||||
void Add(T t, absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) { |
||||
auto iter = storage_.find(MakeLabelString( |
||||
label_keys_, label_values, optional_label_keys_, optional_values)); |
||||
if (iter != storage_.end()) { |
||||
iter->second += t; |
||||
} else { |
||||
storage_[MakeLabelString(label_keys_, label_values, |
||||
optional_label_keys_, optional_values)] = t; |
||||
} |
||||
} |
||||
|
||||
absl::optional<T> GetValue( |
||||
absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) { |
||||
auto iter = storage_.find(MakeLabelString( |
||||
label_keys_, label_values, optional_label_keys_, optional_values)); |
||||
if (iter == storage_.end()) { |
||||
return absl::nullopt; |
||||
} |
||||
return iter->second; |
||||
} |
||||
|
||||
private: |
||||
absl::string_view name_; |
||||
absl::string_view description_; |
||||
absl::string_view unit_; |
||||
std::vector<absl::string_view> label_keys_; |
||||
std::vector<absl::string_view> optional_label_keys_; |
||||
// Aggregation of the same key attributes.
|
||||
absl::flat_hash_map<std::string, T> storage_; |
||||
}; |
||||
|
||||
template <class T> |
||||
class Histogram { |
||||
public: |
||||
explicit Histogram(GlobalInstrumentsRegistry::GlobalInstrumentDescriptor u) |
||||
: name_(u.name), |
||||
description_(u.description), |
||||
unit_(u.unit), |
||||
label_keys_(std::move(u.label_keys)), |
||||
optional_label_keys_(std::move(u.optional_label_keys)) {} |
||||
|
||||
void Record(T t, absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) { |
||||
std::string key = MakeLabelString(label_keys_, label_values, |
||||
optional_label_keys_, optional_values); |
||||
auto iter = storage_.find(key); |
||||
if (iter == storage_.end()) { |
||||
storage_.emplace(key, std::initializer_list<T>{t}); |
||||
} else { |
||||
iter->second.push_back(t); |
||||
} |
||||
} |
||||
|
||||
absl::optional<std::vector<T>> GetValues( |
||||
absl::Span<const absl::string_view> label_values, |
||||
absl::Span<const absl::string_view> optional_values) { |
||||
auto iter = storage_.find(MakeLabelString( |
||||
label_keys_, label_values, optional_label_keys_, optional_values)); |
||||
if (iter == storage_.end()) { |
||||
return absl::nullopt; |
||||
} |
||||
return iter->second; |
||||
} |
||||
|
||||
private: |
||||
absl::string_view name_; |
||||
absl::string_view description_; |
||||
absl::string_view unit_; |
||||
std::vector<absl::string_view> label_keys_; |
||||
std::vector<absl::string_view> optional_label_keys_; |
||||
absl::flat_hash_map<std::string, std::vector<T>> storage_; |
||||
}; |
||||
|
||||
absl::AnyInvocable<bool(const StatsPlugin::ChannelScope& /*scope*/) const> |
||||
channel_filter_; |
||||
// Instruments.
|
||||
absl::flat_hash_map<uint32_t, Counter<uint64_t>> uint64_counters_; |
||||
absl::flat_hash_map<uint32_t, Counter<double>> double_counters_; |
||||
absl::flat_hash_map<uint32_t, Histogram<uint64_t>> uint64_histograms_; |
||||
absl::flat_hash_map<uint32_t, Histogram<double>> double_histograms_; |
||||
}; |
||||
|
||||
// TODO(yijiem): Move this to test/core/util/fake_stats_plugin.h
|
||||
class FakeStatsPluginBuilder { |
||||
public: |
||||
FakeStatsPluginBuilder& SetChannelFilter( |
||||
absl::AnyInvocable<bool(const StatsPlugin::ChannelScope& /*scope*/) const> |
||||
channel_filter) { |
||||
channel_filter_ = std::move(channel_filter); |
||||
return *this; |
||||
} |
||||
|
||||
std::shared_ptr<FakeStatsPlugin> BuildAndRegister() { |
||||
auto f = std::shared_ptr<FakeStatsPlugin>( |
||||
new FakeStatsPlugin(std::move(channel_filter_))); |
||||
GlobalStatsPluginRegistry::RegisterStatsPlugin(f); |
||||
return f; |
||||
} |
||||
|
||||
private: |
||||
absl::AnyInvocable<bool(const StatsPlugin::ChannelScope& /*scope*/) const> |
||||
channel_filter_; |
||||
}; |
||||
|
||||
std::shared_ptr<FakeStatsPlugin> MakeStatsPluginForTarget( |
||||
absl::string_view target_suffix) { |
||||
return FakeStatsPluginBuilder() |
||||
.SetChannelFilter( |
||||
[target_suffix](const StatsPlugin::ChannelScope& scope) { |
||||
return absl::EndsWith(scope.target(), target_suffix); |
||||
}) |
||||
.BuildAndRegister(); |
||||
} |
||||
|
||||
class MetricsTest : public testing::Test { |
||||
public: |
||||
void TearDown() override { |
||||
GlobalInstrumentsRegistry::TestOnlyResetGlobalInstrumentsRegistry(); |
||||
GlobalStatsPluginRegistry::TestOnlyResetGlobalStatsPluginRegistry(); |
||||
} |
||||
}; |
||||
|
||||
TEST_F(MetricsTest, UInt64Counter) { |
||||
const absl::string_view kLabelKeys[] = {"label_key_1", "label_key_2"}; |
||||
const absl::string_view kOptionalLabelKeys[] = {"optional_label_key_1", |
||||
"optional_label_key_2"}; |
||||
auto uint64_counter_handle = GlobalInstrumentsRegistry::RegisterUInt64Counter( |
||||
"uint64_counter", "A simple uint64 counter.", "unit", kLabelKeys, |
||||
kOptionalLabelKeys, true); |
||||
constexpr absl::string_view kLabelValues[] = {"label_value_1", |
||||
"label_value_2"}; |
||||
constexpr absl::string_view kOptionalLabelValues[] = { |
||||
"optional_label_value_1", "optional_label_value_2"}; |
||||
constexpr absl::string_view kDomain1To4 = "domain1.domain2.domain3.domain4"; |
||||
constexpr absl::string_view kDomain2To4 = "domain2.domain3.domain4"; |
||||
constexpr absl::string_view kDomain3To4 = "domain3.domain4"; |
||||
auto plugin1 = MakeStatsPluginForTarget(kDomain1To4); |
||||
auto plugin2 = MakeStatsPluginForTarget(kDomain2To4); |
||||
auto plugin3 = MakeStatsPluginForTarget(kDomain3To4); |
||||
GlobalStatsPluginRegistry::GetStatsPluginsForChannel( |
||||
StatsPlugin::ChannelScope(kDomain1To4, "")) |
||||
.AddCounter(uint64_counter_handle, 1, kLabelValues, kOptionalLabelValues); |
||||
GlobalStatsPluginRegistry::GetStatsPluginsForChannel( |
||||
StatsPlugin::ChannelScope(kDomain2To4, "")) |
||||
.AddCounter(uint64_counter_handle, 2, kLabelValues, kOptionalLabelValues); |
||||
GlobalStatsPluginRegistry::GetStatsPluginsForChannel( |
||||
StatsPlugin::ChannelScope(kDomain3To4, "")) |
||||
.AddCounter(uint64_counter_handle, 3, kLabelValues, kOptionalLabelValues); |
||||
EXPECT_THAT(plugin1->GetCounterValue(uint64_counter_handle, kLabelValues, |
||||
kOptionalLabelValues), |
||||
::testing::Optional(1)); |
||||
EXPECT_THAT(plugin2->GetCounterValue(uint64_counter_handle, kLabelValues, |
||||
kOptionalLabelValues), |
||||
::testing::Optional(3)); |
||||
EXPECT_THAT(plugin3->GetCounterValue(uint64_counter_handle, kLabelValues, |
||||
kOptionalLabelValues), |
||||
::testing::Optional(6)); |
||||
} |
||||
|
||||
TEST_F(MetricsTest, DoubleCounter) { |
||||
const absl::string_view kLabelKeys[] = {"label_key_1", "label_key_2"}; |
||||
const absl::string_view kOptionalLabelKeys[] = {"optional_label_key_1", |
||||
"optional_label_key_2"}; |
||||
auto double_counter_handle = GlobalInstrumentsRegistry::RegisterDoubleCounter( |
||||
"double_counter", "A simple double counter.", "unit", kLabelKeys, |
||||
kOptionalLabelKeys, true); |
||||
constexpr absl::string_view kLabelValues[] = {"label_value_1", |
||||
"label_value_2"}; |
||||
constexpr absl::string_view kOptionalLabelValues[] = { |
||||
"optional_label_value_1", "optional_label_value_2"}; |
||||
constexpr absl::string_view kDomain1To4 = "domain1.domain2.domain3.domain4"; |
||||
constexpr absl::string_view kDomain2To4 = "domain2.domain3.domain4"; |
||||
constexpr absl::string_view kDomain3To4 = "domain3.domain4"; |
||||
auto plugin1 = MakeStatsPluginForTarget(kDomain1To4); |
||||
auto plugin2 = MakeStatsPluginForTarget(kDomain2To4); |
||||
auto plugin3 = MakeStatsPluginForTarget(kDomain3To4); |
||||
GlobalStatsPluginRegistry::GetStatsPluginsForChannel( |
||||
StatsPlugin::ChannelScope(kDomain1To4, "")) |
||||
.AddCounter(double_counter_handle, 1.23, kLabelValues, |
||||
kOptionalLabelValues); |
||||
GlobalStatsPluginRegistry::GetStatsPluginsForChannel( |
||||
StatsPlugin::ChannelScope(kDomain2To4, "")) |
||||
.AddCounter(double_counter_handle, 2.34, kLabelValues, |
||||
kOptionalLabelValues); |
||||
GlobalStatsPluginRegistry::GetStatsPluginsForChannel( |
||||
StatsPlugin::ChannelScope(kDomain3To4, "")) |
||||
.AddCounter(double_counter_handle, 3.45, kLabelValues, |
||||
kOptionalLabelValues); |
||||
EXPECT_THAT(plugin1->GetCounterValue(double_counter_handle, kLabelValues, |
||||
kOptionalLabelValues), |
||||
::testing::Optional(1.23)); |
||||
EXPECT_THAT(plugin2->GetCounterValue(double_counter_handle, kLabelValues, |
||||
kOptionalLabelValues), |
||||
::testing::Optional(3.57)); |
||||
EXPECT_THAT(plugin3->GetCounterValue(double_counter_handle, kLabelValues, |
||||
kOptionalLabelValues), |
||||
::testing::Optional(7.02)); |
||||
} |
||||
|
||||
TEST_F(MetricsTest, UInt64Histogram) { |
||||
const absl::string_view kLabelKeys[] = {"label_key_1", "label_key_2"}; |
||||
const absl::string_view kOptionalLabelKeys[] = {"optional_label_key_1", |
||||
"optional_label_key_2"}; |
||||
auto uint64_histogram_handle = |
||||
GlobalInstrumentsRegistry::RegisterUInt64Histogram( |
||||
"uint64_histogram", "A simple uint64 histogram.", "unit", kLabelKeys, |
||||
kOptionalLabelKeys, true); |
||||
constexpr absl::string_view kLabelValues[] = {"label_value_1", |
||||
"label_value_2"}; |
||||
constexpr absl::string_view kOptionalLabelValues[] = { |
||||
"optional_label_value_1", "optional_label_value_2"}; |
||||
constexpr absl::string_view kDomain1To4 = "domain1.domain2.domain3.domain4"; |
||||
constexpr absl::string_view kDomain2To4 = "domain2.domain3.domain4"; |
||||
constexpr absl::string_view kDomain3To4 = "domain3.domain4"; |
||||
auto plugin1 = MakeStatsPluginForTarget(kDomain1To4); |
||||
auto plugin2 = MakeStatsPluginForTarget(kDomain2To4); |
||||
auto plugin3 = MakeStatsPluginForTarget(kDomain3To4); |
||||
GlobalStatsPluginRegistry::GetStatsPluginsForChannel( |
||||
StatsPlugin::ChannelScope(kDomain1To4, "")) |
||||
.RecordHistogram(uint64_histogram_handle, 1, kLabelValues, |
||||
kOptionalLabelValues); |
||||
GlobalStatsPluginRegistry::GetStatsPluginsForChannel( |
||||
StatsPlugin::ChannelScope(kDomain2To4, "")) |
||||
.RecordHistogram(uint64_histogram_handle, 2, kLabelValues, |
||||
kOptionalLabelValues); |
||||
GlobalStatsPluginRegistry::GetStatsPluginsForChannel( |
||||
StatsPlugin::ChannelScope(kDomain3To4, "")) |
||||
.RecordHistogram(uint64_histogram_handle, 3, kLabelValues, |
||||
kOptionalLabelValues); |
||||
EXPECT_THAT(plugin1->GetHistogramValue(uint64_histogram_handle, kLabelValues, |
||||
kOptionalLabelValues), |
||||
::testing::Optional(::testing::UnorderedElementsAre(1))); |
||||
EXPECT_THAT(plugin2->GetHistogramValue(uint64_histogram_handle, kLabelValues, |
||||
kOptionalLabelValues), |
||||
::testing::Optional(::testing::UnorderedElementsAre(1, 2))); |
||||
EXPECT_THAT(plugin3->GetHistogramValue(uint64_histogram_handle, kLabelValues, |
||||
kOptionalLabelValues), |
||||
::testing::Optional(::testing::UnorderedElementsAre(1, 2, 3))); |
||||
} |
||||
|
||||
TEST_F(MetricsTest, DoubleHistogram) { |
||||
const absl::string_view kLabelKeys[] = {"label_key_1", "label_key_2"}; |
||||
const absl::string_view kOptionalLabelKeys[] = {"optional_label_key_1", |
||||
"optional_label_key_2"}; |
||||
auto double_histogram_handle = |
||||
GlobalInstrumentsRegistry::RegisterDoubleHistogram( |
||||
"double_histogram", "A simple double histogram.", "unit", kLabelKeys, |
||||
kOptionalLabelKeys, true); |
||||
constexpr absl::string_view kLabelValues[] = {"label_value_1", |
||||
"label_value_2"}; |
||||
constexpr absl::string_view kOptionalLabelValues[] = { |
||||
"optional_label_value_1", "optional_label_value_2"}; |
||||
constexpr absl::string_view kDomain1To4 = "domain1.domain2.domain3.domain4"; |
||||
constexpr absl::string_view kDomain2To4 = "domain2.domain3.domain4"; |
||||
constexpr absl::string_view kDomain3To4 = "domain3.domain4"; |
||||
auto plugin1 = MakeStatsPluginForTarget(kDomain1To4); |
||||
auto plugin2 = MakeStatsPluginForTarget(kDomain2To4); |
||||
auto plugin3 = MakeStatsPluginForTarget(kDomain3To4); |
||||
GlobalStatsPluginRegistry::GetStatsPluginsForChannel( |
||||
StatsPlugin::ChannelScope(kDomain1To4, "")) |
||||
.RecordHistogram(double_histogram_handle, 1.23, kLabelValues, |
||||
kOptionalLabelValues); |
||||
GlobalStatsPluginRegistry::GetStatsPluginsForChannel( |
||||
StatsPlugin::ChannelScope(kDomain2To4, "")) |
||||
.RecordHistogram(double_histogram_handle, 2.34, kLabelValues, |
||||
kOptionalLabelValues); |
||||
GlobalStatsPluginRegistry::GetStatsPluginsForChannel( |
||||
StatsPlugin::ChannelScope(kDomain3To4, "")) |
||||
.RecordHistogram(double_histogram_handle, 3.45, kLabelValues, |
||||
kOptionalLabelValues); |
||||
EXPECT_THAT(plugin1->GetHistogramValue(double_histogram_handle, kLabelValues, |
||||
kOptionalLabelValues), |
||||
::testing::Optional(::testing::UnorderedElementsAre(1.23))); |
||||
EXPECT_THAT(plugin2->GetHistogramValue(double_histogram_handle, kLabelValues, |
||||
kOptionalLabelValues), |
||||
::testing::Optional(::testing::UnorderedElementsAre(1.23, 2.34))); |
||||
EXPECT_THAT( |
||||
plugin3->GetHistogramValue(double_histogram_handle, kLabelValues, |
||||
kOptionalLabelValues), |
||||
::testing::Optional(::testing::UnorderedElementsAre(1.23, 2.34, 3.45))); |
||||
} |
||||
|
||||
TEST_F(MetricsTest, DisableByDefaultMetricIsNotRecordedByFakeStatsPlugin) { |
||||
const absl::string_view kLabelKeys[] = {"label_key_1", "label_key_2"}; |
||||
const absl::string_view kOptionalLabelKeys[] = {"optional_label_key_1", |
||||
"optional_label_key_2"}; |
||||
auto double_histogram_handle = |
||||
GlobalInstrumentsRegistry::RegisterDoubleHistogram( |
||||
"double_histogram", "A simple double histogram.", "unit", kLabelKeys, |
||||
kOptionalLabelKeys, /*enable_by_default=*/false); |
||||
constexpr absl::string_view kLabelValues[] = {"label_value_1", |
||||
"label_value_2"}; |
||||
constexpr absl::string_view kOptionalLabelValues[] = { |
||||
"optional_label_value_1", "optional_label_value_2"}; |
||||
constexpr absl::string_view kDomain1To4 = "domain1.domain2.domain3.domain4"; |
||||
auto plugin = MakeStatsPluginForTarget(kDomain1To4); |
||||
GlobalStatsPluginRegistry::GetStatsPluginsForChannel( |
||||
StatsPlugin::ChannelScope(kDomain1To4, "")) |
||||
.RecordHistogram(double_histogram_handle, 1.23, kLabelValues, |
||||
kOptionalLabelValues); |
||||
EXPECT_EQ(plugin->GetHistogramValue(double_histogram_handle, kLabelValues, |
||||
kOptionalLabelValues), |
||||
absl::nullopt); |
||||
} |
||||
|
||||
using MetricsDeathTest = MetricsTest; |
||||
|
||||
TEST_F(MetricsDeathTest, RegisterTheSameMetricNameWouldCrash) { |
||||
const absl::string_view kLabelKeys[] = {"label_key_1", "label_key_2"}; |
||||
const absl::string_view kOptionalLabelKeys[] = {"optional_label_key_1", |
||||
"optional_label_key_2"}; |
||||
(void)GlobalInstrumentsRegistry::RegisterDoubleHistogram( |
||||
"double_histogram", "A simple double histogram.", "unit", kLabelKeys, |
||||
kOptionalLabelKeys, true); |
||||
EXPECT_DEATH(GlobalInstrumentsRegistry::RegisterDoubleHistogram( |
||||
"double_histogram", "A simple double histogram.", "unit", |
||||
kLabelKeys, kOptionalLabelKeys, true), |
||||
"Metric name double_histogram has already been registered."); |
||||
} |
||||
|
||||
} // namespace
|
||||
} // namespace grpc_core
|
||||
|
||||
int main(int argc, char** argv) { |
||||
grpc::testing::TestEnvironment env(&argc, argv); |
||||
::testing::InitGoogleTest(&argc, argv); |
||||
int ret = RUN_ALL_TESTS(); |
||||
return ret; |
||||
} |
Loading…
Reference in new issue