[Author Prev][Author Next][Thread Prev][Thread Next][Author Index][Thread Index]

[tor-commits] [metrics-lib/master] Add new SnowflakeStats descriptor type.



commit dc946fe1c8b50386f60f29a5073046eb2c0b27ad
Author: Karsten Loesing <karsten.loesing@xxxxxxx>
Date:   Tue Aug 13 15:01:45 2019 +0200

    Add new SnowflakeStats descriptor type.
    
    Implements #29461.
---
 CHANGELOG.md                                       |   1 +
 .../org/torproject/descriptor/SnowflakeStats.java  |  86 ++++++++++
 .../descriptor/impl/DescriptorParserImpl.java      |   5 +
 .../java/org/torproject/descriptor/impl/Key.java   |   6 +
 .../torproject/descriptor/impl/ParseHelper.java    |  21 +++
 .../descriptor/impl/SnowflakeStatsImpl.java        | 185 +++++++++++++++++++++
 .../org/torproject/descriptor/DescriptorTest.java  |   7 +-
 .../descriptor/impl/SnowflakeStatsImplTest.java    | 137 +++++++++++++++
 src/test/resources/snowflake/example_metrics.log   |  12 ++
 9 files changed, 459 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b804ceb..2fdc45b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@
      versions resolved by Ivy are the same as in Debian stretch with
      few exceptions.
    - Remove Cobertura from the build process.
+   - Add new SnowflakeStats descriptor type.
 
 
 # Changes in version 2.6.2 - 2019-05-29
diff --git a/src/main/java/org/torproject/descriptor/SnowflakeStats.java b/src/main/java/org/torproject/descriptor/SnowflakeStats.java
new file mode 100644
index 0000000..379a0f7
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/SnowflakeStats.java
@@ -0,0 +1,86 @@
+/* Copyright 2019 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.Optional;
+import java.util.SortedMap;
+
+/**
+ * Contain aggregated information about snowflake proxies and snowflake clients.
+ *
+ * @since 2.7.0
+ */
+public interface SnowflakeStats extends Descriptor {
+
+  /**
+   * Return the end of the included measurement interval.
+   *
+   * @return End of the included measurement interval.
+   * @since 2.7.0
+   */
+  LocalDateTime snowflakeStatsEnd();
+
+  /**
+   * Return the length of the included measurement interval.
+   *
+   * @return Length of the included measurement interval.
+   * @since 2.7.0
+   */
+  Duration snowflakeStatsIntervalLength();
+
+  /**
+   * Return a list of mappings from two-letter country codes to the number of
+   * unique IP addresses of snowflake proxies that have polled.
+   *
+   * @return List of mappings from two-letter country codes to the number of
+   *     unique IP addresses of snowflake proxies that have polled.
+   * @since 2.7.0
+   */
+  Optional<SortedMap<String, Long>> snowflakeIps();
+
+  /**
+   * Return a count of the total number of unique IP addresses of snowflake
+   * proxies that have polled.
+   *
+   * @return Count of the total number of unique IP addresses of snowflake
+   *     proxies that have polled.
+   * @since 2.7.0
+   */
+  Optional<Long> snowflakeIpsTotal();
+
+  /**
+   * Return a count of the number of times a proxy has polled but received no
+   * client offer, rounded up to the nearest multiple of 8.
+   *
+   * @return Count of the number of times a proxy has polled but received no
+   *     client offer, rounded up to the nearest multiple of 8.
+   * @since 2.7.0
+   */
+  Optional<Long> snowflakeIdleCount();
+
+  /**
+   * Return a count of the number of times a client has requested a proxy from
+   * the broker but no proxies were available, rounded up to the nearest
+   * multiple of 8.
+   *
+   * @return Count of the number of times a client has requested a proxy from
+   *     the broker but no proxies were available, rounded up to the nearest
+   *     multiple of 8.
+   * @since 2.7.0
+   */
+  Optional<Long> clientDeniedCount();
+
+  /**
+   * Return a count of the number of times a client successfully received a
+   * proxy from the broker, rounded up to the nearest multiple of 8.
+   *
+   * @return Count of the number of times a client successfully received a proxy
+   *     from the broker, rounded up to the nearest multiple of 8.
+   * @since 2.7.0
+   */
+  Optional<Long> clientSnowflakeMatchCount();
+}
+
diff --git a/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java b/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java
index ef9da56..9b620cb 100644
--- a/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java
+++ b/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java
@@ -130,6 +130,11 @@ public class DescriptorParserImpl implements DescriptorParser {
     } else if (firstLines.startsWith("@type torperf 1.")) {
       return TorperfResultImpl.parseTorperfResults(rawDescriptorBytes,
           sourceFile);
+    } else if (firstLines.startsWith("@type snowflake-stats 1.")
+        || firstLines.startsWith(Key.SNOWFLAKE_STATS_END.keyword + SP)
+        || firstLines.contains(NL + Key.SNOWFLAKE_STATS_END.keyword + SP)) {
+      return this.parseOneOrMoreDescriptors(rawDescriptorBytes, sourceFile,
+          Key.SNOWFLAKE_STATS_END, SnowflakeStatsImpl.class);
     } else if (fileName.contains(LogDescriptorImpl.MARKER)) {
       return LogDescriptorImpl.parse(rawDescriptorBytes, sourceFile, fileName);
     } else if (firstLines.startsWith("@type bandwidth-file 1.")
diff --git a/src/main/java/org/torproject/descriptor/impl/Key.java b/src/main/java/org/torproject/descriptor/impl/Key.java
index f7b613f..cf6ed09 100644
--- a/src/main/java/org/torproject/descriptor/impl/Key.java
+++ b/src/main/java/org/torproject/descriptor/impl/Key.java
@@ -29,6 +29,8 @@ public enum Key {
   CELL_QUEUED_CELLS("cell-queued-cells"),
   CELL_STATS_END("cell-stats-end"),
   CELL_TIME_IN_QUEUE("cell-time-in-queue"),
+  CLIENT_DENIED_COUNT("client-denied-count"),
+  CLIENT_SNOWFLAKE_MATCH_COUNT("client-snowflake-match-count"),
   CLIENT_VERSIONS("client-versions"),
   CONN_BI_DIRECT("conn-bi-direct"),
   CONSENSUS_METHOD("consensus-method"),
@@ -132,6 +134,10 @@ public enum Key {
   SHARED_RAND_PREVIOUS_VALUE("shared-rand-previous-value"),
   SIGNED_DIRECTORY("signed-directory"),
   SIGNING_KEY("signing-key"),
+  SNOWFLAKE_IDLE_COUNT("snowflake-idle-count"),
+  SNOWFLAKE_IPS("snowflake-ips"),
+  SNOWFLAKE_IPS_TOTAL("snowflake-ips-total"),
+  SNOWFLAKE_STATS_END("snowflake-stats-end"),
   TRANSPORT("transport"),
   TUNNELLED_DIR_SERVER("tunnelled-dir-server"),
   UPTIME("uptime"),
diff --git a/src/main/java/org/torproject/descriptor/impl/ParseHelper.java b/src/main/java/org/torproject/descriptor/impl/ParseHelper.java
index dcb365e..ba45ff6 100644
--- a/src/main/java/org/torproject/descriptor/impl/ParseHelper.java
+++ b/src/main/java/org/torproject/descriptor/impl/ParseHelper.java
@@ -11,6 +11,10 @@ import org.apache.commons.codec.binary.Hex;
 import java.text.DateFormat;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Locale;
@@ -101,6 +105,16 @@ public class ParseHelper {
     }
   }
 
+  static Duration parseDuration(String line, String secondsString)
+      throws DescriptorParseException {
+    long parsedSeconds = parseSeconds(line, secondsString);
+    if (parsedSeconds <= 0L) {
+      throw new DescriptorParseException("Duration must be positive in line '"
+          + line + "'.");
+    }
+    return Duration.ofSeconds(parsedSeconds);
+  }
+
   protected static String parseExitPattern(String line, String exitPattern)
       throws DescriptorParseException {
     if (!exitPattern.contains(":")) {
@@ -188,6 +202,13 @@ public class ParseHelper {
     return result;
   }
 
+  static LocalDateTime parseLocalDateTime(String line, String[] parts,
+      int dateIndex, int timeIndex) throws DescriptorParseException {
+    return LocalDateTime.ofInstant(Instant.ofEpochMilli(
+        parseTimestampAtIndex(line, parts, dateIndex, timeIndex)),
+        ZoneOffset.UTC);
+  }
+
   protected static long parseDateAtIndex(String line, String[] parts,
       int dateIndex) throws DescriptorParseException {
     if (dateIndex >= parts.length) {
diff --git a/src/main/java/org/torproject/descriptor/impl/SnowflakeStatsImpl.java b/src/main/java/org/torproject/descriptor/impl/SnowflakeStatsImpl.java
new file mode 100644
index 0000000..2f46bbe
--- /dev/null
+++ b/src/main/java/org/torproject/descriptor/impl/SnowflakeStatsImpl.java
@@ -0,0 +1,185 @@
+/* Copyright 2019 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor.impl;
+
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.SnowflakeStats;
+
+import java.io.File;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.Optional;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.SortedMap;
+
+public class SnowflakeStatsImpl extends DescriptorImpl
+    implements SnowflakeStats {
+
+  private static final Set<Key> atMostOnce = EnumSet.of(
+      Key.SNOWFLAKE_IPS, Key.SNOWFLAKE_IPS_TOTAL, Key.SNOWFLAKE_IDLE_COUNT,
+      Key.CLIENT_DENIED_COUNT, Key.CLIENT_SNOWFLAKE_MATCH_COUNT);
+
+  private static final Set<Key> exactlyOnce = EnumSet.of(
+      Key.SNOWFLAKE_STATS_END);
+
+  SnowflakeStatsImpl(byte[] rawDescriptorBytes, int[] offsetAndLength,
+      File descriptorFile) throws DescriptorParseException {
+    super(rawDescriptorBytes, offsetAndLength, descriptorFile, false);
+    this.parseDescriptorBytes();
+    this.checkExactlyOnceKeys(exactlyOnce);
+    this.checkAtMostOnceKeys(atMostOnce);
+    this.checkFirstKey(Key.SNOWFLAKE_STATS_END);
+    this.clearParsedKeys();
+  }
+
+  SnowflakeStatsImpl(byte[] rawDescriptorBytes, File descriptorFile)
+      throws DescriptorParseException {
+    this(rawDescriptorBytes, new int[] { 0, rawDescriptorBytes.length },
+        descriptorFile);
+  }
+
+  private void parseDescriptorBytes() throws DescriptorParseException {
+    Scanner scanner = this.newScanner().useDelimiter(NL);
+    while (scanner.hasNext()) {
+      String line = scanner.next();
+      if (line.startsWith("@")) {
+        continue;
+      }
+      String[] parts = line.split("[ \t]+");
+      Key key = Key.get(parts[0]);
+      switch (key) {
+        case SNOWFLAKE_STATS_END:
+          this.parseSnowflakeStatsEnd(line, parts);
+          break;
+        case SNOWFLAKE_IPS:
+          this.parseSnowflakeIps(line, parts);
+          break;
+        case SNOWFLAKE_IPS_TOTAL:
+          this.parseSnowflakeIpsTotal(line, parts);
+          break;
+        case SNOWFLAKE_IDLE_COUNT:
+          this.parseSnowflakeIdleCount(line, parts);
+          break;
+        case CLIENT_DENIED_COUNT:
+          this.parseClientDeniedCount(line, parts);
+          break;
+        case CLIENT_SNOWFLAKE_MATCH_COUNT:
+          this.parseClientSnowflakeMatchCount(line, parts);
+          break;
+        case INVALID:
+        default:
+          ParseHelper.parseKeyword(line, parts[0]);
+          if (this.unrecognizedLines == null) {
+            this.unrecognizedLines = new ArrayList<>();
+          }
+          this.unrecognizedLines.add(line);
+      }
+    }
+  }
+
+  private void parseSnowflakeStatsEnd(String line, String[] parts)
+      throws DescriptorParseException {
+    if (parts.length < 5 || parts[3].length() < 2 || !parts[3].startsWith("(")
+        || !parts[4].equals("s)")) {
+      throw new DescriptorParseException("Illegal line '" + line + "'.");
+    }
+    this.snowflakeStatsEnd = ParseHelper.parseLocalDateTime(line, parts,
+        1, 2);
+    this.snowflakeStatsIntervalLength = ParseHelper.parseDuration(line,
+        parts[3].substring(1));
+  }
+
+  private void parseSnowflakeIps(String line, String[] parts)
+      throws DescriptorParseException {
+    this.snowflakeIps = ParseHelper.parseCommaSeparatedKeyLongValueList(line,
+        parts, 1, 2);
+  }
+
+  private void parseSnowflakeIpsTotal(String line, String[] parts)
+      throws DescriptorParseException {
+    this.snowflakeIpsTotal = parseLong(line, parts, 1);
+  }
+
+  private void parseSnowflakeIdleCount(String line, String[] parts)
+      throws DescriptorParseException {
+    this.snowflakeIdleCount = parseLong(line, parts, 1);
+  }
+
+  private void parseClientDeniedCount(String line, String[] parts)
+      throws DescriptorParseException {
+    this.clientDeniedCount = parseLong(line, parts, 1);
+  }
+
+  private void parseClientSnowflakeMatchCount(String line, String[] parts)
+      throws DescriptorParseException {
+    this.clientSnowflakeMatchCount = parseLong(line, parts, 1);
+  }
+
+  private static Long parseLong(String line, String[] parts, int index)
+      throws DescriptorParseException {
+    if (index >= parts.length) {
+      throw new DescriptorParseException(String.format(
+          "Line '%s' does not contain a long value at index %d.", line, index));
+    }
+    try {
+      return Long.parseLong(parts[index]);
+    } catch (NumberFormatException e) {
+      throw new DescriptorParseException(String.format(
+          "Unable to parse long value '%s' in line '%s'.", parts[index], line));
+    }
+  }
+
+  private LocalDateTime snowflakeStatsEnd;
+
+  @Override
+  public LocalDateTime snowflakeStatsEnd() {
+    return this.snowflakeStatsEnd;
+  }
+
+  private Duration snowflakeStatsIntervalLength;
+
+  @Override
+  public Duration snowflakeStatsIntervalLength() {
+    return this.snowflakeStatsIntervalLength;
+  }
+
+  private SortedMap<String, Long> snowflakeIps;
+
+  @Override
+  public Optional<SortedMap<String, Long>> snowflakeIps() {
+    return Optional.ofNullable(this.snowflakeIps);
+  }
+
+  private Long snowflakeIpsTotal;
+
+  @Override
+  public Optional<Long> snowflakeIpsTotal() {
+    return Optional.ofNullable(this.snowflakeIpsTotal);
+  }
+
+  private Long snowflakeIdleCount;
+
+  @Override
+  public Optional<Long> snowflakeIdleCount() {
+    return Optional.ofNullable(this.snowflakeIdleCount);
+  }
+
+  private Long clientDeniedCount;
+
+  @Override
+  public Optional<Long> clientDeniedCount() {
+    return Optional.ofNullable(this.clientDeniedCount);
+  }
+
+  private Long clientSnowflakeMatchCount;
+
+  @Override
+  public Optional<Long> clientSnowflakeMatchCount() {
+    return Optional.ofNullable(this.clientSnowflakeMatchCount);
+  }
+}
+
diff --git a/src/test/java/org/torproject/descriptor/DescriptorTest.java b/src/test/java/org/torproject/descriptor/DescriptorTest.java
index 9e32ac9..719d16b 100644
--- a/src/test/java/org/torproject/descriptor/DescriptorTest.java
+++ b/src/test/java/org/torproject/descriptor/DescriptorTest.java
@@ -80,7 +80,12 @@ public class DescriptorTest {
           {"bridge/2017-07-17-17-09-00-server-descriptors",
            BridgeServerDescriptor.class,
            new String[] {"@type bridge-server-descriptor 1.2"},
-              13}
+              13},
+
+          {"snowflake/example_metrics.log",
+           SnowflakeStats.class,
+           new String[0],
+              2}
         });
   }
 
diff --git a/src/test/java/org/torproject/descriptor/impl/SnowflakeStatsImplTest.java b/src/test/java/org/torproject/descriptor/impl/SnowflakeStatsImplTest.java
new file mode 100644
index 0000000..6d0e50b
--- /dev/null
+++ b/src/test/java/org/torproject/descriptor/impl/SnowflakeStatsImplTest.java
@@ -0,0 +1,137 @@
+/* Copyright 2019 The Tor Project
+ * See LICENSE for licensing information */
+
+package org.torproject.descriptor.impl;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.torproject.descriptor.DescriptorParseException;
+import org.torproject.descriptor.SnowflakeStats;
+
+import org.hamcrest.Matchers;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+
+public class SnowflakeStatsImplTest {
+
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  /**
+   * Example from example_metrics.log attached to #29461.
+   */
+  private static final String[] exampleMetricsLog = new String[] {
+      "snowflake-stats-end 2019-08-07 19:52:11 (86400 s)",
+      "snowflake-ips VN=5,NL=26,AU=30,GT=2,NO=5,EG=3,NI=1,AT=22,FR=42,CA=44,"
+          + "ZA=3,PL=20,RU=10,HR=1,CN=1,RO=4,??=7,TH=7,UA=5,DZ=5,HU=5,CH=15,"
+          + "AE=1,PH=6,RS=3,BR=20,IT=8,KR=13,HK=7,GR=5,GB=41,DK=4,CZ=7,IE=4,"
+          + "PT=7,TR=2,NP=2,BA=1,BE=2,IN=45,SE=23,CL=3,IL=3,FI=7,MX=6,CO=1,"
+          + "PK=4,ID=9,IR=7,JO=2,CR=2,US=265,DE=92,LV=1,MY=8,AR=5,NZ=10,BG=2,"
+          + "UY=1,TW=5,SI=3,LU=2,GE=2,BN=1,JP=15,ES=9,SG=7,EC=1",
+      "snowflake-ips-total 937",
+      "snowflake-idle-count 660976",
+      "client-denied-count 0",
+      "client-snowflake-match-count 864" };
+
+  @Test
+  public void testExampleMetricsLog() throws DescriptorParseException {
+    SnowflakeStats snowflakeStats = new SnowflakeStatsImpl(
+        new TestDescriptorBuilder(exampleMetricsLog).build(), null);
+    assertEquals(LocalDateTime.of(2019, 8, 7, 19, 52, 11),
+        snowflakeStats.snowflakeStatsEnd());
+    assertEquals(Duration.ofDays(1L),
+        snowflakeStats.snowflakeStatsIntervalLength());
+    assertTrue(snowflakeStats.snowflakeIps().isPresent());
+    assertEquals(68, snowflakeStats.snowflakeIps().get().size());
+    assertTrue(snowflakeStats.snowflakeIpsTotal().isPresent());
+    assertEquals((Long) 937L, snowflakeStats.snowflakeIpsTotal().get());
+    assertTrue(snowflakeStats.snowflakeIdleCount().isPresent());
+    assertEquals((Long) 660976L, snowflakeStats.snowflakeIdleCount().get());
+    assertTrue(snowflakeStats.clientDeniedCount().isPresent());
+    assertEquals((Long) 0L, snowflakeStats.clientDeniedCount().get());
+    assertTrue(snowflakeStats.clientSnowflakeMatchCount().isPresent());
+    assertEquals((Long) 864L, snowflakeStats.clientSnowflakeMatchCount().get());
+  }
+
+  @Test
+  public void testMinimalSnowflakeStats() throws DescriptorParseException {
+    SnowflakeStats snowflakeStats = new SnowflakeStatsImpl(
+        new TestDescriptorBuilder(exampleMetricsLog[0]).build(), null);
+    assertEquals(LocalDateTime.of(2019, 8, 7, 19, 52, 11),
+        snowflakeStats.snowflakeStatsEnd());
+    assertEquals(Duration.ofDays(1L),
+        snowflakeStats.snowflakeStatsIntervalLength());
+    assertFalse(snowflakeStats.snowflakeIps().isPresent());
+    assertFalse(snowflakeStats.snowflakeIpsTotal().isPresent());
+    assertFalse(snowflakeStats.snowflakeIdleCount().isPresent());
+    assertFalse(snowflakeStats.clientDeniedCount().isPresent());
+    assertFalse(snowflakeStats.clientSnowflakeMatchCount().isPresent());
+  }
+
+  @Test
+  public void testEmptyLine() throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Blank lines are not allowed."));
+    new SnowflakeStatsImpl(new TestDescriptorBuilder(exampleMetricsLog)
+        .appendLines("")
+        .build(), null);
+  }
+
+  @Test
+  public void testDuplicateLine() throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "must be contained at most once."));
+    new SnowflakeStatsImpl(new TestDescriptorBuilder(
+        exampleMetricsLog[0], exampleMetricsLog[1], exampleMetricsLog[1])
+        .build(), null);
+  }
+
+  @Test
+  public void testEmptyList() throws DescriptorParseException {
+    SnowflakeStats snowflakeStats = new SnowflakeStatsImpl(
+        new TestDescriptorBuilder(exampleMetricsLog[0], "snowflake-ips ")
+        .build(), null);
+    assertEquals(LocalDateTime.of(2019, 8, 7, 19, 52, 11),
+        snowflakeStats.snowflakeStatsEnd());
+    assertEquals(Duration.ofDays(1L),
+        snowflakeStats.snowflakeStatsIntervalLength());
+    assertTrue(snowflakeStats.snowflakeIps().isPresent());
+    assertTrue(snowflakeStats.snowflakeIps().get().isEmpty());
+  }
+
+  @Test
+  public void testNoValue() throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "does not contain a long value at index 1."));
+    new SnowflakeStatsImpl(new TestDescriptorBuilder(
+        exampleMetricsLog[0], "snowflake-ips-total").build(), null);
+  }
+
+  @Test
+  public void testNotANumber() throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Unable to parse long value"));
+    new SnowflakeStatsImpl(new TestDescriptorBuilder(
+        exampleMetricsLog[0], "snowflake-ips-total NaN").build(), null);
+  }
+
+  @Test
+  public void testNonPositiveIntervalLength() throws DescriptorParseException {
+    this.thrown.expect(DescriptorParseException.class);
+    this.thrown.expectMessage(Matchers.containsString(
+        "Duration must be positive"));
+    new SnowflakeStatsImpl(new TestDescriptorBuilder(
+        "snowflake-stats-end 2019-08-07 19:52:11 (0 s)").build(), null);
+  }
+}
+
diff --git a/src/test/resources/snowflake/example_metrics.log b/src/test/resources/snowflake/example_metrics.log
new file mode 100644
index 0000000..cfc7beb
--- /dev/null
+++ b/src/test/resources/snowflake/example_metrics.log
@@ -0,0 +1,12 @@
+snowflake-stats-end 2019-08-07 19:52:11 (86400 s)
+snowflake-ips VN=5,NL=26,AU=30,GT=2,NO=5,EG=3,NI=1,AT=22,FR=42,CA=44,ZA=3,PL=20,RU=10,HR=1,CN=1,RO=4,??=7,TH=7,UA=5,DZ=5,HU=5,CH=15,AE=1,PH=6,RS=3,BR=20,IT=8,KR=13,HK=7,GR=5,GB=41,DK=4,CZ=7,IE=4,PT=7,TR=2,NP=2,BA=1,BE=2,IN=45,SE=23,CL=3,IL=3,FI=7,MX=6,CO=1,PK=4,ID=9,IR=7,JO=2,CR=2,US=265,DE=92,LV=1,MY=8,AR=5,NZ=10,BG=2,UY=1,TW=5,SI=3,LU=2,GE=2,BN=1,JP=15,ES=9,SG=7,EC=1
+snowflake-ips-total 937
+snowflake-idle-count 660976
+client-denied-count 0
+client-snowflake-match-count 864
+snowflake-stats-end 2019-08-08 19:52:11 (86400 s)
+snowflake-ips IE=7,IN=31,ES=17,MY=7,NO=7,IR=6,NL=36,ZA=2,GT=1,PK=5,US=284,SE=21,UY=1,AR=8,VN=6,RS=3,GB=37,CZ=12,NZ=7,CO=2,PH=6,RO=5,AT=24,GE=1,BE=5,EE=1,TR=5,CL=5,CA=59,PT=9,MX=76,IL=3,BG=2,BA=1,HU=9,JO=2,PL=16,GR=5,KR=13,EG=2,TW=10,ID=14,FI=9,DK=3,IT=11,TH=3,DE=118,SI=4,CH=19,UA=5,AU=32,NG=3,AE=1,RU=17,NI=1,JP=18,SD=1,LU=2,FR=80,BR=12,CR=6,CN=16,DZ=4,SG=13,NP=2,??=7,HK=12,HR=5,BN=1
+snowflake-ips-total 1178
+snowflake-idle-count 979344
+client-denied-count 0
+client-snowflake-match-count 392

_______________________________________________
tor-commits mailing list
tor-commits@xxxxxxxxxxxxxxxxxxxx
https://lists.torproject.org/cgi-bin/mailman/listinfo/tor-commits