queryapigate 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
queryapigate/engine.py ADDED
@@ -0,0 +1,166 @@
1
+ """Ties the pieces together: look up the connection, validate the SQL, run it, wrap the page."""
2
+ import hashlib
3
+ import logging
4
+ import time
5
+
6
+ from . import config, metrics, store
7
+ from .errors import ApiError
8
+ from .formats import ResultSetDTO
9
+ from .pool import get_pool
10
+ from .runners import RUNNERS, STREAM_RUNNERS
11
+ from .sqltools import validate_sql
12
+
13
+ log = logging.getLogger('queryapigate')
14
+
15
+
16
+ def _sql_hash(sql):
17
+ """A correlation key for "did this same query run elsewhere/before" without re-reading the SQL text
18
+ itself - same idea (and same full-length sha256 hex) pool.py and cache.py already use for their own
19
+ keys. Logged *alongside* the full SQL text (see the log.info() calls below), not instead of it - the
20
+ hash is for a log aggregator to filter/group on; the full text is still there for a human reading logs
21
+ locally."""
22
+ return hashlib.sha256(sql.encode('utf-8')).hexdigest()
23
+
24
+
25
+ def execute_sql(sql, connection_name, limit, offset, params=None, timeout=None, allow_writes=True, key_name='-',
26
+ allowed_write_ops=None):
27
+ """Run ``sql`` on a named connection and return the requested page as a ResultSetDTO.
28
+
29
+ ``allow_writes`` is the caller's own permission (e.g. a scoped API key); the connection is only ever
30
+ writable when both that *and* the server-wide QUERYAPIGATE_ALLOW_WRITES allow it - a caller can narrow the
31
+ server's setting, never widen it. ``key_name`` is only for the query counter in ``/metrics`` (audit: which
32
+ key touched which connection); it plays no part in what the query is allowed to do. ``allowed_write_ops``
33
+ is a key's own narrower allow-list of write keywords, if it has one - see ``sqltools.validate_sql()``.
34
+ """
35
+ details = store.get_connection(connection_name)
36
+ effective_allow_writes = config.allow_writes() and allow_writes
37
+ sql = validate_sql(sql, dialect=details['db'], allow_writes=effective_allow_writes,
38
+ allowed_write_ops=allowed_write_ops)
39
+ log.info('Executing on %s (%s), limit=%s offset=%s timeout=%s: %s',
40
+ connection_name, details['db'], limit, offset, timeout, sql,
41
+ extra={'connection': connection_name, 'dialect': details['db'], 'limit': limit, 'offset': offset,
42
+ 'timeout': timeout, 'sql_hash': _sql_hash(sql)})
43
+ started = time.monotonic()
44
+ status = 'error'
45
+ metrics.inc_active_query()
46
+ try:
47
+ columns, rows = RUNNERS[details['db']](details, sql, params, limit, offset, not effective_allow_writes,
48
+ timeout, get_pool())
49
+ status = 'success'
50
+ except ApiError:
51
+ raise
52
+ except ImportError as error:
53
+ log.exception('Missing database driver')
54
+ raise ApiError(f"The driver for '{details['db']}' is not installed", 500, detail=str(error)) from error
55
+ except Exception as error:
56
+ log.exception('Query on %s failed', connection_name)
57
+ raise ApiError('An error occurred while executing the SQL query', 500, detail=str(error)) from error
58
+ finally:
59
+ metrics.dec_active_query()
60
+ elapsed = time.monotonic() - started
61
+ metrics.observe_query(connection_name, details['db'], status, elapsed, key_name)
62
+ threshold = config.slow_query_threshold()
63
+ if threshold and elapsed >= threshold:
64
+ log.warning('Slow query on %s (%s): %.1fms - %s', connection_name, details['db'], elapsed * 1000, sql,
65
+ extra={'connection': connection_name, 'dialect': details['db'],
66
+ 'duration_ms': round(elapsed * 1000, 1)})
67
+ has_more = len(rows) > limit
68
+ result_rows = rows[:limit]
69
+ metrics.observe_rows(connection_name, details['db'], key_name, len(result_rows))
70
+ return ResultSetDTO(result_rows, columns, has_more=has_more)
71
+
72
+
73
+ def stream_sql(sql, connection_name, params=None, timeout=None, key_name='-'):
74
+ """Like execute_sql, but for the whole result rather than one page - and, unlike execute_sql, always
75
+ read-only regardless of QUERYAPIGATE_ALLOW_WRITES or the caller's own permission. A large export has no
76
+ business mutating data, and forcing this sidesteps a lot of incidental complexity around commit timing
77
+ on a connection that may stay checked out for a long time - see runners.py's "Streaming" section for
78
+ what that already involves per dialect without adding writes into the mix too.
79
+
80
+ Returns (columns, rows): ``columns`` is available immediately (the underlying generator is primed once
81
+ to get it, surfacing a connection or SQL error here just like execute_sql does), ``rows`` is a lazy
82
+ generator - the connection checked out (or freshly opened) for it is released only once that generator
83
+ is exhausted, errors, or a client disconnect closes it early (see runners._make_stream_runner).
84
+ """
85
+ details = store.get_connection(connection_name)
86
+ sql = validate_sql(sql, dialect=details['db'], allow_writes=False)
87
+ log.info('Streaming from %s (%s): %s', connection_name, details['db'], sql,
88
+ extra={'connection': connection_name, 'dialect': details['db'], 'sql_hash': _sql_hash(sql)})
89
+ status = 'error'
90
+ metrics.inc_active_query()
91
+ try:
92
+ stream = STREAM_RUNNERS[details['db']](details, sql, params, timeout, get_pool())
93
+ columns = next(stream)
94
+ status = 'success'
95
+ except ApiError:
96
+ raise
97
+ except ImportError as error:
98
+ log.exception('Missing database driver')
99
+ raise ApiError(f"The driver for '{details['db']}' is not installed", 500, detail=str(error)) from error
100
+ except Exception as error:
101
+ log.exception('Streaming query on %s failed', connection_name)
102
+ raise ApiError('An error occurred while executing the SQL query', 500, detail=str(error)) from error
103
+ finally:
104
+ # On success, the query stays active until _drain() below finishes consuming it - the decrement
105
+ # (and the row/stream-status metrics) move there with it, not here.
106
+ if status == 'error':
107
+ metrics.observe_stream(connection_name, details['db'], status, key_name)
108
+ metrics.dec_active_query()
109
+ return columns, _drain(stream, connection_name, details['db'], key_name)
110
+
111
+
112
+ def _drain(rows, connection_name, dialect, key_name):
113
+ """Wraps the row generator stream_sql() returns: records the export's final status once it is fully
114
+ consumed (success) or fails partway through (error - client disconnects are not failures, so
115
+ GeneratorExit is excluded), and makes sure a mid-stream failure lands in the log. Once the response has
116
+ started, a failure here can no longer change its status or body shape - the client just sees the
117
+ connection end early - so logging clearly is the most this can do about it. Also where the query
118
+ started by stream_sql() actually stops being "active" (see inc_active_query() there), and where rows
119
+ that made it out are counted - even a partial count on failure or disconnect is data that left the
120
+ server, not nothing.
121
+
122
+ Also enforces QUERYAPIGATE_STREAM_MAX_ROWS (config.stream_max_rows()), if set: unlike a paged response,
123
+ ?stream=true has no ceiling of its own otherwise - the whole point is not buffering the result, so
124
+ nothing else naturally bounds how much a single export can return. Breaking out of the ``for`` loop
125
+ below (rather than letting it run to exhaustion) still reaches the ``else`` clause normally - a ``break``
126
+ only skips a ``for``'s own ``else``, not the surrounding ``try``'s - so the usual success bookkeeping
127
+ still runs; ``rows.close()`` is what actually releases the still-open connection early, the same
128
+ GeneratorExit-based cleanup a client disconnecting mid-stream already triggers (see
129
+ runners._make_stream_runner), just initiated from here instead of by the client closing the response.
130
+ """
131
+ limit = config.stream_max_rows()
132
+ truncated = False
133
+ count = 0
134
+ try:
135
+ for row in rows:
136
+ count += 1
137
+ yield row
138
+ if limit is not None and count >= limit:
139
+ truncated = True
140
+ break
141
+ except GeneratorExit:
142
+ metrics.observe_rows(connection_name, dialect, key_name, count)
143
+ metrics.dec_active_query()
144
+ raise
145
+ except Exception:
146
+ log.exception('Streaming from %s failed partway through', connection_name)
147
+ metrics.observe_stream(connection_name, dialect, 'error', key_name)
148
+ metrics.observe_rows(connection_name, dialect, key_name, count)
149
+ metrics.dec_active_query()
150
+ raise
151
+ else:
152
+ if truncated:
153
+ rows.close()
154
+ log.warning('Streaming from %s truncated at %s rows (QUERYAPIGATE_STREAM_MAX_ROWS)',
155
+ connection_name, limit,
156
+ extra={'connection': connection_name, 'dialect': dialect, 'row_limit': limit})
157
+ metrics.observe_stream(connection_name, dialect, 'truncated' if truncated else 'success', key_name)
158
+ metrics.observe_rows(connection_name, dialect, key_name, count)
159
+ metrics.dec_active_query()
160
+
161
+
162
+ def timed(func, *args, **kwargs):
163
+ """Call ``func`` and return (result, elapsed milliseconds)."""
164
+ started = time.monotonic()
165
+ result = func(*args, **kwargs)
166
+ return result, round((time.monotonic() - started) * 1000)
queryapigate/errors.py ADDED
@@ -0,0 +1,8 @@
1
+ class ApiError(Exception):
2
+ """An error that maps directly onto an HTTP response."""
3
+
4
+ def __init__(self, message, status=400, **extra):
5
+ super().__init__(message)
6
+ self.message = message
7
+ self.status = status
8
+ self.extra = extra
@@ -0,0 +1,193 @@
1
+ """Result sets and their output formats (JSON, NDJSON, CSV, TSV, XML, YAML, XLSX)."""
2
+ import csv
3
+ import json
4
+ import re
5
+ import xml.etree.ElementTree as ET
6
+ from datetime import date, datetime, time
7
+ from decimal import Decimal
8
+ from io import BytesIO, StringIO
9
+
10
+ import yaml
11
+ from flask import Response, jsonify
12
+ from openpyxl import Workbook
13
+ from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
14
+
15
+
16
+ def json_default(obj):
17
+ """``default=`` hook for json.dumps covering the types database drivers return."""
18
+ if isinstance(obj, datetime):
19
+ return obj.strftime('%Y-%m-%d %H:%M:%S')
20
+ if isinstance(obj, (date, time)):
21
+ return obj.isoformat()
22
+ if isinstance(obj, Decimal):
23
+ return float(obj)
24
+ if isinstance(obj, (bytes, bytearray, memoryview)):
25
+ return bytes(obj).decode('utf-8', 'replace')
26
+ return str(obj)
27
+
28
+
29
+ def _unique(names):
30
+ """Disambiguate repeated column names (e.g. ``SELECT a.id, b.id``) so no data is lost."""
31
+ seen, result = {}, []
32
+ for name in names:
33
+ name = str(name)
34
+ seen[name] = seen.get(name, 0) + 1
35
+ result.append(name if seen[name] == 1 else f'{name}_{seen[name]}')
36
+ return result
37
+
38
+
39
+ def _cell(value):
40
+ """Coerce a value into plain built-in types that CSV, YAML and Excel writers all accept.
41
+
42
+ Some drivers (e.g. H2 through JPype) return subclasses of int/float/str, which PyYAML's safe
43
+ dumper rejects because it only recognises the exact built-in types.
44
+ """
45
+ if value is None:
46
+ return None
47
+ if isinstance(value, bool):
48
+ return bool(value)
49
+ if isinstance(value, int):
50
+ return int(value)
51
+ if isinstance(value, float):
52
+ return float(value)
53
+ if isinstance(value, str):
54
+ return str(value)
55
+ if isinstance(value, Decimal):
56
+ return float(value)
57
+ if isinstance(value, (bytes, bytearray, memoryview)):
58
+ return bytes(value).decode('utf-8', 'replace')
59
+ if isinstance(value, datetime):
60
+ return value.replace(tzinfo=None) if value.tzinfo else value
61
+ if isinstance(value, (date, time)):
62
+ return value
63
+ return str(value)
64
+
65
+
66
+ def _xml_name(name):
67
+ name = re.sub(r'[^\w.\-]', '_', name)
68
+ return name if re.match(r'[A-Za-z_]', name) else f'_{name}'
69
+
70
+
71
+ class ResultSetDTO:
72
+ """A page of query results (column names + rows) with output-format converters."""
73
+
74
+ def __init__(self, rows, columns, has_more=False):
75
+ self.columns = _unique(columns)
76
+ self.rows = [[_cell(v) for v in row] for row in rows]
77
+ self.has_more = has_more
78
+
79
+ def __bool__(self):
80
+ return bool(self.rows)
81
+
82
+ def as_dicts(self):
83
+ return [dict(zip(self.columns, row)) for row in self.rows]
84
+
85
+ def _to_delimited(self, delimiter, mimetype):
86
+ data_io = StringIO()
87
+ writer = csv.writer(data_io, delimiter=delimiter)
88
+ writer.writerow(self.columns)
89
+ writer.writerows(self.rows)
90
+ return Response(data_io.getvalue(), mimetype=mimetype)
91
+
92
+ def to_csv(self):
93
+ return self._to_delimited(',', 'text/csv')
94
+
95
+ def to_tsv(self):
96
+ return self._to_delimited('\t', 'text/tab-separated-values')
97
+
98
+ def to_json(self):
99
+ return jsonify(self.as_dicts())
100
+
101
+ def to_ndjson(self):
102
+ lines = (json.dumps(row, default=json_default) for row in self.as_dicts())
103
+ return Response('\n'.join(lines) + '\n', mimetype='application/x-ndjson')
104
+
105
+ def to_xml(self):
106
+ root = ET.Element('data')
107
+ tags = [_xml_name(c) for c in self.columns]
108
+ for row in self.rows:
109
+ item = ET.SubElement(root, 'item')
110
+ for tag, value in zip(tags, row):
111
+ ET.SubElement(item, tag).text = '' if value is None else str(value)
112
+ return Response(ET.tostring(root, encoding='unicode', method='xml'), mimetype='application/xml')
113
+
114
+ def to_yaml(self):
115
+ return Response(yaml.safe_dump(self.as_dicts(), default_flow_style=False, sort_keys=False,
116
+ allow_unicode=True),
117
+ mimetype='application/x-yaml')
118
+
119
+ def to_xlsx(self):
120
+ wb = Workbook()
121
+ ws = wb.active
122
+ ws.append(self.columns)
123
+ for row in self.rows:
124
+ ws.append([ILLEGAL_CHARACTERS_RE.sub('', v) if isinstance(v, str) else v for v in row])
125
+ excel_data = BytesIO()
126
+ wb.save(excel_data)
127
+ return Response(
128
+ excel_data.getvalue(),
129
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
130
+ headers={'Content-Disposition': 'attachment;filename=result.xlsx'},
131
+ )
132
+
133
+
134
+ FORMATTERS = {
135
+ 'json': ResultSetDTO.to_json,
136
+ 'ndjson': ResultSetDTO.to_ndjson,
137
+ 'csv': ResultSetDTO.to_csv,
138
+ 'tsv': ResultSetDTO.to_tsv,
139
+ 'xml': ResultSetDTO.to_xml,
140
+ 'yaml': ResultSetDTO.to_yaml,
141
+ 'xlsx': ResultSetDTO.to_xlsx,
142
+ }
143
+
144
+ # --------------------------------------------------------------------------------------
145
+ # Streaming: unlike ResultSetDTO above, these never hold more than one row (or one small
146
+ # write buffer) in memory at a time - `rows` is a generator pulling batches from a live
147
+ # database cursor, not a pre-materialised list. Only CSV and NDJSON are genuinely streamable
148
+ # text formats; XML/YAML/XLSX all need the whole document structure in memory to write
149
+ # correctly (a closing root tag, a single top-level list, a zip's central directory), so
150
+ # they stay page-at-a-time via ResultSetDTO/FORMATTERS above.
151
+
152
+ STREAM_FORMATTERS = frozenset({'csv', 'tsv', 'ndjson'})
153
+ STREAM_MIMETYPES = {'csv': 'text/csv', 'tsv': 'text/tab-separated-values', 'ndjson': 'application/x-ndjson'}
154
+ STREAM_DELIMITERS = {'csv': ',', 'tsv': '\t'}
155
+
156
+
157
+ def _stream_delimited(columns, rows, delimiter):
158
+ buf = StringIO()
159
+ writer = csv.writer(buf, delimiter=delimiter)
160
+ writer.writerow(columns)
161
+ yield buf.getvalue()
162
+ for row in rows:
163
+ buf.seek(0)
164
+ buf.truncate(0)
165
+ writer.writerow([_cell(v) for v in row])
166
+ yield buf.getvalue()
167
+
168
+
169
+ def _stream_ndjson(columns, rows):
170
+ for row in rows:
171
+ yield json.dumps(dict(zip(columns, (_cell(v) for v in row))), default=json_default) + '\n'
172
+
173
+
174
+ def iter_stream_chunks(output_format, columns, rows):
175
+ """The text chunks for ``output_format`` in ``STREAM_FORMATTERS``, generated from ``rows`` as they
176
+ arrive rather than built up front - shared by stream_response() (an HTTP response body) and the
177
+ ``queryapigate export`` CLI command (written straight to a file), so there is exactly one place that knows
178
+ how to turn a row stream into CSV/TSV/NDJSON text."""
179
+ columns = _unique(columns)
180
+ if output_format == 'ndjson':
181
+ return _stream_ndjson(columns, rows)
182
+ return _stream_delimited(columns, rows, STREAM_DELIMITERS[output_format])
183
+
184
+
185
+ def stream_response(output_format, columns, rows, filename):
186
+ """A chunked ``Response`` for ``output_format`` in ``STREAM_FORMATTERS``, generated from ``rows`` as
187
+ they arrive rather than built up front - the point being that a result far larger than fits in memory
188
+ can still be exported, at the cost of the usual conveniences a fully-buffered response gets for free
189
+ (a reliable Content-Length, the ability to retry a failed write, an ETag for caching)."""
190
+ body = iter_stream_chunks(output_format, columns, rows)
191
+ response = Response(body, mimetype=STREAM_MIMETYPES[output_format])
192
+ response.headers['Content-Disposition'] = f'attachment; filename="{filename}.{output_format}"'
193
+ return response
Binary file
@@ -0,0 +1,75 @@
1
+ """One log line per event from the ``queryapigate`` logger, tagged with the request ID and the calling API key's
2
+ name that caused it (both ``-`` outside a request, e.g. at startup; key is also ``-`` when no key is
3
+ configured at all, and ``admin`` for ``QUERYAPIGATE_API_KEY`` - see ``apikeys.Permission``). Plain text by
4
+ default; ``QUERYAPIGATE_JSON_LOGS=1`` switches to one JSON object per line for log aggregators.
5
+
6
+ In JSON mode, any caller-supplied ``extra={...}`` on a ``log.info()``/``log.warning()`` call becomes its
7
+ own top-level JSON key, not just text folded into ``message`` - see ``_JsonFormatter``. ``engine.py`` and
8
+ ``app.py`` use this for fields a log aggregator would otherwise have to parse out of the message string
9
+ (``connection``, ``dialect``, ``status``, ``duration_ms``, ...), so a query or request can be filtered on
10
+ those directly rather than only by ``request_id``/``key``, which were already real fields before this.
11
+
12
+ ``configure()`` is called from ``create_app()`` - the one place every entry point (``queryapigate serve``,
13
+ gunicorn/WSGI, the test suite) goes through - so logging is set up the same way regardless of how the app
14
+ is run, instead of relying on the CLI's ``logging.basicConfig()``, which never runs under a WSGI server.
15
+ """
16
+ import json
17
+ import logging
18
+
19
+ from flask import g
20
+
21
+ from . import config
22
+
23
+ _PLAIN_FORMAT = '%(asctime)s %(levelname)s %(name)s [%(request_id)s key=%(key)s]: %(message)s'
24
+ # Every attribute a plain LogRecord carries with no `extra` at all - used to spot which attributes on a
25
+ # given record were added via `extra={...}` (see _JsonFormatter below), without hardcoding their names.
26
+ _BASE_RECORD_ATTRS = frozenset(vars(logging.makeLogRecord({}))) | {'message', 'asctime'}
27
+
28
+
29
+ class _RequestContextFilter(logging.Filter):
30
+ def filter(self, record):
31
+ try:
32
+ record.request_id = g.request_id
33
+ except RuntimeError: # outside a Flask request context (startup, CLI, background threads)
34
+ record.request_id = '-'
35
+ record.key = '-'
36
+ return True
37
+ permission = g.get('permission') # unset before the key is resolved, e.g. a CORS preflight
38
+ record.key = (permission.name if permission else None) or '-'
39
+ return True
40
+
41
+
42
+ class _JsonFormatter(logging.Formatter):
43
+ def format(self, record):
44
+ payload = {
45
+ 'time': self.formatTime(record, '%Y-%m-%dT%H:%M:%S'),
46
+ 'level': record.levelname,
47
+ 'logger': record.name,
48
+ 'request_id': getattr(record, 'request_id', '-'),
49
+ 'key': getattr(record, 'key', '-'),
50
+ 'message': record.getMessage(),
51
+ }
52
+ # request_id/key are already set above (from _RequestContextFilter, not a caller's `extra`); every
53
+ # other attribute a record doesn't get by default came from an explicit `extra={...}` at the call
54
+ # site, and becomes its own field here rather than staying folded into `message` above.
55
+ payload.update({k: v for k, v in vars(record).items()
56
+ if k not in _BASE_RECORD_ATTRS and k not in ('request_id', 'key')})
57
+ if record.exc_info:
58
+ payload['exception'] = self.formatException(record.exc_info)
59
+ return json.dumps(payload)
60
+
61
+
62
+ def configure(logger):
63
+ """Attach one handler to ``logger``, replacing any handler set by an earlier call of this function (safe to
64
+ call repeatedly - once per ``create_app()``, including in tests) but leaving any other handler alone, e.g.
65
+ one ``unittest.TestCase.assertLogs`` installs temporarily while a test is running."""
66
+ for existing in list(logger.handlers):
67
+ if getattr(existing, '_queryapigate_managed', False):
68
+ logger.removeHandler(existing)
69
+ handler = logging.StreamHandler()
70
+ handler._queryapigate_managed = True
71
+ handler.addFilter(_RequestContextFilter())
72
+ handler.setFormatter(_JsonFormatter() if config.json_logs() else logging.Formatter(_PLAIN_FORMAT))
73
+ logger.addHandler(handler)
74
+ logger.setLevel(logging.INFO)
75
+ logger.propagate = False
@@ -0,0 +1,215 @@
1
+ """In-process metrics, rendered as Prometheus text exposition format at ``/metrics``.
2
+
3
+ Counters and histograms live in this process's memory - correct for the image this project ships (gunicorn
4
+ with a single worker; see the Dockerfile, which explains why in a comment next to ``--workers 1``) but not
5
+ for a multi-process deployment, which would need a shared backing store instead (e.g. ``prometheus_client``
6
+ in its multiprocess mode) - nothing here does that.
7
+ """
8
+ import bisect
9
+ import threading
10
+
11
+ from . import pool
12
+
13
+ _BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0)
14
+
15
+ _lock = threading.Lock()
16
+ _request_counts: dict[tuple, int] = {} # (method, endpoint, status, key) -> int
17
+ _request_hist: dict[tuple, list] = {} # (method, endpoint) -> [count per bucket in _BUCKETS..., +Inf count]
18
+ _request_sum: dict[tuple, float] = {} # (method, endpoint) -> total seconds
19
+ _query_counts: dict[tuple, int] = {} # (connection, dialect, status, key) -> int
20
+ _query_hist: dict[tuple, list] = {} # (connection, dialect) -> [count per bucket...]
21
+ _query_sum: dict[tuple, float] = {} # (connection, dialect) -> total seconds
22
+ _stream_counts: dict[tuple, int] = {} # (connection, dialect, status, key) -> int
23
+ _row_counts: dict[tuple, int] = {} # (connection, dialect, key) -> total rows returned
24
+ _serialization_hist: dict[tuple, list] = {} # (format,) -> [count per bucket...]
25
+ _serialization_sum: dict[tuple, float] = {} # (format,) -> total seconds
26
+ _active_queries = 0 # queries currently executing (paged or mid-stream)
27
+ _rate_limit_rejections = 0
28
+
29
+
30
+ def _bucket_index(elapsed):
31
+ return bisect.bisect_left(_BUCKETS, elapsed)
32
+
33
+
34
+ def observe_request(method, endpoint, status, elapsed, key='-'):
35
+ """`key` is the calling API key's name ('admin' for QUERYAPIGATE_API_KEY, '-' when none is configured), kept
36
+ on the request/query counters (audit: who did what) but not the latency histograms, so the number of
37
+ distinct keys never multiplies the size of the bucketed output."""
38
+ with _lock:
39
+ count_key = (method, endpoint, status, key)
40
+ _request_counts[count_key] = _request_counts.get(count_key, 0) + 1
41
+ hkey = (method, endpoint)
42
+ counts = _request_hist.setdefault(hkey, [0] * (len(_BUCKETS) + 1))
43
+ counts[_bucket_index(elapsed)] += 1
44
+ _request_sum[hkey] = _request_sum.get(hkey, 0.0) + elapsed
45
+
46
+
47
+ def observe_query(connection_name, dialect, status, elapsed, key='-'):
48
+ with _lock:
49
+ count_key = (connection_name, dialect, status, key)
50
+ _query_counts[count_key] = _query_counts.get(count_key, 0) + 1
51
+ hkey = (connection_name, dialect)
52
+ counts = _query_hist.setdefault(hkey, [0] * (len(_BUCKETS) + 1))
53
+ counts[_bucket_index(elapsed)] += 1
54
+ _query_sum[hkey] = _query_sum.get(hkey, 0.0) + elapsed
55
+
56
+
57
+ def observe_stream(connection_name, dialect, status, key='-'):
58
+ """A separate counter from observe_query(): a streaming export can run for minutes, so mixing its
59
+ duration into the regular per-query latency histogram would make that histogram's percentiles
60
+ meaningless for the fast, page-at-a-time queries it actually describes. There is deliberately no
61
+ equivalent streaming latency histogram here for the same reason - just how many started and how they
62
+ ended."""
63
+ with _lock:
64
+ count_key = (connection_name, dialect, status, key)
65
+ _stream_counts[count_key] = _stream_counts.get(count_key, 0) + 1
66
+
67
+
68
+ def observe_rows(connection_name, dialect, key, count):
69
+ """Rows actually returned to the caller - the trimmed page for a paged query, or however many made it
70
+ out before a streaming export finished or failed partway through (see engine._drain()); not split by
71
+ status, since a partial streamed count is still data that left the server, not nothing."""
72
+ with _lock:
73
+ row_key = (connection_name, dialect, key)
74
+ _row_counts[row_key] = _row_counts.get(row_key, 0) + count
75
+
76
+
77
+ def observe_serialization(output_format, elapsed):
78
+ """How long formats.render() took to build the response body - JSON/CSV/TSV/XML/YAML/XLSX encoding,
79
+ after the query itself has already finished. Only ever called for a paged (non-streaming) response:
80
+ streaming formats it row by row, interleaved with network I/O, so there's no single "serialization
81
+ happened here" span to measure the way there is for a page built in memory up front."""
82
+ with _lock:
83
+ hkey = (output_format,)
84
+ counts = _serialization_hist.setdefault(hkey, [0] * (len(_BUCKETS) + 1))
85
+ counts[_bucket_index(elapsed)] += 1
86
+ _serialization_sum[hkey] = _serialization_sum.get(hkey, 0.0) + elapsed
87
+
88
+
89
+ def inc_active_query():
90
+ global _active_queries
91
+ with _lock:
92
+ _active_queries += 1
93
+
94
+
95
+ def dec_active_query():
96
+ """Pairs with inc_active_query(). For a paged query the whole span is one function call
97
+ (engine.execute_sql); for a streaming export the connection stays checked out for as long as the client
98
+ keeps reading, so the increment happens once in engine.stream_sql() but the decrement is deferred to
99
+ engine._drain() - whichever of the two actually turns out to be where the query stops being active."""
100
+ global _active_queries
101
+ with _lock:
102
+ _active_queries -= 1
103
+
104
+
105
+ def inc_rate_limit_rejection():
106
+ global _rate_limit_rejections
107
+ with _lock:
108
+ _rate_limit_rejections += 1
109
+
110
+
111
+ def summary_for_key(name):
112
+ """Live usage for one API key's name, aggregated from the same counters /metrics renders - queries run,
113
+ of those how many failed, and rows returned. No latency figure: the request/query duration histograms
114
+ are deliberately not split by key (see observe_request()'s docstring), so there is nothing to average
115
+ per key without changing that trade-off."""
116
+ with _lock:
117
+ queries = sum(count for (_, _, _, key), count in _query_counts.items() if key == name)
118
+ errors = sum(count for (_, _, status, key), count in _query_counts.items()
119
+ if key == name and status == 'error')
120
+ rows = sum(count for (_, _, key), count in _row_counts.items() if key == name)
121
+ return {'queries': queries, 'errors': errors, 'rows': rows}
122
+
123
+
124
+ def summary_for_connection(name):
125
+ """Live usage for one connection name, aggregated the same way as summary_for_key() - plus an average
126
+ query latency, which *is* available here: the duration histograms are keyed by (connection, dialect),
127
+ and a connection has exactly one dialect, so this is an unambiguous per-connection average."""
128
+ with _lock:
129
+ queries = sum(count for (conn, _, _, _), count in _query_counts.items() if conn == name)
130
+ errors = sum(count for (conn, _, status, _), count in _query_counts.items()
131
+ if conn == name and status == 'error')
132
+ rows = sum(count for (conn, _, _), count in _row_counts.items() if conn == name)
133
+ elapsed = sum(total for (conn, _), total in _query_sum.items() if conn == name)
134
+ sampled = sum(sum(counts) for (conn, _), counts in _query_hist.items() if conn == name)
135
+ avg_duration_ms = round(elapsed / sampled * 1000, 1) if sampled else None
136
+ return {'queries': queries, 'errors': errors, 'rows': rows, 'avg_duration_ms': avg_duration_ms}
137
+
138
+
139
+ def _escape(value):
140
+ return str(value).replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n')
141
+
142
+
143
+ def _labels(pairs):
144
+ return '{' + ','.join(f'{name}="{_escape(value)}"' for name, value in pairs) + '}'
145
+
146
+
147
+ def _render_counter(lines, name, help_text, label_names, counts):
148
+ lines.append(f'# HELP {name} {help_text}')
149
+ lines.append(f'# TYPE {name} counter')
150
+ for key in sorted(counts):
151
+ lines.append(f'{name}{_labels(zip(label_names, key))} {counts[key]}')
152
+
153
+
154
+ def _render_histogram(lines, name, help_text, label_names, hist, totals):
155
+ lines.append(f'# HELP {name} {help_text}')
156
+ lines.append(f'# TYPE {name} histogram')
157
+ for key in sorted(hist):
158
+ labels = list(zip(label_names, key))
159
+ cumulative = 0
160
+ for edge, count in zip(_BUCKETS, hist[key]):
161
+ cumulative += count
162
+ lines.append(f'{name}_bucket{_labels([*labels, ("le", edge)])} {cumulative}')
163
+ cumulative += hist[key][-1] # the +Inf bucket
164
+ lines.append(f'{name}_bucket{_labels([*labels, ("le", "+Inf")])} {cumulative}')
165
+ lines.append(f'{name}_sum{_labels(labels)} {totals.get(key, 0.0)}')
166
+ lines.append(f'{name}_count{_labels(labels)} {cumulative}')
167
+
168
+
169
+ def render():
170
+ """The current metrics as Prometheus text exposition format."""
171
+ with _lock:
172
+ request_counts = dict(_request_counts)
173
+ request_hist = {k: list(v) for k, v in _request_hist.items()}
174
+ request_sum = dict(_request_sum)
175
+ query_counts = dict(_query_counts)
176
+ query_hist = {k: list(v) for k, v in _query_hist.items()}
177
+ query_sum = dict(_query_sum)
178
+ stream_counts = dict(_stream_counts)
179
+ row_counts = dict(_row_counts)
180
+ serialization_hist = {k: list(v) for k, v in _serialization_hist.items()}
181
+ serialization_sum = dict(_serialization_sum)
182
+ active_queries = _active_queries
183
+ rejections = _rate_limit_rejections
184
+
185
+ lines = []
186
+ _render_counter(lines, 'queryapigate_requests_total', 'Total HTTP requests.',
187
+ ('method', 'endpoint', 'status', 'key'), request_counts)
188
+ _render_histogram(lines, 'queryapigate_request_duration_seconds', 'HTTP request latency in seconds.',
189
+ ('method', 'endpoint'), request_hist, request_sum)
190
+ _render_counter(lines, 'queryapigate_queries_total', 'Total SQL queries executed.',
191
+ ('connection', 'dialect', 'status', 'key'), query_counts)
192
+ _render_histogram(lines, 'queryapigate_query_duration_seconds', 'SQL query latency in seconds.',
193
+ ('connection', 'dialect'), query_hist, query_sum)
194
+ _render_counter(lines, 'queryapigate_stream_exports_total', 'Total streaming (stream=true) exports started.',
195
+ ('connection', 'dialect', 'status', 'key'), stream_counts)
196
+ _render_counter(lines, 'queryapigate_rows_returned_total', 'Total rows returned by SQL queries.',
197
+ ('connection', 'dialect', 'key'), row_counts)
198
+ _render_histogram(lines, 'queryapigate_serialization_duration_seconds',
199
+ 'Response body serialization latency in seconds, by output format (paged responses only).',
200
+ ('format',), serialization_hist, serialization_sum)
201
+
202
+ lines.append('# HELP queryapigate_active_queries SQL queries currently executing (paged or mid-stream).')
203
+ lines.append('# TYPE queryapigate_active_queries gauge')
204
+ lines.append(f'queryapigate_active_queries {active_queries}')
205
+
206
+ lines.append('# HELP queryapigate_pool_idle_connections Idle pooled database connections currently held.')
207
+ lines.append('# TYPE queryapigate_pool_idle_connections gauge')
208
+ shared = pool.get_pool()
209
+ lines.append(f'queryapigate_pool_idle_connections {shared.idle_count() if shared else 0}')
210
+
211
+ lines.append('# HELP queryapigate_rate_limit_rejections_total Requests rejected by the rate limiter.')
212
+ lines.append('# TYPE queryapigate_rate_limit_rejections_total counter')
213
+ lines.append(f'queryapigate_rate_limit_rejections_total {rejections}')
214
+
215
+ return '\n'.join(lines) + '\n'