1import base64
2from contextlib import closing
3import gzip
4from http.server import BaseHTTPRequestHandler
5import os
6import socket
7from socketserver import ThreadingMixIn
8import ssl
9import sys
10import threading
11from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
12from urllib.error import HTTPError
13from urllib.parse import parse_qs, quote_plus, urlparse
14from urllib.request import (
15 BaseHandler, build_opener, HTTPHandler, HTTPRedirectHandler, HTTPSHandler,
16 Request,
17)
18from wsgiref.simple_server import make_server, WSGIRequestHandler, WSGIServer
19
20from .openmetrics import exposition as openmetrics
21from .registry import CollectorRegistry, REGISTRY
22from .utils import floatToGoString
23from .validation import _is_valid_legacy_metric_name
24
25__all__ = (
26 'CONTENT_TYPE_LATEST',
27 'delete_from_gateway',
28 'generate_latest',
29 'instance_ip_grouping_key',
30 'make_asgi_app',
31 'make_wsgi_app',
32 'MetricsHandler',
33 'push_to_gateway',
34 'pushadd_to_gateway',
35 'start_http_server',
36 'start_wsgi_server',
37 'write_to_textfile',
38)
39
40CONTENT_TYPE_LATEST = 'text/plain; version=0.0.4; charset=utf-8'
41"""Content type of the latest text format"""
42
43
44class _PrometheusRedirectHandler(HTTPRedirectHandler):
45 """
46 Allow additional methods (e.g. PUT) and data forwarding in redirects.
47
48 Use of this class constitute a user's explicit agreement to the
49 redirect responses the Prometheus client will receive when using it.
50 You should only use this class if you control or otherwise trust the
51 redirect behavior involved and are certain it is safe to full transfer
52 the original request (method and data) to the redirected URL. For
53 example, if you know there is a cosmetic URL redirect in front of a
54 local deployment of a Prometheus server, and all redirects are safe,
55 this is the class to use to handle redirects in that case.
56
57 The standard HTTPRedirectHandler does not forward request data nor
58 does it allow redirected PUT requests (which Prometheus uses for some
59 operations, for example `push_to_gateway`) because these cannot
60 generically guarantee no violations of HTTP RFC 2616 requirements for
61 the user to explicitly confirm redirects that could have unexpected
62 side effects (such as rendering a PUT request non-idempotent or
63 creating multiple resources not named in the original request).
64 """
65
66 def redirect_request(self, req, fp, code, msg, headers, newurl):
67 """
68 Apply redirect logic to a request.
69
70 See parent HTTPRedirectHandler.redirect_request for parameter info.
71
72 If the redirect is disallowed, this raises the corresponding HTTP error.
73 If the redirect can't be determined, return None to allow other handlers
74 to try. If the redirect is allowed, return the new request.
75
76 This method specialized for the case when (a) the user knows that the
77 redirect will not cause unacceptable side effects for any request method,
78 and (b) the user knows that any request data should be passed through to
79 the redirect. If either condition is not met, this should not be used.
80 """
81 # note that requests being provided by a handler will use get_method to
82 # indicate the method, by monkeypatching this, instead of setting the
83 # Request object's method attribute.
84 m = getattr(req, "method", req.get_method())
85 if not (code in (301, 302, 303, 307) and m in ("GET", "HEAD")
86 or code in (301, 302, 303) and m in ("POST", "PUT")):
87 raise HTTPError(req.full_url, code, msg, headers, fp)
88 new_request = Request(
89 newurl.replace(' ', '%20'), # space escaping in new url if needed.
90 headers=req.headers,
91 origin_req_host=req.origin_req_host,
92 unverifiable=True,
93 data=req.data,
94 )
95 new_request.method = m
96 return new_request
97
98
99def _bake_output(registry, accept_header, accept_encoding_header, params, disable_compression):
100 """Bake output for metrics output."""
101 # Choose the correct plain text format of the output.
102 encoder, content_type = choose_encoder(accept_header)
103 if 'name[]' in params:
104 registry = registry.restricted_registry(params['name[]'])
105 output = encoder(registry)
106 headers = [('Content-Type', content_type)]
107 # If gzip encoding required, gzip the output.
108 if not disable_compression and gzip_accepted(accept_encoding_header):
109 output = gzip.compress(output)
110 headers.append(('Content-Encoding', 'gzip'))
111 return '200 OK', headers, output
112
113
114def make_wsgi_app(registry: CollectorRegistry = REGISTRY, disable_compression: bool = False) -> Callable:
115 """Create a WSGI app which serves the metrics from a registry."""
116
117 def prometheus_app(environ, start_response):
118 # Prepare parameters
119 accept_header = environ.get('HTTP_ACCEPT')
120 accept_encoding_header = environ.get('HTTP_ACCEPT_ENCODING')
121 params = parse_qs(environ.get('QUERY_STRING', ''))
122 method = environ['REQUEST_METHOD']
123
124 if method == 'OPTIONS':
125 status = '200 OK'
126 headers = [('Allow', 'OPTIONS,GET')]
127 output = b''
128 elif method != 'GET':
129 status = '405 Method Not Allowed'
130 headers = [('Allow', 'OPTIONS,GET')]
131 output = '# HTTP {}: {}; use OPTIONS or GET\n'.format(status, method).encode()
132 elif environ['PATH_INFO'] == '/favicon.ico':
133 # Serve empty response for browsers
134 status = '200 OK'
135 headers = []
136 output = b''
137 else:
138 # Note: For backwards compatibility, the URI path for GET is not
139 # constrained to the documented /metrics, but any path is allowed.
140 # Bake output
141 status, headers, output = _bake_output(registry, accept_header, accept_encoding_header, params, disable_compression)
142 # Return output
143 start_response(status, headers)
144 return [output]
145
146 return prometheus_app
147
148
149class _SilentHandler(WSGIRequestHandler):
150 """WSGI handler that does not log requests."""
151
152 def log_message(self, format, *args):
153 """Log nothing."""
154
155
156class ThreadingWSGIServer(ThreadingMixIn, WSGIServer):
157 """Thread per request HTTP server."""
158 # Make worker threads "fire and forget". Beginning with Python 3.7 this
159 # prevents a memory leak because ``ThreadingMixIn`` starts to gather all
160 # non-daemon threads in a list in order to join on them at server close.
161 daemon_threads = True
162
163
164def _get_best_family(address, port):
165 """Automatically select address family depending on address"""
166 # HTTPServer defaults to AF_INET, which will not start properly if
167 # binding an ipv6 address is requested.
168 # This function is based on what upstream python did for http.server
169 # in https://github.com/python/cpython/pull/11767
170 infos = socket.getaddrinfo(address, port, type=socket.SOCK_STREAM, flags=socket.AI_PASSIVE)
171 family, _, _, _, sockaddr = next(iter(infos))
172 return family, sockaddr[0]
173
174
175def _get_ssl_ctx(
176 certfile: str,
177 keyfile: str,
178 protocol: int,
179 cafile: Optional[str] = None,
180 capath: Optional[str] = None,
181 client_auth_required: bool = False,
182) -> ssl.SSLContext:
183 """Load context supports SSL."""
184 ssl_cxt = ssl.SSLContext(protocol=protocol)
185
186 if cafile is not None or capath is not None:
187 try:
188 ssl_cxt.load_verify_locations(cafile, capath)
189 except IOError as exc:
190 exc_type = type(exc)
191 msg = str(exc)
192 raise exc_type(f"Cannot load CA certificate chain from file "
193 f"{cafile!r} or directory {capath!r}: {msg}")
194 else:
195 try:
196 ssl_cxt.load_default_certs(purpose=ssl.Purpose.CLIENT_AUTH)
197 except IOError as exc:
198 exc_type = type(exc)
199 msg = str(exc)
200 raise exc_type(f"Cannot load default CA certificate chain: {msg}")
201
202 if client_auth_required:
203 ssl_cxt.verify_mode = ssl.CERT_REQUIRED
204
205 try:
206 ssl_cxt.load_cert_chain(certfile=certfile, keyfile=keyfile)
207 except IOError as exc:
208 exc_type = type(exc)
209 msg = str(exc)
210 raise exc_type(f"Cannot load server certificate file {certfile!r} or "
211 f"its private key file {keyfile!r}: {msg}")
212
213 return ssl_cxt
214
215
216def start_wsgi_server(
217 port: int,
218 addr: str = '0.0.0.0',
219 registry: CollectorRegistry = REGISTRY,
220 certfile: Optional[str] = None,
221 keyfile: Optional[str] = None,
222 client_cafile: Optional[str] = None,
223 client_capath: Optional[str] = None,
224 protocol: int = ssl.PROTOCOL_TLS_SERVER,
225 client_auth_required: bool = False,
226) -> Tuple[WSGIServer, threading.Thread]:
227 """Starts a WSGI server for prometheus metrics as a daemon thread."""
228
229 class TmpServer(ThreadingWSGIServer):
230 """Copy of ThreadingWSGIServer to update address_family locally"""
231
232 TmpServer.address_family, addr = _get_best_family(addr, port)
233 app = make_wsgi_app(registry)
234 httpd = make_server(addr, port, app, TmpServer, handler_class=_SilentHandler)
235 if certfile and keyfile:
236 context = _get_ssl_ctx(certfile, keyfile, protocol, client_cafile, client_capath, client_auth_required)
237 httpd.socket = context.wrap_socket(httpd.socket, server_side=True)
238 t = threading.Thread(target=httpd.serve_forever)
239 t.daemon = True
240 t.start()
241
242 return httpd, t
243
244
245start_http_server = start_wsgi_server
246
247
248def generate_latest(registry: CollectorRegistry = REGISTRY) -> bytes:
249 """Returns the metrics from the registry in latest text format as a string."""
250
251 def sample_line(samples):
252 if samples.labels:
253 labelstr = '{0}'.format(','.join(
254 ['{}="{}"'.format(
255 openmetrics.escape_label_name(k), openmetrics._escape(v))
256 for k, v in sorted(samples.labels.items())]))
257 else:
258 labelstr = ''
259 timestamp = ''
260 if samples.timestamp is not None:
261 # Convert to milliseconds.
262 timestamp = f' {int(float(samples.timestamp) * 1000):d}'
263 if _is_valid_legacy_metric_name(samples.name):
264 if labelstr:
265 labelstr = '{{{0}}}'.format(labelstr)
266 return f'{samples.name}{labelstr} {floatToGoString(samples.value)}{timestamp}\n'
267 maybe_comma = ''
268 if labelstr:
269 maybe_comma = ','
270 return f'{{{openmetrics.escape_metric_name(samples.name)}{maybe_comma}{labelstr}}} {floatToGoString(samples.value)}{timestamp}\n'
271
272 output = []
273 for metric in registry.collect():
274 try:
275 mname = metric.name
276 mtype = metric.type
277 # Munging from OpenMetrics into Prometheus format.
278 if mtype == 'counter':
279 mname = mname + '_total'
280 elif mtype == 'info':
281 mname = mname + '_info'
282 mtype = 'gauge'
283 elif mtype == 'stateset':
284 mtype = 'gauge'
285 elif mtype == 'gaugehistogram':
286 # A gauge histogram is really a gauge,
287 # but this captures the structure better.
288 mtype = 'histogram'
289 elif mtype == 'unknown':
290 mtype = 'untyped'
291
292 output.append('# HELP {} {}\n'.format(
293 openmetrics.escape_metric_name(mname), metric.documentation.replace('\\', r'\\').replace('\n', r'\n')))
294 output.append(f'# TYPE {openmetrics.escape_metric_name(mname)} {mtype}\n')
295
296 om_samples: Dict[str, List[str]] = {}
297 for s in metric.samples:
298 for suffix in ['_created', '_gsum', '_gcount']:
299 if s.name == metric.name + suffix:
300 # OpenMetrics specific sample, put in a gauge at the end.
301 om_samples.setdefault(suffix, []).append(sample_line(s))
302 break
303 else:
304 output.append(sample_line(s))
305 except Exception as exception:
306 exception.args = (exception.args or ('',)) + (metric,)
307 raise
308
309 for suffix, lines in sorted(om_samples.items()):
310 output.append('# HELP {} {}\n'.format(openmetrics.escape_metric_name(metric.name + suffix),
311 metric.documentation.replace('\\', r'\\').replace('\n', r'\n')))
312 output.append(f'# TYPE {openmetrics.escape_metric_name(metric.name + suffix)} gauge\n')
313 output.extend(lines)
314 return ''.join(output).encode('utf-8')
315
316
317def choose_encoder(accept_header: str) -> Tuple[Callable[[CollectorRegistry], bytes], str]:
318 accept_header = accept_header or ''
319 for accepted in accept_header.split(','):
320 if accepted.split(';')[0].strip() == 'application/openmetrics-text':
321 return (openmetrics.generate_latest,
322 openmetrics.CONTENT_TYPE_LATEST)
323 return generate_latest, CONTENT_TYPE_LATEST
324
325
326def gzip_accepted(accept_encoding_header: str) -> bool:
327 accept_encoding_header = accept_encoding_header or ''
328 for accepted in accept_encoding_header.split(','):
329 if accepted.split(';')[0].strip().lower() == 'gzip':
330 return True
331 return False
332
333
334class MetricsHandler(BaseHTTPRequestHandler):
335 """HTTP handler that gives metrics from ``REGISTRY``."""
336 registry: CollectorRegistry = REGISTRY
337
338 def do_GET(self) -> None:
339 # Prepare parameters
340 registry = self.registry
341 accept_header = self.headers.get('Accept')
342 accept_encoding_header = self.headers.get('Accept-Encoding')
343 params = parse_qs(urlparse(self.path).query)
344 # Bake output
345 status, headers, output = _bake_output(registry, accept_header, accept_encoding_header, params, False)
346 # Return output
347 self.send_response(int(status.split(' ')[0]))
348 for header in headers:
349 self.send_header(*header)
350 self.end_headers()
351 self.wfile.write(output)
352
353 def log_message(self, format: str, *args: Any) -> None:
354 """Log nothing."""
355
356 @classmethod
357 def factory(cls, registry: CollectorRegistry) -> type:
358 """Returns a dynamic MetricsHandler class tied
359 to the passed registry.
360 """
361 # This implementation relies on MetricsHandler.registry
362 # (defined above and defaulted to REGISTRY).
363
364 # As we have unicode_literals, we need to create a str()
365 # object for type().
366 cls_name = str(cls.__name__)
367 MyMetricsHandler = type(cls_name, (cls, object),
368 {"registry": registry})
369 return MyMetricsHandler
370
371
372def write_to_textfile(path: str, registry: CollectorRegistry) -> None:
373 """Write metrics to the given path.
374
375 This is intended for use with the Node exporter textfile collector.
376 The path must end in .prom for the textfile collector to process it."""
377 tmppath = f'{path}.{os.getpid()}.{threading.current_thread().ident}'
378 try:
379 with open(tmppath, 'wb') as f:
380 f.write(generate_latest(registry))
381
382 # rename(2) is atomic but fails on Windows if the destination file exists
383 if os.name == 'nt':
384 os.replace(tmppath, path)
385 else:
386 os.rename(tmppath, path)
387 except Exception:
388 if os.path.exists(tmppath):
389 os.remove(tmppath)
390 raise
391
392
393def _make_handler(
394 url: str,
395 method: str,
396 timeout: Optional[float],
397 headers: Sequence[Tuple[str, str]],
398 data: bytes,
399 base_handler: Union[BaseHandler, type],
400) -> Callable[[], None]:
401 def handle() -> None:
402 request = Request(url, data=data)
403 request.get_method = lambda: method # type: ignore
404 for k, v in headers:
405 request.add_header(k, v)
406 resp = build_opener(base_handler).open(request, timeout=timeout)
407 if resp.code >= 400:
408 raise OSError(f"error talking to pushgateway: {resp.code} {resp.msg}")
409
410 return handle
411
412
413def default_handler(
414 url: str,
415 method: str,
416 timeout: Optional[float],
417 headers: List[Tuple[str, str]],
418 data: bytes,
419) -> Callable[[], None]:
420 """Default handler that implements HTTP/HTTPS connections.
421
422 Used by the push_to_gateway functions. Can be re-used by other handlers."""
423
424 return _make_handler(url, method, timeout, headers, data, HTTPHandler)
425
426
427def passthrough_redirect_handler(
428 url: str,
429 method: str,
430 timeout: Optional[float],
431 headers: List[Tuple[str, str]],
432 data: bytes,
433) -> Callable[[], None]:
434 """
435 Handler that automatically trusts redirect responses for all HTTP methods.
436
437 Augments standard HTTPRedirectHandler capability by permitting PUT requests,
438 preserving the method upon redirect, and passing through all headers and
439 data from the original request. Only use this handler if you control or
440 trust the source of redirect responses you encounter when making requests
441 via the Prometheus client. This handler will simply repeat the identical
442 request, including same method and data, to the new redirect URL."""
443
444 return _make_handler(url, method, timeout, headers, data, _PrometheusRedirectHandler)
445
446
447def basic_auth_handler(
448 url: str,
449 method: str,
450 timeout: Optional[float],
451 headers: List[Tuple[str, str]],
452 data: bytes,
453 username: Optional[str] = None,
454 password: Optional[str] = None,
455) -> Callable[[], None]:
456 """Handler that implements HTTP/HTTPS connections with Basic Auth.
457
458 Sets auth headers using supplied 'username' and 'password', if set.
459 Used by the push_to_gateway functions. Can be re-used by other handlers."""
460
461 def handle():
462 """Handler that implements HTTP Basic Auth.
463 """
464 if username is not None and password is not None:
465 auth_value = f'{username}:{password}'.encode()
466 auth_token = base64.b64encode(auth_value)
467 auth_header = b'Basic ' + auth_token
468 headers.append(('Authorization', auth_header))
469 default_handler(url, method, timeout, headers, data)()
470
471 return handle
472
473
474def tls_auth_handler(
475 url: str,
476 method: str,
477 timeout: Optional[float],
478 headers: List[Tuple[str, str]],
479 data: bytes,
480 certfile: str,
481 keyfile: str,
482 cafile: Optional[str] = None,
483 protocol: int = ssl.PROTOCOL_TLS_CLIENT,
484 insecure_skip_verify: bool = False,
485) -> Callable[[], None]:
486 """Handler that implements an HTTPS connection with TLS Auth.
487
488 The default protocol (ssl.PROTOCOL_TLS_CLIENT) will also enable
489 ssl.CERT_REQUIRED and SSLContext.check_hostname by default. This can be
490 disabled by setting insecure_skip_verify to True.
491
492 Both this handler and the TLS feature on pushgateay are experimental."""
493 context = ssl.SSLContext(protocol=protocol)
494 if cafile is not None:
495 context.load_verify_locations(cafile)
496 else:
497 context.load_default_certs()
498
499 if insecure_skip_verify:
500 context.check_hostname = False
501 context.verify_mode = ssl.CERT_NONE
502
503 context.load_cert_chain(certfile=certfile, keyfile=keyfile)
504 handler = HTTPSHandler(context=context)
505 return _make_handler(url, method, timeout, headers, data, handler)
506
507
508def push_to_gateway(
509 gateway: str,
510 job: str,
511 registry: CollectorRegistry,
512 grouping_key: Optional[Dict[str, Any]] = None,
513 timeout: Optional[float] = 30,
514 handler: Callable = default_handler,
515) -> None:
516 """Push metrics to the given pushgateway.
517
518 `gateway` the url for your push gateway. Either of the form
519 'http://pushgateway.local', or 'pushgateway.local'.
520 Scheme defaults to 'http' if none is provided
521 `job` is the job label to be attached to all pushed metrics
522 `registry` is an instance of CollectorRegistry
523 `grouping_key` please see the pushgateway documentation for details.
524 Defaults to None
525 `timeout` is how long push will attempt to connect before giving up.
526 Defaults to 30s, can be set to None for no timeout.
527 `handler` is an optional function which can be provided to perform
528 requests to the 'gateway'.
529 Defaults to None, in which case an http or https request
530 will be carried out by a default handler.
531 If not None, the argument must be a function which accepts
532 the following arguments:
533 url, method, timeout, headers, and content
534 May be used to implement additional functionality not
535 supported by the built-in default handler (such as SSL
536 client certicates, and HTTP authentication mechanisms).
537 'url' is the URL for the request, the 'gateway' argument
538 described earlier will form the basis of this URL.
539 'method' is the HTTP method which should be used when
540 carrying out the request.
541 'timeout' requests not successfully completed after this
542 many seconds should be aborted. If timeout is None, then
543 the handler should not set a timeout.
544 'headers' is a list of ("header-name","header-value") tuples
545 which must be passed to the pushgateway in the form of HTTP
546 request headers.
547 The function should raise an exception (e.g. IOError) on
548 failure.
549 'content' is the data which should be used to form the HTTP
550 Message Body.
551
552 This overwrites all metrics with the same job and grouping_key.
553 This uses the PUT HTTP method."""
554 _use_gateway('PUT', gateway, job, registry, grouping_key, timeout, handler)
555
556
557def pushadd_to_gateway(
558 gateway: str,
559 job: str,
560 registry: Optional[CollectorRegistry],
561 grouping_key: Optional[Dict[str, Any]] = None,
562 timeout: Optional[float] = 30,
563 handler: Callable = default_handler,
564) -> None:
565 """PushAdd metrics to the given pushgateway.
566
567 `gateway` the url for your push gateway. Either of the form
568 'http://pushgateway.local', or 'pushgateway.local'.
569 Scheme defaults to 'http' if none is provided
570 `job` is the job label to be attached to all pushed metrics
571 `registry` is an instance of CollectorRegistry
572 `grouping_key` please see the pushgateway documentation for details.
573 Defaults to None
574 `timeout` is how long push will attempt to connect before giving up.
575 Defaults to 30s, can be set to None for no timeout.
576 `handler` is an optional function which can be provided to perform
577 requests to the 'gateway'.
578 Defaults to None, in which case an http or https request
579 will be carried out by a default handler.
580 See the 'prometheus_client.push_to_gateway' documentation
581 for implementation requirements.
582
583 This replaces metrics with the same name, job and grouping_key.
584 This uses the POST HTTP method."""
585 _use_gateway('POST', gateway, job, registry, grouping_key, timeout, handler)
586
587
588def delete_from_gateway(
589 gateway: str,
590 job: str,
591 grouping_key: Optional[Dict[str, Any]] = None,
592 timeout: Optional[float] = 30,
593 handler: Callable = default_handler,
594) -> None:
595 """Delete metrics from the given pushgateway.
596
597 `gateway` the url for your push gateway. Either of the form
598 'http://pushgateway.local', or 'pushgateway.local'.
599 Scheme defaults to 'http' if none is provided
600 `job` is the job label to be attached to all pushed metrics
601 `grouping_key` please see the pushgateway documentation for details.
602 Defaults to None
603 `timeout` is how long delete will attempt to connect before giving up.
604 Defaults to 30s, can be set to None for no timeout.
605 `handler` is an optional function which can be provided to perform
606 requests to the 'gateway'.
607 Defaults to None, in which case an http or https request
608 will be carried out by a default handler.
609 See the 'prometheus_client.push_to_gateway' documentation
610 for implementation requirements.
611
612 This deletes metrics with the given job and grouping_key.
613 This uses the DELETE HTTP method."""
614 _use_gateway('DELETE', gateway, job, None, grouping_key, timeout, handler)
615
616
617def _use_gateway(
618 method: str,
619 gateway: str,
620 job: str,
621 registry: Optional[CollectorRegistry],
622 grouping_key: Optional[Dict[str, Any]],
623 timeout: Optional[float],
624 handler: Callable,
625) -> None:
626 gateway_url = urlparse(gateway)
627 # See https://bugs.python.org/issue27657 for details on urlparse in py>=3.7.6.
628 if not gateway_url.scheme or gateway_url.scheme not in ['http', 'https']:
629 gateway = f'http://{gateway}'
630
631 gateway = gateway.rstrip('/')
632 url = '{}/metrics/{}/{}'.format(gateway, *_escape_grouping_key("job", job))
633
634 data = b''
635 if method != 'DELETE':
636 if registry is None:
637 registry = REGISTRY
638 data = generate_latest(registry)
639
640 if grouping_key is None:
641 grouping_key = {}
642 url += ''.join(
643 '/{}/{}'.format(*_escape_grouping_key(str(k), str(v)))
644 for k, v in sorted(grouping_key.items()))
645
646 handler(
647 url=url, method=method, timeout=timeout,
648 headers=[('Content-Type', CONTENT_TYPE_LATEST)], data=data,
649 )()
650
651
652def _escape_grouping_key(k, v):
653 if v == "":
654 # Per https://github.com/prometheus/pushgateway/pull/346.
655 return k + "@base64", "="
656 elif '/' in v:
657 # Added in Pushgateway 0.9.0.
658 return k + "@base64", base64.urlsafe_b64encode(v.encode("utf-8")).decode("utf-8")
659 else:
660 return k, quote_plus(v)
661
662
663def instance_ip_grouping_key() -> Dict[str, Any]:
664 """Grouping key with instance set to the IP Address of this host."""
665 with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as s:
666 if sys.platform == 'darwin':
667 # This check is done this way only on MacOS devices
668 # it is done this way because the localhost method does
669 # not work.
670 # This method was adapted from this StackOverflow answer:
671 # https://stackoverflow.com/a/28950776
672 s.connect(('10.255.255.255', 1))
673 else:
674 s.connect(('localhost', 0))
675
676 return {'instance': s.getsockname()[0]}
677
678
679from .asgi import make_asgi_app # noqa