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/__init__.py +6 -0
- queryapigate/__main__.py +3 -0
- queryapigate/apikeys.py +535 -0
- queryapigate/app.py +844 -0
- queryapigate/cache.py +61 -0
- queryapigate/cli.py +168 -0
- queryapigate/config.py +268 -0
- queryapigate/cors.py +51 -0
- queryapigate/engine.py +166 -0
- queryapigate/errors.py +8 -0
- queryapigate/formats.py +193 -0
- queryapigate/lib/h2-2.2.224.jar +0 -0
- queryapigate/logging_setup.py +75 -0
- queryapigate/metrics.py +215 -0
- queryapigate/openapi.py +410 -0
- queryapigate/params.py +275 -0
- queryapigate/pool.py +146 -0
- queryapigate/ratelimit.py +88 -0
- queryapigate/runners.py +795 -0
- queryapigate/schema.py +99 -0
- queryapigate/sqltools.py +184 -0
- queryapigate/store.py +433 -0
- queryapigate/ui.py +2371 -0
- queryapigate-0.5.0.dist-info/METADATA +708 -0
- queryapigate-0.5.0.dist-info/RECORD +29 -0
- queryapigate-0.5.0.dist-info/WHEEL +5 -0
- queryapigate-0.5.0.dist-info/entry_points.txt +2 -0
- queryapigate-0.5.0.dist-info/licenses/LICENSE +21 -0
- queryapigate-0.5.0.dist-info/top_level.txt +1 -0
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
queryapigate/formats.py
ADDED
|
@@ -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
|
queryapigate/metrics.py
ADDED
|
@@ -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'
|