1 // Copyright 2014 The Chromium Authors
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4
5 #include "components/metrics/unsent_log_store.h"
6
7 #include <cmath>
8 #include <memory>
9 #include <string>
10 #include <utility>
11
12 #include "base/base64.h"
13 #include "base/hash/sha1.h"
14 #include "base/metrics/histogram_macros.h"
15 #include "base/strings/string_number_conversions.h"
16 #include "base/strings/string_util.h"
17 #include "base/timer/elapsed_timer.h"
18 #include "components/metrics/unsent_log_store_metrics.h"
19 #include "components/prefs/pref_service.h"
20 #include "components/prefs/scoped_user_pref_update.h"
21 #include "crypto/hmac.h"
22 #include "third_party/zlib/google/compression_utils.h"
23
24 namespace metrics {
25
26 namespace {
27
28 const char kLogHashKey[] = "hash";
29 const char kLogSignatureKey[] = "signature";
30 const char kLogTimestampKey[] = "timestamp";
31 const char kLogDataKey[] = "data";
32 const char kLogUnsentCountKey[] = "unsent_samples_count";
33 const char kLogSentCountKey[] = "sent_samples_count";
34 const char kLogPersistedSizeInKbKey[] = "unsent_persisted_size_in_kb";
35 const char kLogUserIdKey[] = "user_id";
36
EncodeToBase64(const std::string & to_convert)37 std::string EncodeToBase64(const std::string& to_convert) {
38 DCHECK(to_convert.data());
39 std::string base64_result;
40 base::Base64Encode(to_convert, &base64_result);
41 return base64_result;
42 }
43
DecodeFromBase64(const std::string & to_convert)44 std::string DecodeFromBase64(const std::string& to_convert) {
45 std::string result;
46 base::Base64Decode(to_convert, &result);
47 return result;
48 }
49
50 // Used to write unsent logs to prefs.
51 class LogsPrefWriter {
52 public:
53 // Create a writer that will write unsent logs to |list_value|. |list_value|
54 // should be a base::Value::List representing a pref. Clears the contents of
55 // |list_value|.
LogsPrefWriter(base::Value::List * list_value)56 explicit LogsPrefWriter(base::Value::List* list_value)
57 : list_value_(list_value) {
58 DCHECK(list_value);
59 list_value->clear();
60 }
61
62 LogsPrefWriter(const LogsPrefWriter&) = delete;
63 LogsPrefWriter& operator=(const LogsPrefWriter&) = delete;
64
~LogsPrefWriter()65 ~LogsPrefWriter() { DCHECK(finished_); }
66
67 // Persists |log| by appending it to |list_value_|.
WriteLogEntry(UnsentLogStore::LogInfo * log)68 void WriteLogEntry(UnsentLogStore::LogInfo* log) {
69 DCHECK(!finished_);
70
71 base::Value::Dict dict_value;
72 dict_value.Set(kLogHashKey, EncodeToBase64(log->hash));
73 dict_value.Set(kLogSignatureKey, EncodeToBase64(log->signature));
74 dict_value.Set(kLogDataKey, EncodeToBase64(log->compressed_log_data));
75 dict_value.Set(kLogTimestampKey, log->timestamp);
76
77 auto user_id = log->log_metadata.user_id;
78 if (user_id.has_value()) {
79 dict_value.Set(kLogUserIdKey,
80 EncodeToBase64(base::NumberToString(user_id.value())));
81 }
82 list_value_->Append(std::move(dict_value));
83
84 auto samples_count = log->log_metadata.samples_count;
85 if (samples_count.has_value()) {
86 unsent_samples_count_ += samples_count.value();
87 }
88 unsent_persisted_size_ += log->compressed_log_data.length();
89 ++unsent_logs_count_;
90 }
91
92 // Indicates to this writer that it is finished, and that it should not write
93 // any more logs. This also reverses |list_value_| in order to maintain the
94 // original order of the logs that were written.
Finish()95 void Finish() {
96 DCHECK(!finished_);
97 finished_ = true;
98 std::reverse(list_value_->begin(), list_value_->end());
99 }
100
unsent_samples_count() const101 base::HistogramBase::Count unsent_samples_count() const {
102 return unsent_samples_count_;
103 }
104
unsent_persisted_size() const105 size_t unsent_persisted_size() const { return unsent_persisted_size_; }
106
unsent_logs_count() const107 size_t unsent_logs_count() const { return unsent_logs_count_; }
108
109 private:
110 // The list where the logs will be written to. This should represent a pref.
111 raw_ptr<base::Value::List> list_value_;
112
113 // Whether or not this writer has finished writing to pref.
114 bool finished_ = false;
115
116 // The total number of histogram samples written so far.
117 base::HistogramBase::Count unsent_samples_count_ = 0;
118
119 // The total size of logs written so far.
120 size_t unsent_persisted_size_ = 0;
121
122 // The total number of logs written so far.
123 size_t unsent_logs_count_ = 0;
124 };
125
GetString(const base::Value::Dict & dict,base::StringPiece key,std::string & out)126 bool GetString(const base::Value::Dict& dict,
127 base::StringPiece key,
128 std::string& out) {
129 const std::string* value = dict.FindString(key);
130 if (!value)
131 return false;
132 out = *value;
133 return true;
134 }
135
136 } // namespace
137
138 UnsentLogStore::LogInfo::LogInfo() = default;
139 UnsentLogStore::LogInfo::~LogInfo() = default;
140
Init(const std::string & log_data,const std::string & log_timestamp,const std::string & signing_key,const LogMetadata & optional_log_metadata)141 void UnsentLogStore::LogInfo::Init(const std::string& log_data,
142 const std::string& log_timestamp,
143 const std::string& signing_key,
144 const LogMetadata& optional_log_metadata) {
145 DCHECK(!log_data.empty());
146
147 if (!compression::GzipCompress(log_data, &compressed_log_data)) {
148 NOTREACHED();
149 return;
150 }
151
152 hash = base::SHA1HashString(log_data);
153
154 if (!ComputeHMACForLog(log_data, signing_key, &signature)) {
155 NOTREACHED() << "HMAC signing failed";
156 }
157
158 timestamp = log_timestamp;
159 this->log_metadata = optional_log_metadata;
160 }
161
Init(const std::string & log_data,const std::string & signing_key,const LogMetadata & optional_log_metadata)162 void UnsentLogStore::LogInfo::Init(const std::string& log_data,
163 const std::string& signing_key,
164 const LogMetadata& optional_log_metadata) {
165 Init(log_data, base::NumberToString(base::Time::Now().ToTimeT()), signing_key,
166 optional_log_metadata);
167 }
168
UnsentLogStore(std::unique_ptr<UnsentLogStoreMetrics> metrics,PrefService * local_state,const char * log_data_pref_name,const char * metadata_pref_name,size_t min_log_count,size_t min_log_bytes,size_t max_log_size,const std::string & signing_key,MetricsLogsEventManager * logs_event_manager)169 UnsentLogStore::UnsentLogStore(std::unique_ptr<UnsentLogStoreMetrics> metrics,
170 PrefService* local_state,
171 const char* log_data_pref_name,
172 const char* metadata_pref_name,
173 size_t min_log_count,
174 size_t min_log_bytes,
175 size_t max_log_size,
176 const std::string& signing_key,
177 MetricsLogsEventManager* logs_event_manager)
178 : metrics_(std::move(metrics)),
179 local_state_(local_state),
180 log_data_pref_name_(log_data_pref_name),
181 metadata_pref_name_(metadata_pref_name),
182 min_log_count_(min_log_count),
183 min_log_bytes_(min_log_bytes),
184 max_log_size_(max_log_size != 0 ? max_log_size : static_cast<size_t>(-1)),
185 signing_key_(signing_key),
186 logs_event_manager_(logs_event_manager),
187 staged_log_index_(-1) {
188 DCHECK(local_state_);
189 // One of the limit arguments must be non-zero.
190 DCHECK(min_log_count_ > 0 || min_log_bytes_ > 0);
191 }
192
193 UnsentLogStore::~UnsentLogStore() = default;
194
has_unsent_logs() const195 bool UnsentLogStore::has_unsent_logs() const {
196 return !!size();
197 }
198
199 // True if a log has been staged.
has_staged_log() const200 bool UnsentLogStore::has_staged_log() const {
201 return staged_log_index_ != -1;
202 }
203
204 // Returns the compressed data of the element in the front of the list.
staged_log() const205 const std::string& UnsentLogStore::staged_log() const {
206 DCHECK(has_staged_log());
207 return list_[staged_log_index_]->compressed_log_data;
208 }
209
210 // Returns the hash of element in the front of the list.
staged_log_hash() const211 const std::string& UnsentLogStore::staged_log_hash() const {
212 DCHECK(has_staged_log());
213 return list_[staged_log_index_]->hash;
214 }
215
216 // Returns the signature of element in the front of the list.
staged_log_signature() const217 const std::string& UnsentLogStore::staged_log_signature() const {
218 DCHECK(has_staged_log());
219 return list_[staged_log_index_]->signature;
220 }
221
222 // Returns the timestamp of the element in the front of the list.
staged_log_timestamp() const223 const std::string& UnsentLogStore::staged_log_timestamp() const {
224 DCHECK(has_staged_log());
225 return list_[staged_log_index_]->timestamp;
226 }
227
228 // Returns the user id of the current staged log.
staged_log_user_id() const229 absl::optional<uint64_t> UnsentLogStore::staged_log_user_id() const {
230 DCHECK(has_staged_log());
231 return list_[staged_log_index_]->log_metadata.user_id;
232 }
233
234 // static
ComputeHMACForLog(const std::string & log_data,const std::string & signing_key,std::string * signature)235 bool UnsentLogStore::ComputeHMACForLog(const std::string& log_data,
236 const std::string& signing_key,
237 std::string* signature) {
238 crypto::HMAC hmac(crypto::HMAC::SHA256);
239 const size_t digest_length = hmac.DigestLength();
240 unsigned char* hmac_data = reinterpret_cast<unsigned char*>(
241 base::WriteInto(signature, digest_length + 1));
242 return hmac.Init(signing_key) &&
243 hmac.Sign(log_data, hmac_data, digest_length);
244 }
245
StageNextLog()246 void UnsentLogStore::StageNextLog() {
247 // CHECK, rather than DCHECK, because swap()ing with an empty list causes
248 // hard-to-identify crashes much later.
249 CHECK(!list_.empty());
250 DCHECK(!has_staged_log());
251 staged_log_index_ = list_.size() - 1;
252 NotifyLogEvent(MetricsLogsEventManager::LogEvent::kLogStaged,
253 list_[staged_log_index_]->hash);
254 DCHECK(has_staged_log());
255 }
256
DiscardStagedLog(base::StringPiece reason)257 void UnsentLogStore::DiscardStagedLog(base::StringPiece reason) {
258 DCHECK(has_staged_log());
259 DCHECK_LT(static_cast<size_t>(staged_log_index_), list_.size());
260 NotifyLogEvent(MetricsLogsEventManager::LogEvent::kLogDiscarded,
261 list_[staged_log_index_]->hash, reason);
262 list_.erase(list_.begin() + staged_log_index_);
263 staged_log_index_ = -1;
264 }
265
MarkStagedLogAsSent()266 void UnsentLogStore::MarkStagedLogAsSent() {
267 DCHECK(has_staged_log());
268 DCHECK_LT(static_cast<size_t>(staged_log_index_), list_.size());
269 auto samples_count = list_[staged_log_index_]->log_metadata.samples_count;
270 if (samples_count.has_value())
271 total_samples_sent_ += samples_count.value();
272 NotifyLogEvent(MetricsLogsEventManager::LogEvent::kLogUploaded,
273 list_[staged_log_index_]->hash);
274 }
275
TrimAndPersistUnsentLogs(bool overwrite_in_memory_store)276 void UnsentLogStore::TrimAndPersistUnsentLogs(bool overwrite_in_memory_store) {
277 ScopedListPrefUpdate update(local_state_, log_data_pref_name_);
278 LogsPrefWriter writer(&update.Get());
279
280 std::vector<std::unique_ptr<LogInfo>> trimmed_list;
281 size_t bytes_used = 0;
282
283 // The distance of the staged log from the end of the list of logs, which is
284 // usually 0 (end of list). This is used in case there is currently a staged
285 // log, which may or may not get trimmed. We want to keep track of the new
286 // position of the staged log after trimming so that we can update
287 // |staged_log_index_|.
288 absl::optional<size_t> staged_index_distance;
289
290 // Reverse order, so newest ones are prioritized.
291 for (int i = list_.size() - 1; i >= 0; --i) {
292 size_t log_size = list_[i]->compressed_log_data.length();
293 // Hit the caps, we can stop moving the logs.
294 if (bytes_used >= min_log_bytes_ &&
295 writer.unsent_logs_count() >= min_log_count_) {
296 // The rest of the logs (including the current one) are trimmed.
297 if (overwrite_in_memory_store) {
298 NotifyLogsEvent(base::span<std::unique_ptr<LogInfo>>(
299 list_.begin(), list_.begin() + i + 1),
300 MetricsLogsEventManager::LogEvent::kLogTrimmed);
301 }
302 break;
303 }
304 // Omit overly large individual logs.
305 if (log_size > max_log_size_) {
306 metrics_->RecordDroppedLogSize(log_size);
307 if (overwrite_in_memory_store) {
308 NotifyLogEvent(MetricsLogsEventManager::LogEvent::kLogTrimmed,
309 list_[i]->hash, "Log size too large.");
310 }
311 continue;
312 }
313
314 bytes_used += log_size;
315
316 if (staged_log_index_ == i) {
317 staged_index_distance = writer.unsent_logs_count();
318 }
319
320 // Append log to prefs.
321 writer.WriteLogEntry(list_[i].get());
322 if (overwrite_in_memory_store)
323 trimmed_list.emplace_back(std::move(list_[i]));
324 }
325
326 writer.Finish();
327
328 if (overwrite_in_memory_store) {
329 // We went in reverse order, but appended entries. So reverse list to
330 // correct.
331 std::reverse(trimmed_list.begin(), trimmed_list.end());
332
333 size_t dropped_logs_count = list_.size() - trimmed_list.size();
334 if (dropped_logs_count > 0)
335 metrics_->RecordDroppedLogsNum(dropped_logs_count);
336
337 // Put the trimmed list in the correct place.
338 list_.swap(trimmed_list);
339
340 // We may need to adjust the staged index since the number of logs may be
341 // reduced.
342 if (staged_index_distance.has_value()) {
343 staged_log_index_ = list_.size() - 1 - staged_index_distance.value();
344 } else {
345 // Set |staged_log_index_| to -1. It might already be -1. E.g., at the
346 // time we are trimming logs, there was no staged log. However, it is also
347 // possible that we trimmed away the staged log, so we need to update the
348 // index to -1.
349 staged_log_index_ = -1;
350 }
351 }
352
353 WriteToMetricsPref(writer.unsent_samples_count(), total_samples_sent_,
354 writer.unsent_persisted_size());
355 }
356
LoadPersistedUnsentLogs()357 void UnsentLogStore::LoadPersistedUnsentLogs() {
358 ReadLogsFromPrefList(local_state_->GetList(log_data_pref_name_));
359 RecordMetaDataMetrics();
360 }
361
StoreLog(const std::string & log_data,const LogMetadata & log_metadata,MetricsLogsEventManager::CreateReason reason)362 void UnsentLogStore::StoreLog(const std::string& log_data,
363 const LogMetadata& log_metadata,
364 MetricsLogsEventManager::CreateReason reason) {
365 std::unique_ptr<LogInfo> info = std::make_unique<LogInfo>();
366 info->Init(log_data, signing_key_, log_metadata);
367 StoreLogInfo(std::move(info), log_data.size(), reason);
368 }
369
StoreLogInfo(std::unique_ptr<LogInfo> log_info,size_t uncompressed_log_size,MetricsLogsEventManager::CreateReason reason)370 void UnsentLogStore::StoreLogInfo(
371 std::unique_ptr<LogInfo> log_info,
372 size_t uncompressed_log_size,
373 MetricsLogsEventManager::CreateReason reason) {
374 DCHECK(log_info);
375 metrics_->RecordCompressionRatio(log_info->compressed_log_data.size(),
376 uncompressed_log_size);
377 NotifyLogCreated(*log_info, reason);
378 list_.emplace_back(std::move(log_info));
379 }
380
GetLogAtIndex(size_t index)381 const std::string& UnsentLogStore::GetLogAtIndex(size_t index) {
382 DCHECK_GE(index, 0U);
383 DCHECK_LT(index, list_.size());
384 return list_[index]->compressed_log_data;
385 }
386
ReplaceLogAtIndex(size_t index,const std::string & new_log_data,const LogMetadata & log_metadata)387 std::string UnsentLogStore::ReplaceLogAtIndex(size_t index,
388 const std::string& new_log_data,
389 const LogMetadata& log_metadata) {
390 DCHECK_GE(index, 0U);
391 DCHECK_LT(index, list_.size());
392
393 // Avoid copying of long strings.
394 std::string old_log_data;
395 old_log_data.swap(list_[index]->compressed_log_data);
396 std::string old_timestamp;
397 old_timestamp.swap(list_[index]->timestamp);
398 std::string old_hash;
399 old_hash.swap(list_[index]->hash);
400
401 std::unique_ptr<LogInfo> info = std::make_unique<LogInfo>();
402 info->Init(new_log_data, old_timestamp, signing_key_, log_metadata);
403 // Note that both the compression ratio of the new log and the log that is
404 // being replaced are recorded.
405 metrics_->RecordCompressionRatio(info->compressed_log_data.size(),
406 new_log_data.size());
407
408 // TODO(crbug/1363747): Pass a message to make it clear that the new log is
409 // replacing the old log.
410 NotifyLogEvent(MetricsLogsEventManager::LogEvent::kLogDiscarded, old_hash);
411 NotifyLogCreated(*info, MetricsLogsEventManager::CreateReason::kUnknown);
412 list_[index] = std::move(info);
413 return old_log_data;
414 }
415
Purge()416 void UnsentLogStore::Purge() {
417 NotifyLogsEvent(list_, MetricsLogsEventManager::LogEvent::kLogDiscarded,
418 "Purged.");
419
420 if (has_staged_log()) {
421 DiscardStagedLog();
422 }
423 list_.clear();
424 local_state_->ClearPref(log_data_pref_name_);
425 // The |total_samples_sent_| isn't cleared intentionally because it is still
426 // meaningful.
427 if (metadata_pref_name_)
428 local_state_->ClearPref(metadata_pref_name_);
429 }
430
SetLogsEventManager(MetricsLogsEventManager * logs_event_manager)431 void UnsentLogStore::SetLogsEventManager(
432 MetricsLogsEventManager* logs_event_manager) {
433 logs_event_manager_ = logs_event_manager;
434 }
435
ReadLogsFromPrefList(const base::Value::List & list_value)436 void UnsentLogStore::ReadLogsFromPrefList(const base::Value::List& list_value) {
437 // The below DCHECK ensures that a log from prefs is not loaded multiple
438 // times, which is important for the semantics of the NotifyLogsCreated() call
439 // below.
440 DCHECK(list_.empty());
441
442 if (list_value.empty()) {
443 metrics_->RecordLogReadStatus(UnsentLogStoreMetrics::LIST_EMPTY);
444 return;
445 }
446
447 const size_t log_count = list_value.size();
448
449 list_.resize(log_count);
450
451 for (size_t i = 0; i < log_count; ++i) {
452 const base::Value::Dict* dict = list_value[i].GetIfDict();
453 std::unique_ptr<LogInfo> info = std::make_unique<LogInfo>();
454 if (!dict || !GetString(*dict, kLogDataKey, info->compressed_log_data) ||
455 !GetString(*dict, kLogHashKey, info->hash) ||
456 !GetString(*dict, kLogTimestampKey, info->timestamp) ||
457 !GetString(*dict, kLogSignatureKey, info->signature)) {
458 // Something is wrong, so we don't try to get any persisted logs.
459 list_.clear();
460 metrics_->RecordLogReadStatus(
461 UnsentLogStoreMetrics::LOG_STRING_CORRUPTION);
462 return;
463 }
464
465 info->compressed_log_data = DecodeFromBase64(info->compressed_log_data);
466 info->hash = DecodeFromBase64(info->hash);
467 info->signature = DecodeFromBase64(info->signature);
468 // timestamp doesn't need to be decoded.
469
470 // Extract user id of the log if it exists.
471 const std::string* user_id_str = dict->FindString(kLogUserIdKey);
472 if (user_id_str) {
473 uint64_t user_id;
474
475 // Only initialize the metadata if conversion was successful.
476 if (base::StringToUint64(DecodeFromBase64(*user_id_str), &user_id))
477 info->log_metadata.user_id = user_id;
478 }
479
480 list_[i] = std::move(info);
481 }
482
483 // Only notify log observers after loading all logs from pref instead of
484 // notifying as logs are loaded. This is because we may return early and end
485 // up not loading any logs.
486 NotifyLogsCreated(
487 list_, MetricsLogsEventManager::CreateReason::kLoadFromPreviousSession);
488
489 metrics_->RecordLogReadStatus(UnsentLogStoreMetrics::RECALL_SUCCESS);
490 }
491
WriteToMetricsPref(base::HistogramBase::Count unsent_samples_count,base::HistogramBase::Count sent_samples_count,size_t unsent_persisted_size) const492 void UnsentLogStore::WriteToMetricsPref(
493 base::HistogramBase::Count unsent_samples_count,
494 base::HistogramBase::Count sent_samples_count,
495 size_t unsent_persisted_size) const {
496 if (metadata_pref_name_ == nullptr)
497 return;
498
499 ScopedDictPrefUpdate update(local_state_, metadata_pref_name_);
500 base::Value::Dict& pref_data = update.Get();
501 pref_data.Set(kLogUnsentCountKey, unsent_samples_count);
502 pref_data.Set(kLogSentCountKey, sent_samples_count);
503 // Round up to kb.
504 pref_data.Set(kLogPersistedSizeInKbKey,
505 static_cast<int>(std::ceil(unsent_persisted_size / 1024.0)));
506 }
507
RecordMetaDataMetrics()508 void UnsentLogStore::RecordMetaDataMetrics() {
509 if (metadata_pref_name_ == nullptr)
510 return;
511
512 const base::Value::Dict& value = local_state_->GetDict(metadata_pref_name_);
513
514 auto unsent_samples_count = value.FindInt(kLogUnsentCountKey);
515 auto sent_samples_count = value.FindInt(kLogSentCountKey);
516 auto unsent_persisted_size_in_kb = value.FindInt(kLogPersistedSizeInKbKey);
517
518 if (unsent_samples_count && sent_samples_count &&
519 unsent_persisted_size_in_kb) {
520 metrics_->RecordLastUnsentLogMetadataMetrics(
521 unsent_samples_count.value(), sent_samples_count.value(),
522 unsent_persisted_size_in_kb.value());
523 }
524 }
525
NotifyLogCreated(const LogInfo & info,MetricsLogsEventManager::CreateReason reason)526 void UnsentLogStore::NotifyLogCreated(
527 const LogInfo& info,
528 MetricsLogsEventManager::CreateReason reason) {
529 if (!logs_event_manager_)
530 return;
531 logs_event_manager_->NotifyLogCreated(info.hash, info.compressed_log_data,
532 info.timestamp, reason);
533 }
534
NotifyLogsCreated(base::span<std::unique_ptr<LogInfo>> logs,MetricsLogsEventManager::CreateReason reason)535 void UnsentLogStore::NotifyLogsCreated(
536 base::span<std::unique_ptr<LogInfo>> logs,
537 MetricsLogsEventManager::CreateReason reason) {
538 if (!logs_event_manager_)
539 return;
540 for (const std::unique_ptr<LogInfo>& info : logs) {
541 logs_event_manager_->NotifyLogCreated(info->hash, info->compressed_log_data,
542 info->timestamp, reason);
543 }
544 }
545
NotifyLogEvent(MetricsLogsEventManager::LogEvent event,base::StringPiece log_hash,base::StringPiece message)546 void UnsentLogStore::NotifyLogEvent(MetricsLogsEventManager::LogEvent event,
547 base::StringPiece log_hash,
548 base::StringPiece message) {
549 if (!logs_event_manager_)
550 return;
551 logs_event_manager_->NotifyLogEvent(event, log_hash, message);
552 }
553
NotifyLogsEvent(base::span<std::unique_ptr<LogInfo>> logs,MetricsLogsEventManager::LogEvent event,base::StringPiece message)554 void UnsentLogStore::NotifyLogsEvent(base::span<std::unique_ptr<LogInfo>> logs,
555 MetricsLogsEventManager::LogEvent event,
556 base::StringPiece message) {
557 if (!logs_event_manager_)
558 return;
559 for (const std::unique_ptr<LogInfo>& info : logs) {
560 logs_event_manager_->NotifyLogEvent(event, info->hash, message);
561 }
562 }
563
564 } // namespace metrics
565