节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
7.87 KiB
Stardust
"""DB-API 数据库自动埋点

通过代理 Connection / Cursor 实现,无需修改业务代码:

- sqlite3:标准库,自动安装
- pymysql、psycopg2:已安装时自动导入并安装,缺失时静默跳过

Span 名称 ``db:{system}:{action}:{tables}``,Tag 为 SQL 文本。
驱动异常会原样抛出,返回值保持不变。
"""

import importlib
import re
import threading
from typing import Any

from . import get_tracer, is_suppressed

_lock = threading.RLock()
_originals = {}

_TARGETS = (
    ("sqlite3", "sqlite"),
    ("pymysql", "mysql"),
    ("psycopg2", "postgresql"),
)

_TABLE_PATTERNS = (
    re.compile(r"\bfrom\s+([\w\.\"`\[\]]+)", re.IGNORECASE),
    re.compile(r"\bjoin\s+([\w\.\"`\[\]]+)", re.IGNORECASE),
    re.compile(r"\binsert\s+into\s+([\w\.\"`\[\]]+)", re.IGNORECASE),
    re.compile(r"\bupdate\s+([\w\.\"`\[\]]+)", re.IGNORECASE),
    re.compile(r"\bdelete\s+from\s+([\w\.\"`\[\]]+)", re.IGNORECASE),
    re.compile(r"\bdrop\s+table\s+(?:if\s+exists\s+)?([\w\.\"`\[\]]+)", re.IGNORECASE),
    re.compile(r"\btruncate\s+table\s+([\w\.\"`\[\]]+)", re.IGNORECASE),
    re.compile(r"\bcreate\s+table\s+(?:if\s+not\s+exists\s+)?([\w\.\"`\[\]]+)", re.IGNORECASE),
    re.compile(r"\balter\s+table\s+([\w\.\"`\[\]]+)", re.IGNORECASE),
)
_ACTION_PATTERN = re.compile(r"^\s*(?:/\*.*?\*/\s*)*([a-zA-Z]+)", re.DOTALL)
_DATE_SUFFIX_PATTERN = re.compile(r"_\d{4}(?:\d{2}(?:\d{2})?)?$")

_ACTIONS = {
    "select",
    "insert",
    "update",
    "delete",
    "replace",
    "merge",
    "create",
    "alter",
    "drop",
    "truncate",
    "call",
    "exec",
    "execute",
    "with",
}


def patch() -> None:
    """安装数据库埋点(幂等,缺失的驱动静默跳过)"""
    with _lock:
        for module_name, system in _TARGETS:
            try:
                module = importlib.import_module(module_name)
                original = getattr(module, "connect", None)
                if original is None or getattr(
                    original, "__stardust_instrumented__", False
                ):
                    continue

                module.connect = _make_connect(original, system)
                _originals[module_name] = (module, original)
            except Exception:
                continue


def unpatch() -> None:
    """卸载数据库埋点,恢复原始 connect"""
    with _lock:
        for module_name, (module, original) in list(_originals.items()):
            try:
                if getattr(getattr(module, "connect", None), "__stardust_instrumented__", False):
                    module.connect = original
            except Exception:
                pass
        _originals.clear()


def _make_connect(original: Any, system: str) -> Any:
    def connect(*args, **kwargs):
        return _ConnectionProxy(original(*args, **kwargs), system)

    connect.__stardust_instrumented__ = True
    connect.__wrapped__ = original
    return connect


def _sql_text(sql: Any) -> str:
    if isinstance(sql, str):
        return sql
    try:
        return str(sql)
    except Exception:
        return ""


def _action(sql: str) -> str:
    match = _ACTION_PATTERN.match(sql or "")
    if not match:
        return "exec"
    word = match.group(1).lower()
    return word if word in _ACTIONS else "exec"


def _clean_table(name: str) -> str:
    table = name.strip().strip("\"'`[]")
    if "." in table:
        table = table.split(".")[-1]
    table = table.strip("\"'`[]")
    table = _DATE_SUFFIX_PATTERN.sub("", table)
    return table


def _tables(sql: str) -> str:
    found = []
    for pattern in _TABLE_PATTERNS:
        for match in pattern.finditer(sql or ""):
            table = _clean_table(match.group(1))
            if table and table.lower() not in ("select", "where", "set", "values"):
                if table not in found:
                    found.append(table)
    return ",".join(found)


def _span_name(system: str, sql: Any) -> str:
    text = _sql_text(sql)
    tables = _tables(text)
    action = _action(text)
    if tables:
        return "db:%s:%s:%s" % (system, action, tables)
    return "db:%s:%s" % (system, action)


_OWN_ATTRS = frozenset(("_connection", "_cursor", "_tracer", "_system"))


class _CursorProxy:
    """DB-API Cursor 代理"""

    def __init__(self, cursor: Any, system: str):
        object.__setattr__(self, "_cursor", cursor)
        object.__setattr__(self, "_system", system)

    def execute(self, sql, parameters=None):
        if parameters is None:
            result = self._trace(sql, lambda: self._cursor.execute(sql))
        else:
            result = self._trace(sql, lambda: self._cursor.execute(sql, parameters))
        return self if result is self._cursor else result

    def executemany(self, sql, seq_of_parameters):
        result = self._trace(
            sql, lambda: self._cursor.executemany(sql, seq_of_parameters)
        )
        return self if result is self._cursor else result

    def _trace(self, sql, call):
        tracer = get_tracer()
        if tracer is None or is_suppressed():
            return call()
        try:
            span = tracer.new_span(_span_name(self._system, sql))
        except Exception:
            return call()
        span.tag = _sql_text(sql)
        with span:
            return call()

    def __iter__(self):
        return iter(self._cursor)

    def __enter__(self):
        enter = getattr(self._cursor, "__enter__", None)
        if enter is not None:
            enter()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        exit_method = getattr(self._cursor, "__exit__", None)
        if exit_method is not None:
            return exit_method(exc_type, exc_val, exc_tb)
        return False

    def __getattr__(self, name):
        return getattr(object.__getattribute__(self, "_cursor"), name)

    def __setattr__(self, name, value):
        if name in _OWN_ATTRS:
            object.__setattr__(self, name, value)
        else:
            setattr(self._cursor, name, value)


class _ConnectionProxy:
    """DB-API Connection 代理"""

    def __init__(self, connection: Any, system: str):
        object.__setattr__(self, "_connection", connection)
        object.__setattr__(self, "_system", system)

    def cursor(self, *args, **kwargs):
        return _CursorProxy(self._connection.cursor(*args, **kwargs), self._system)

    def execute(self, sql, parameters=None):
        def call():
            if parameters is None:
                return self._connection.execute(sql)
            return self._connection.execute(sql, parameters)

        return self._wrap_result(self._trace(sql, call))

    def executemany(self, sql, seq_of_parameters):
        def call():
            return self._connection.executemany(sql, seq_of_parameters)

        return self._wrap_result(self._trace(sql, call))

    def _trace(self, sql, call):
        tracer = get_tracer()
        if tracer is None or is_suppressed():
            return call()
        try:
            span = tracer.new_span(_span_name(self._system, sql))
        except Exception:
            return call()
        span.tag = _sql_text(sql)
        with span:
            return call()

    def _wrap_result(self, result):
        if result is not None and hasattr(result, "fetchone"):
            return _CursorProxy(result, self._system)
        return result

    def __enter__(self):
        enter = getattr(self._connection, "__enter__", None)
        if enter is not None:
            enter()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        exit_method = getattr(self._connection, "__exit__", None)
        if exit_method is not None:
            return exit_method(exc_type, exc_val, exc_tb)
        return False

    def __getattr__(self, name):
        return getattr(object.__getattribute__(self, "_connection"), name)

    def __setattr__(self, name, value):
        if name in _OWN_ATTRS:
            object.__setattr__(self, name, value)
        else:
            setattr(self._connection, name, value)