diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 3fc83438..b7f2361f 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -117,7 +117,7 @@ jobs: --plugins-dir=plugins \ --branch=$GITHUB_SHA \ --version-note=$GITHUB_SHA \ - --extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb --root" \ + --extra-options="--setting template_debug 1 --crossdb --root" \ --install 'datasette-ephemeral-tables>=0.2.2' \ --service "datasette-latest$SUFFIX" \ --secret $LATEST_DATASETTE_SECRET diff --git a/datasette/app.py b/datasette/app.py index 37c4e882..8cee9b74 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -57,7 +57,6 @@ from .telemetry import ( ) from .telemetry_registry import HTTP_ROUTE, STARTUP from .tokens import TokenInvalid -from .tracer import AsgiTracer from .url_builder import Urls from .utils import ( SPATIALITE_FUNCTIONS, @@ -294,11 +293,6 @@ SETTINGS = ( False, "Allow display of template debug information with ?_context=1", ), - Setting( - "trace_debug", - False, - "Allow display of SQL trace debug information with ?_trace=1", - ), Setting("base_url", "/", "Datasette URLs should use this base path"), ) _HASH_URLS_REMOVED = "The hash_urls setting has been removed, try the datasette-hashed-urls plugin instead" @@ -2867,8 +2861,6 @@ class Datasette: self.close() asgi = CrossOriginProtectionMiddleware(DatasetteRouter(self, routes), self) - if self.setting("trace_debug"): - asgi = AsgiTracer(asgi) asgi = AsgiLifespan( asgi, on_startup=[self._startup_sequence], diff --git a/datasette/database.py b/datasette/database.py index 7114a691..efb8c56e 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -40,7 +40,6 @@ from .telemetry_registry import ( TRANSACTION, TRUNCATED, ) -from .tracer import trace from .utils import ( call_with_supported_arguments, detect_fts, @@ -284,24 +283,18 @@ class Database: cursor, return_all=return_all, returning_limit=returning_limit ) - # SIM117 wants these two context managers merged. They are kept nested - # deliberately: the hand-rolled tracer's wrapper is on its way out, and - # nesting makes removing it a single-line deletion. - with trace( # noqa: SIM117 - "sql", database=self.name, sql=sql.strip(), params=params - ): - with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span: - span.set_attribute(DB_SYSTEM, "sqlite") - span.set_attribute(DB_NAMESPACE, self.name) - span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql)) - operation_name = sql_operation_name(sql) - if operation_name: - span.set_attribute(DB_OPERATION_NAME, operation_name) - if params: - span.set_attribute(PARAM_COUNT, len(params)) - results = await self.execute_write_fn( - _inner, block=block, request=request, transaction=transaction - ) + with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span: + span.set_attribute(DB_SYSTEM, "sqlite") + span.set_attribute(DB_NAMESPACE, self.name) + span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql)) + operation_name = sql_operation_name(sql) + if operation_name: + span.set_attribute(DB_OPERATION_NAME, operation_name) + if params: + span.set_attribute(PARAM_COUNT, len(params)) + results = await self.execute_write_fn( + _inner, block=block, request=request, transaction=transaction + ) return results async def execute_write_script(self, sql, block=True, request=None): @@ -310,22 +303,18 @@ class Database: def _inner(conn): return conn.executescript(sql) - # Nested on purpose - see the note in execute_write(). - with trace( # noqa: SIM117 - "sql", database=self.name, sql=sql.strip(), executescript=True - ): - # No db.operation.name here, deliberately: executescript() runs - # several semicolon-separated statements, and semantic conventions - # say the attribute should not be extracted from query text that - # can hold more than one operation - see sql_operation_name(). - with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span: - span.set_attribute(DB_SYSTEM, "sqlite") - span.set_attribute(DB_NAMESPACE, self.name) - span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql)) - span.set_attribute(EXECUTESCRIPT, True) - results = await self.execute_write_fn( - _inner, block=block, transaction=False, request=request - ) + # No db.operation.name here, deliberately: executescript() runs + # several semicolon-separated statements, and semantic conventions + # say the attribute should not be extracted from query text that + # can hold more than one operation - see sql_operation_name(). + with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span: + span.set_attribute(DB_SYSTEM, "sqlite") + span.set_attribute(DB_NAMESPACE, self.name) + span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql)) + span.set_attribute(EXECUTESCRIPT, True) + results = await self.execute_write_fn( + _inner, block=block, transaction=False, request=request + ) return results async def execute_write_many(self, sql, params_seq, block=True, request=None): @@ -342,27 +331,22 @@ class Database: return conn.executemany(sql, count_params(params_seq)), count - # Nested on purpose - see the note in execute_write(). - with trace( - "sql", database=self.name, sql=sql.strip(), executemany=True - ) as kwargs: - with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span: - span.set_attribute(DB_SYSTEM, "sqlite") - span.set_attribute(DB_NAMESPACE, self.name) - span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql)) - span.set_attribute(EXECUTEMANY, True) - # A single statement run with many parameter sets, so unlike - # execute_write_script() there is exactly one operation to name. - operation_name = sql_operation_name(sql) - if operation_name: - span.set_attribute(DB_OPERATION_NAME, operation_name) - results, count = await self.execute_write_fn( - _inner, block=block, request=request - ) - # count is the number of parameter *sets* consumed by - # executemany(), not a row count - executemany returns no rows. - span.set_attribute(PARAM_SETS, count) - kwargs["count"] = count + with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span: + span.set_attribute(DB_SYSTEM, "sqlite") + span.set_attribute(DB_NAMESPACE, self.name) + span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql)) + span.set_attribute(EXECUTEMANY, True) + # A single statement run with many parameter sets, so unlike + # execute_write_script() there is exactly one operation to name. + operation_name = sql_operation_name(sql) + if operation_name: + span.set_attribute(DB_OPERATION_NAME, operation_name) + results, count = await self.execute_write_fn( + _inner, block=block, request=request + ) + # count is the number of parameter *sets* consumed by + # executemany(), not a row count - executemany returns no rows. + span.set_attribute(PARAM_SETS, count) return results async def execute_isolated_fn(self, fn): @@ -701,8 +685,7 @@ class Database: # # copy_context() is not selective: it also carries Datasette's own # ContextVars - _skip_permission_checks and _permission_check_cache - # (datasette/permissions.py), _in_datasette_client (app.py) and, - # until the hand-rolled tracer goes, trace_task_id (tracer.py) - + # (datasette/permissions.py) and _in_datasette_client (app.py) - # into worker threads, where they previously took their defaults. # That is safe, for two reasons. Nothing reads them on a worker # thread: the permission code that reads the first two is async and @@ -711,9 +694,9 @@ class Database: # cannot outlive the submit that carried it and reach the next task # on this shared pool - "skip permission checks" in particular can # never bleed from one request into another's query. Where a value - # would be read - a plugin calling datasette.in_client() or trace() - # from inside an execute_fn callable - seeing the submitting - # request's value is the more accurate answer, not a leak. + # would be read - a plugin calling datasette.in_client() from inside + # an execute_fn callable - seeing the submitting request's value is + # the more accurate answer, not a leak. ctx = contextvars.copy_context() future = self.ds.executor.submit(ctx.run, in_thread) self._pending_execute_futures.add(future) @@ -818,59 +801,53 @@ class Database: else: return Results(rows, False, cursor.description) - # SIM117 wants these two context managers merged. They are kept nested - # deliberately: the hand-rolled tracer's wrapper is on its way out, and - # nesting makes removing it a single-line deletion. - with trace( # noqa: SIM117 - "sql", database=self.name, sql=sql.strip(), params=params - ): - # Exception handling is explicit rather than left to the context - # manager's defaults, so that callers passing log_sql_errors=False - # can be honoured - see the comment on the generic handler below. - with tracer.start_as_current_span( - DB_QUERY, - kind=DB_QUERY.kind, - record_exception=False, - set_status_on_exception=False, - ) as span: - span.set_attribute(DB_SYSTEM, "sqlite") - span.set_attribute(DB_NAMESPACE, self.name) - span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql)) - span.set_attribute(TIME_LIMIT_MS, time_limit_ms) - operation_name = sql_operation_name(sql) - if operation_name: - span.set_attribute(DB_OPERATION_NAME, operation_name) - if table: - span.set_attribute(DB_COLLECTION_NAME, table) - if params: - span.set_attribute(PARAM_COUNT, len(params)) - try: - results = await self.execute_fn(sql_operation_in_thread) - except QueryInterrupted as e: - # datasette.interrupted is set either way - it is the - # signal worth having. Only the ERROR status is - # conditional; see the timeout_expected comment above. - span.set_attribute(INTERRUPTED, True) - if not timeout_expected: - span.set_status(Status(StatusCode.ERROR, str(e))) - span.record_exception(e) - raise - except Exception as e: - # log_sql_errors=False means the caller is probing and - # treats failure as an expected answer, not an error. - # Facet suggestion is the big one: it runs json_type() - # against every column precisely to find out which ones - # raise, so a table with N text columns would otherwise - # mark N queries per page as failed - burying real errors - # and setting off any alerting based on span status. - if log_sql_errors: - span.record_exception(e) - span.set_status(Status(StatusCode.ERROR, str(e))) - else: - span.set_attribute(SQL_ERROR_SUPPRESSED, True) - raise - span.set_attribute(TRUNCATED, results.truncated) - span.set_attribute(ROWS_RETURNED, len(results.rows)) + # Exception handling is explicit rather than left to the context + # manager's defaults, so that callers passing log_sql_errors=False + # can be honoured - see the comment on the generic handler below. + with tracer.start_as_current_span( + DB_QUERY, + kind=DB_QUERY.kind, + record_exception=False, + set_status_on_exception=False, + ) as span: + span.set_attribute(DB_SYSTEM, "sqlite") + span.set_attribute(DB_NAMESPACE, self.name) + span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql)) + span.set_attribute(TIME_LIMIT_MS, time_limit_ms) + operation_name = sql_operation_name(sql) + if operation_name: + span.set_attribute(DB_OPERATION_NAME, operation_name) + if table: + span.set_attribute(DB_COLLECTION_NAME, table) + if params: + span.set_attribute(PARAM_COUNT, len(params)) + try: + results = await self.execute_fn(sql_operation_in_thread) + except QueryInterrupted as e: + # datasette.interrupted is set either way - it is the + # signal worth having. Only the ERROR status is + # conditional; see the timeout_expected comment above. + span.set_attribute(INTERRUPTED, True) + if not timeout_expected: + span.set_status(Status(StatusCode.ERROR, str(e))) + span.record_exception(e) + raise + except Exception as e: + # log_sql_errors=False means the caller is probing and + # treats failure as an expected answer, not an error. + # Facet suggestion is the big one: it runs json_type() + # against every column precisely to find out which ones + # raise, so a table with N text columns would otherwise + # mark N queries per page as failed - burying real errors + # and setting off any alerting based on span status. + if log_sql_errors: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR, str(e))) + else: + span.set_attribute(SQL_ERROR_SUPPRESSED, True) + raise + span.set_attribute(TRUNCATED, results.truncated) + span.set_attribute(ROWS_RETURNED, len(results.rows)) return results @property diff --git a/datasette/tracer.py b/datasette/tracer.py deleted file mode 100644 index 1fbda6f9..00000000 --- a/datasette/tracer.py +++ /dev/null @@ -1,156 +0,0 @@ -import asyncio -import json -import time -import traceback -from contextlib import contextmanager -from contextvars import ContextVar - -from markupsafe import escape - -tracers = {} - -TRACE_RESERVED_KEYS = {"type", "start", "end", "duration_ms", "traceback"} - -trace_task_id = ContextVar("trace_task_id", default=None) - - -def get_task_id(): - current = trace_task_id.get(None) - if current is not None: - return current - try: - loop = asyncio.get_event_loop() - except RuntimeError: - return None - return id(asyncio.current_task(loop=loop)) - - -@contextmanager -def trace_child_tasks(): - token = trace_task_id.set(get_task_id()) - try: - yield - finally: - trace_task_id.reset(token) - - -@contextmanager -def trace(trace_type, **kwargs): - assert not TRACE_RESERVED_KEYS.intersection( - kwargs.keys() - ), f".trace() keyword parameters cannot include {TRACE_RESERVED_KEYS}" - task_id = get_task_id() - if task_id is None: - yield kwargs - return - tracer = tracers.get(task_id) - if tracer is None: - yield kwargs - return - start = time.perf_counter() - captured_error = None - try: - yield kwargs - except Exception as ex: - captured_error = ex - raise - finally: - end = time.perf_counter() - trace_info = { - "type": trace_type, - "start": start, - "end": end, - "duration_ms": (end - start) * 1000, - "traceback": traceback.format_list(traceback.extract_stack(limit=6)[:-3]), - "error": str(captured_error) if captured_error else None, - } - trace_info.update(kwargs) - tracer.append(trace_info) - - -@contextmanager -def capture_traces(tracer): - # tracer is a list - task_id = get_task_id() - if task_id is None: - yield - return - tracers[task_id] = tracer - yield - del tracers[task_id] - - -class AsgiTracer: - # If the body is larger than this we don't attempt to append the trace - max_body_bytes = 1024 * 256 # 256 KB - - def __init__(self, app): - self.app = app - - async def __call__(self, scope, receive, send): - if b"_trace=1" not in scope.get("query_string", b"").split(b"&"): - await self.app(scope, receive, send) - return - trace_start = time.perf_counter() - traces = [] - - accumulated_body = b"" - size_limit_exceeded = False - response_headers = [] - - async def wrapped_send(message): - nonlocal accumulated_body, size_limit_exceeded, response_headers - - if message["type"] == "http.response.start": - response_headers = message["headers"] - await send(message) - return - - if message["type"] != "http.response.body" or size_limit_exceeded: - await send(message) - return - - # Accumulate body until the end or until size is exceeded - accumulated_body += message["body"] - if len(accumulated_body) > self.max_body_bytes: - # Send what we have accumulated so far - await send( - { - "type": "http.response.body", - "body": accumulated_body, - "more_body": bool(message.get("more_body")), - } - ) - size_limit_exceeded = True - return - - if not message.get("more_body"): - # We have all the body - modify it and send the result - # TODO: What to do about Content-Type or other cases? - trace_info = { - "request_duration_ms": 1000 * (time.perf_counter() - trace_start), - "sum_trace_duration_ms": sum(t["duration_ms"] for t in traces), - "num_traces": len(traces), - "traces": traces, - } - content_type = next( - ( - v.decode("utf8") - for k, v in response_headers - if k.lower() == b"content-type" - ), - "", - ) - if "text/html" in content_type and b"
" in accumulated_body: - extra = escape(json.dumps(trace_info, indent=2)) - extra_html = f"
{extra}".encode() - accumulated_body = accumulated_body.replace(b"", extra_html) - elif "json" in content_type and accumulated_body.startswith(b"{"): - data = json.loads(accumulated_body.decode("utf8")) - if "_trace" not in data: - data["_trace"] = trace_info - accumulated_body = json.dumps(data).encode("utf8") - await send({"type": "http.response.body", "body": accumulated_body}) - - with capture_traces(traces): - await self.app(scope, receive, wrapped_send) diff --git a/datasette/views/base.py b/datasette/views/base.py index 48108cec..d8340fc4 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -3,7 +3,6 @@ import hashlib import sys from datasette.utils import ( - EscapeHtmlWriter, InvalidSql, LimitedWriter, add_cors_headers, @@ -225,26 +224,11 @@ async def stream_csv(datasette, fetch_data, request, database): headings.append(f"{column}_label") content_type = "text/plain; charset=utf-8" - preamble = "" - postamble = "" - - trace = request.args.get("_trace") - if trace: - content_type = "text/html; charset=utf-8" - preamble = ( - "
" - '