| 1 | """ |
|---|
| 2 | Tests for ``/statistics?t=openmetrics``. |
|---|
| 3 | |
|---|
| 4 | Ported to Python 3. |
|---|
| 5 | """ |
|---|
| 6 | |
|---|
| 7 | from prometheus_client.openmetrics import parser |
|---|
| 8 | |
|---|
| 9 | from treq.testing import RequestTraversalAgent |
|---|
| 10 | |
|---|
| 11 | from twisted.web.http import OK |
|---|
| 12 | from twisted.web.client import readBody |
|---|
| 13 | from twisted.web.resource import Resource |
|---|
| 14 | |
|---|
| 15 | from testtools.twistedsupport import succeeded |
|---|
| 16 | from testtools.matchers import ( |
|---|
| 17 | AfterPreprocessing, |
|---|
| 18 | Equals, |
|---|
| 19 | MatchesAll, |
|---|
| 20 | MatchesStructure, |
|---|
| 21 | MatchesPredicate, |
|---|
| 22 | ) |
|---|
| 23 | from testtools.content import text_content |
|---|
| 24 | |
|---|
| 25 | from allmydata.web.status import Statistics |
|---|
| 26 | from allmydata.test.common import SyncTestCase |
|---|
| 27 | |
|---|
| 28 | |
|---|
| 29 | class FakeStatsProvider: |
|---|
| 30 | """ |
|---|
| 31 | A stats provider that hands backed a canned collection of performance |
|---|
| 32 | statistics. |
|---|
| 33 | """ |
|---|
| 34 | |
|---|
| 35 | def get_stats(self): |
|---|
| 36 | # Parsed into a dict from a running tahoe's /statistics?t=json |
|---|
| 37 | stats = { |
|---|
| 38 | "stats": { |
|---|
| 39 | "storage_server.latencies.get.99_9_percentile": None, |
|---|
| 40 | "storage_server.latencies.close.10_0_percentile": 0.00021910667419433594, |
|---|
| 41 | "storage_server.latencies.read.01_0_percentile": 2.8848648071289062e-05, |
|---|
| 42 | "storage_server.latencies.writev.99_9_percentile": None, |
|---|
| 43 | "storage_server.latencies.read.99_9_percentile": None, |
|---|
| 44 | "storage_server.latencies.allocate.99_0_percentile": 0.000988006591796875, |
|---|
| 45 | "storage_server.latencies.writev.mean": 0.00045332245070571654, |
|---|
| 46 | "storage_server.latencies.close.99_9_percentile": None, |
|---|
| 47 | "cpu_monitor.15min_avg": 0.00017592000079223033, |
|---|
| 48 | "storage_server.disk_free_for_root": 103289454592, |
|---|
| 49 | "storage_server.latencies.get.99_0_percentile": 0.000347137451171875, |
|---|
| 50 | "storage_server.latencies.get.mean": 0.00021158285060171353, |
|---|
| 51 | "storage_server.latencies.read.90_0_percentile": 8.893013000488281e-05, |
|---|
| 52 | "storage_server.latencies.write.01_0_percentile": 3.600120544433594e-05, |
|---|
| 53 | "storage_server.latencies.write.99_9_percentile": 0.00017690658569335938, |
|---|
| 54 | "storage_server.latencies.close.90_0_percentile": 0.00033211708068847656, |
|---|
| 55 | "storage_server.disk_total": 103497859072, |
|---|
| 56 | "storage_server.latencies.close.95_0_percentile": 0.0003509521484375, |
|---|
| 57 | "storage_server.latencies.readv.samplesize": 1000, |
|---|
| 58 | "storage_server.disk_free_for_nonroot": 103289454592, |
|---|
| 59 | "storage_server.latencies.close.mean": 0.0002715024480059103, |
|---|
| 60 | "storage_server.latencies.writev.95_0_percentile": 0.0007410049438476562, |
|---|
| 61 | "storage_server.latencies.readv.90_0_percentile": 0.0003781318664550781, |
|---|
| 62 | "storage_server.latencies.readv.99_0_percentile": 0.0004050731658935547, |
|---|
| 63 | "storage_server.latencies.allocate.mean": 0.0007128627429454784, |
|---|
| 64 | "storage_server.latencies.close.samplesize": 326, |
|---|
| 65 | "storage_server.latencies.get.50_0_percentile": 0.0001819133758544922, |
|---|
| 66 | "storage_server.latencies.write.50_0_percentile": 4.482269287109375e-05, |
|---|
| 67 | "storage_server.latencies.readv.01_0_percentile": 0.0002970695495605469, |
|---|
| 68 | "storage_server.latencies.get.10_0_percentile": 0.00015687942504882812, |
|---|
| 69 | "storage_server.latencies.allocate.90_0_percentile": 0.0008189678192138672, |
|---|
| 70 | "storage_server.latencies.get.samplesize": 472, |
|---|
| 71 | "storage_server.total_bucket_count": 393, |
|---|
| 72 | "storage_server.latencies.read.mean": 5.936201880959903e-05, |
|---|
| 73 | "storage_server.latencies.allocate.01_0_percentile": 0.0004208087921142578, |
|---|
| 74 | "storage_server.latencies.allocate.99_9_percentile": None, |
|---|
| 75 | "storage_server.latencies.readv.mean": 0.00034061360359191893, |
|---|
| 76 | "storage_server.disk_used": 208404480, |
|---|
| 77 | "storage_server.latencies.allocate.50_0_percentile": 0.0007410049438476562, |
|---|
| 78 | "storage_server.latencies.read.99_0_percentile": 0.00011992454528808594, |
|---|
| 79 | "node.uptime": 3805759.8545179367, |
|---|
| 80 | "storage_server.latencies.writev.10_0_percentile": 0.00035190582275390625, |
|---|
| 81 | "storage_server.latencies.writev.90_0_percentile": 0.0006821155548095703, |
|---|
| 82 | "storage_server.latencies.close.01_0_percentile": 0.00021505355834960938, |
|---|
| 83 | "storage_server.latencies.close.50_0_percentile": 0.0002579689025878906, |
|---|
| 84 | "cpu_monitor.1min_avg": 0.0002130000000003444, |
|---|
| 85 | "storage_server.latencies.writev.50_0_percentile": 0.0004138946533203125, |
|---|
| 86 | "storage_server.latencies.read.95_0_percentile": 9.107589721679688e-05, |
|---|
| 87 | "storage_server.latencies.readv.95_0_percentile": 0.0003859996795654297, |
|---|
| 88 | "storage_server.latencies.write.10_0_percentile": 3.719329833984375e-05, |
|---|
| 89 | "storage_server.accepting_immutable_shares": 1, |
|---|
| 90 | "storage_server.latencies.writev.samplesize": 309, |
|---|
| 91 | "storage_server.latencies.get.95_0_percentile": 0.0003190040588378906, |
|---|
| 92 | "storage_server.latencies.readv.10_0_percentile": 0.00032210350036621094, |
|---|
| 93 | "storage_server.latencies.get.90_0_percentile": 0.0002999305725097656, |
|---|
| 94 | "storage_server.latencies.get.01_0_percentile": 0.0001239776611328125, |
|---|
| 95 | "cpu_monitor.total": 641.4941180000001, |
|---|
| 96 | "storage_server.latencies.write.samplesize": 1000, |
|---|
| 97 | "storage_server.latencies.write.95_0_percentile": 9.489059448242188e-05, |
|---|
| 98 | "storage_server.latencies.read.50_0_percentile": 6.890296936035156e-05, |
|---|
| 99 | "storage_server.latencies.writev.01_0_percentile": 0.00033211708068847656, |
|---|
| 100 | "storage_server.latencies.read.10_0_percentile": 3.0994415283203125e-05, |
|---|
| 101 | "storage_server.latencies.allocate.10_0_percentile": 0.0004949569702148438, |
|---|
| 102 | "storage_server.reserved_space": 0, |
|---|
| 103 | "storage_server.disk_avail": 103289454592, |
|---|
| 104 | "storage_server.latencies.write.99_0_percentile": 0.00011301040649414062, |
|---|
| 105 | "storage_server.latencies.write.90_0_percentile": 9.083747863769531e-05, |
|---|
| 106 | "cpu_monitor.5min_avg": 0.0002370666691157502, |
|---|
| 107 | "storage_server.latencies.write.mean": 5.8008909225463864e-05, |
|---|
| 108 | "storage_server.latencies.readv.50_0_percentile": 0.00033020973205566406, |
|---|
| 109 | "storage_server.latencies.close.99_0_percentile": 0.0004038810729980469, |
|---|
| 110 | "storage_server.allocated": 0, |
|---|
| 111 | "storage_server.latencies.writev.99_0_percentile": 0.0007710456848144531, |
|---|
| 112 | "storage_server.latencies.readv.99_9_percentile": 0.0004780292510986328, |
|---|
| 113 | "storage_server.latencies.read.samplesize": 170, |
|---|
| 114 | "storage_server.latencies.allocate.samplesize": 406, |
|---|
| 115 | "storage_server.latencies.allocate.95_0_percentile": 0.0008411407470703125, |
|---|
| 116 | }, |
|---|
| 117 | "counters": { |
|---|
| 118 | "storage_server.writev": 309, |
|---|
| 119 | "storage_server.bytes_added": 197836146, |
|---|
| 120 | "storage_server.close": 326, |
|---|
| 121 | "storage_server.readv": 14299, |
|---|
| 122 | "storage_server.allocate": 406, |
|---|
| 123 | "storage_server.read": 170, |
|---|
| 124 | "storage_server.write": 3775, |
|---|
| 125 | "storage_server.get": 472, |
|---|
| 126 | }, |
|---|
| 127 | } |
|---|
| 128 | return stats |
|---|
| 129 | |
|---|
| 130 | |
|---|
| 131 | class HackItResource(Resource, object): |
|---|
| 132 | """ |
|---|
| 133 | A bridge between ``RequestTraversalAgent`` and ``MultiFormatResource`` |
|---|
| 134 | (used by ``Statistics``). ``MultiFormatResource`` expects the request |
|---|
| 135 | object to have a ``fields`` attribute but Twisted's ``IRequest`` has no |
|---|
| 136 | such attribute. Create it here. |
|---|
| 137 | """ |
|---|
| 138 | |
|---|
| 139 | def getChildWithDefault(self, path, request): |
|---|
| 140 | request.fields = None |
|---|
| 141 | return Resource.getChildWithDefault(self, path, request) |
|---|
| 142 | |
|---|
| 143 | |
|---|
| 144 | class OpenMetrics(SyncTestCase): |
|---|
| 145 | """ |
|---|
| 146 | Tests for ``/statistics?t=openmetrics``. |
|---|
| 147 | """ |
|---|
| 148 | |
|---|
| 149 | def test_spec_compliance(self): |
|---|
| 150 | """ |
|---|
| 151 | Does our output adhere to the `OpenMetrics <https://openmetrics.io/>` spec? |
|---|
| 152 | https://github.com/OpenObservability/OpenMetrics/ |
|---|
| 153 | https://prometheus.io/docs/instrumenting/exposition_formats/ |
|---|
| 154 | """ |
|---|
| 155 | root = HackItResource() |
|---|
| 156 | root.putChild(b"", Statistics(FakeStatsProvider())) |
|---|
| 157 | rta = RequestTraversalAgent(root) |
|---|
| 158 | d = rta.request(b"GET", b"http://localhost/?t=openmetrics") |
|---|
| 159 | self.assertThat(d, succeeded(matches_stats(self))) |
|---|
| 160 | |
|---|
| 161 | |
|---|
| 162 | def matches_stats(testcase): |
|---|
| 163 | """ |
|---|
| 164 | Create a matcher that matches a response that confirms to the OpenMetrics |
|---|
| 165 | specification. |
|---|
| 166 | |
|---|
| 167 | * The ``Content-Type`` is **application/openmetrics-text; version=1.0.0; charset=utf-8**. |
|---|
| 168 | * The status is **OK**. |
|---|
| 169 | * The body can be parsed by an OpenMetrics parser. |
|---|
| 170 | * The metric families in the body are grouped and sorted. |
|---|
| 171 | * At least one of the expected families appears in the body. |
|---|
| 172 | |
|---|
| 173 | :param testtools.TestCase testcase: The case to which to add detail about the matching process. |
|---|
| 174 | |
|---|
| 175 | :return: A matcher. |
|---|
| 176 | """ |
|---|
| 177 | return MatchesAll( |
|---|
| 178 | MatchesStructure( |
|---|
| 179 | code=Equals(OK), |
|---|
| 180 | # "The content type MUST be..." |
|---|
| 181 | headers=has_header( |
|---|
| 182 | "content-type", |
|---|
| 183 | "application/openmetrics-text; version=1.0.0; charset=utf-8", |
|---|
| 184 | ), |
|---|
| 185 | ), |
|---|
| 186 | AfterPreprocessing( |
|---|
| 187 | readBodyText, |
|---|
| 188 | succeeded( |
|---|
| 189 | MatchesAll( |
|---|
| 190 | MatchesPredicate(add_detail(testcase, "response body"), "%s dummy"), |
|---|
| 191 | parses_as_openmetrics(), |
|---|
| 192 | ) |
|---|
| 193 | ), |
|---|
| 194 | ), |
|---|
| 195 | ) |
|---|
| 196 | |
|---|
| 197 | |
|---|
| 198 | def add_detail(testcase, name): |
|---|
| 199 | """ |
|---|
| 200 | Create a matcher that always matches and as a side-effect adds the matched |
|---|
| 201 | value as detail to the testcase. |
|---|
| 202 | |
|---|
| 203 | :param testtools.TestCase testcase: The case to which to add the detail. |
|---|
| 204 | |
|---|
| 205 | :return: A matcher. |
|---|
| 206 | """ |
|---|
| 207 | |
|---|
| 208 | def predicate(value): |
|---|
| 209 | testcase.addDetail(name, text_content(value)) |
|---|
| 210 | return True |
|---|
| 211 | |
|---|
| 212 | return predicate |
|---|
| 213 | |
|---|
| 214 | |
|---|
| 215 | def readBodyText(response): |
|---|
| 216 | """ |
|---|
| 217 | Read the response body and decode it using UTF-8. |
|---|
| 218 | |
|---|
| 219 | :param twisted.web.iweb.IResponse response: The response from which to |
|---|
| 220 | read the body. |
|---|
| 221 | |
|---|
| 222 | :return: A ``Deferred`` that fires with the ``str`` body. |
|---|
| 223 | """ |
|---|
| 224 | d = readBody(response) |
|---|
| 225 | d.addCallback(lambda body: body.decode("utf-8")) |
|---|
| 226 | return d |
|---|
| 227 | |
|---|
| 228 | |
|---|
| 229 | def has_header(name, value): |
|---|
| 230 | """ |
|---|
| 231 | Create a matcher that matches a response object that includes the given |
|---|
| 232 | name / value pair. |
|---|
| 233 | |
|---|
| 234 | :param str name: The name of the item in the HTTP header to match. |
|---|
| 235 | :param str value: The value of the item in the HTTP header to match by equality. |
|---|
| 236 | |
|---|
| 237 | :return: A matcher. |
|---|
| 238 | """ |
|---|
| 239 | return AfterPreprocessing( |
|---|
| 240 | lambda headers: headers.getRawHeaders(name), |
|---|
| 241 | Equals([value]), |
|---|
| 242 | ) |
|---|
| 243 | |
|---|
| 244 | |
|---|
| 245 | def parses_as_openmetrics(): |
|---|
| 246 | """ |
|---|
| 247 | Create a matcher that matches a ``str`` string that can be parsed as an |
|---|
| 248 | OpenMetrics response and includes a certain well-known value expected by |
|---|
| 249 | the tests. |
|---|
| 250 | |
|---|
| 251 | :return: A matcher. |
|---|
| 252 | """ |
|---|
| 253 | # The parser throws if it does not like its input. |
|---|
| 254 | # Wrapped in a list() to drain the generator. |
|---|
| 255 | return AfterPreprocessing( |
|---|
| 256 | lambda body: list(parser.text_string_to_metric_families(body)), |
|---|
| 257 | AfterPreprocessing( |
|---|
| 258 | lambda families: families[-1].name, |
|---|
| 259 | Equals("tahoe_stats_storage_server_total_bucket_count"), |
|---|
| 260 | ), |
|---|
| 261 | ) |
|---|