diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f1c9e5a6..5431dfa92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Release Notes +## [v0.49.0] (2024-08-05) + +* Add `logfire.instrument_mysql()` by @aditkumar72 in https://github.com/pydantic/logfire/pull/341 +* Set OTEL status description when logging exceptions by @alexmojaki in https://github.com/pydantic/logfire/pull/348 +* Switch UpDownCounters to cumulative aggregation temporality by @alexmojaki in https://github.com/pydantic/logfire/pull/347 +* Log more info about internal errors by @alexmojaki in https://github.com/pydantic/logfire/pull/346 + ## [v0.48.1] (2024-07-29) * Handle newer opentelemetry versions by @alexmojaki in https://github.com/pydantic/logfire/pull/337 @@ -228,6 +235,7 @@ First release from new repo! * Ensure `logfire.testing` doesn't depend on pydantic and eval_type_backport by @alexmojaki in https://github.com/pydantic/logfire/pull/40 * Allow using pydantic plugin with models defined before calling logfire.configure by @alexmojaki in https://github.com/pydantic/logfire/pull/36 +[v0.49.0]: https://github.com/pydantic/logfire/compare/v0.48.1...v0.49.0 [v0.48.1]: https://github.com/pydantic/logfire/compare/v0.48.0...v0.48.1 [v0.48.0]: https://github.com/pydantic/logfire/compare/v0.47.0...v0.48.0 [v0.47.0]: https://github.com/pydantic/logfire/compare/v0.46.1...v0.47.0 diff --git a/docs/guides/onboarding_checklist/add_manual_tracing.md b/docs/guides/onboarding_checklist/add_manual_tracing.md index f5e502d21..bc9bf3949 100644 --- a/docs/guides/onboarding_checklist/add_manual_tracing.md +++ b/docs/guides/onboarding_checklist/add_manual_tracing.md @@ -145,8 +145,8 @@ Contrary to the previous section, this _will_ work well in Python 3.11+ because - The feature is enabled by default in Python 3.11+. You can disable it with [`logfire.configure(inspect_arguments=False)`][logfire.configure(inspect_arguments)]. You can also enable it in Python 3.9 and 3.10, but it's more likely to not work correctly. - Inspecting arguments is expected to always work under normal circumstances. The main caveat is that the source code must be available, so e.g. deploying only `.pyc` files will cause it to fail. -- If inspecting arguments fails, you will get a warning, and the f-string argument will be treated as a normal string. This means you will get high-cardinality span names such as `'Hello Alice'` and no `name` attribute, but the information won't be completely lost. -- If inspecting arguments is enabled, then arguments will be inspected regardless of whether f-strings are being used. So if you write `logfire.info('Hello {name}', name=name)` and inspecting arguments fails, then you will still get a warning and `'Hello {name}'` will be used as the message rather than formatting it. +- If inspecting arguments fails, you will get a warning, and the f-string argument will be used as a formatting template. This means you will get high-cardinality span names such as `'Hello Alice'` and no `name` attribute, but the information won't be completely lost. +- If inspecting arguments is enabled, then arguments will be inspected regardless of whether f-strings are being used. So if you write `logfire.info('Hello {name}', name=name)` and inspecting arguments fails, then you will still get a warning. - The values inside f-strings are evaluated and formatted by Logfire a second time. This means you should avoid code like `logfire.info(f'Hello {get_username()}')` if `get_username()` (or the string conversion of whatever it returns) is expensive or has side effects. - The first argument must be an actual f-string. `logfire.info(f'Hello {name}')` will work, but `message = f'Hello {name}'; logfire.info(message)` will not, nor will `logfire.info('Hello ' + name)`. - Inspecting arguments is cached so that the performance overhead of repeatedly inspecting the same f-string is minimal. However, there is a non-negligible overhead of parsing a large source file the first time arguments need to be inspected inside it. Either way, avoiding this overhead requires disabling inspecting arguments entirely, not merely avoiding f-strings. diff --git a/docs/integrations/index.md b/docs/integrations/index.md index 80bc38d75..0f5f4a82d 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -25,6 +25,7 @@ Below you can see more details on how to use Logfire with some of the most popul | [Asyncpg](asyncpg.md) | Databases | | [Psycopg](psycopg.md) | Databases | | [PyMongo](pymongo.md) | Databases | +| [MySQL](mysql.md) | Databases | | [Redis](redis.md) | Databases | | [Celery](celery.md) | Task Queue | | [System Metrics](system_metrics.md) | System Metrics | diff --git a/docs/integrations/mysql.md b/docs/integrations/mysql.md new file mode 100644 index 000000000..c6fdd7b12 --- /dev/null +++ b/docs/integrations/mysql.md @@ -0,0 +1,86 @@ +# MySQL + +The [`logfire.instrument_mysql()`][logfire.Logfire.instrument_mysql] method can be used to instrument the [MySQL Connector/Python][mysql-connector] database driver with **Logfire**, creating a span for every query. + +## Installation + +Install `logfire` with the `mysql` extra: + +{{ install_logfire(extras=['mysql']) }} + +## Usage + +Let's setup a MySQL database using Docker and run a Python script that connects to the database using MySQL connector to +demonstrate how to use **Logfire** with MySQL. + +### Setup a MySQL Database Using Docker + +First, we need to initialize a MySQL database. This can be easily done using Docker with the following command: + +```bash +docker run --name mysql \ + -e MYSQL_ROOT_PASSWORD=secret \ + -e MYSQL_DATABASE=database \ + -e MYSQL_USER=user \ + -e MYSQL_PASSWORD=secret \ + -p 3306:3306 -d mysql +``` + +This command accomplishes the following: + +- `--name mysql`: gives the container a name of "mysql". +- `-e MYSQL_ROOT_PASSWORD=secret` sets the root password to "secret". +- `-e MYSQL_DATABASE=database` creates a new database named "database". +- `-e MYSQL_USER=user` creates a new user named "user". +- `-e MYSQL_PASSWORD=secret` sets the password for the new user to "secret". +- `-p 3306:3306` maps port 3306 inside Docker as port 3306 on the host machine. +- `-d mysql` runs the container in the background and prints the container ID. The image is "mysql". + +### Run the Python script + +The following Python script connects to the MySQL database and executes some SQL queries: + +```py +import logfire +import mysql.connector + +logfire.configure() + +# To instrument the whole module: +logfire.instrument_mysql() + +connection = mysql.connector.connect( + host="localhost", + user="user", + password="secret", + database="database", + port=3306, + use_pure=True, +) + +# Or instrument just the connection: +# connection = logfire.instrument_mysql(connection) + +with logfire.span('Create table and insert data'), connection.cursor() as cursor: + cursor.execute( + 'CREATE TABLE IF NOT EXISTS test (id INT AUTO_INCREMENT PRIMARY KEY, num integer, data varchar(255));' + ) + + # Insert some data + cursor.execute('INSERT INTO test (num, data) VALUES (%s, %s)', (100, 'abc')) + cursor.execute('INSERT INTO test (num, data) VALUES (%s, %s)', (200, 'def')) + + # Query the data + cursor.execute('SELECT * FROM test') + results = cursor.fetchall() # Fetch all rows + for row in results: + print(row) # Print each row +``` + +[`logfire.instrument_mysql()`][logfire.Logfire.instrument_mysql] uses the +**OpenTelemetry MySQL Instrumentation** package, +which you can find more information about [here][opentelemetry-mysql]. + +[opentelemetry-mysql]: https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/mysql/mysql.html +[mysql]: https://www.mysql.com/ +[mysql-connector]: https://dev.mysql.com/doc/connector-python/en/ diff --git a/logfire-api/logfire_api/__init__.py b/logfire-api/logfire_api/__init__.py index 463595b36..882c5adb9 100644 --- a/logfire-api/logfire_api/__init__.py +++ b/logfire-api/logfire_api/__init__.py @@ -154,6 +154,7 @@ def shutdown(self, *args, **kwargs) -> None: ... instrument_sqlalchemy = DEFAULT_LOGFIRE_INSTANCE.instrument_sqlalchemy instrument_redis = DEFAULT_LOGFIRE_INSTANCE.instrument_redis instrument_pymongo = DEFAULT_LOGFIRE_INSTANCE.instrument_pymongo + instrument_mysql = DEFAULT_LOGFIRE_INSTANCE.instrument_mysql shutdown = DEFAULT_LOGFIRE_INSTANCE.shutdown def no_auto_trace(x): diff --git a/logfire-api/logfire_api/__init__.pyi b/logfire-api/logfire_api/__init__.pyi index d3fa6c56b..1c8fb5ecd 100644 --- a/logfire-api/logfire_api/__init__.pyi +++ b/logfire-api/logfire_api/__init__.pyi @@ -51,6 +51,7 @@ __all__ = [ 'instrument_sqlalchemy', 'instrument_redis', 'instrument_pymongo', + 'instrument_mysql', 'AutoTraceModule', 'with_tags', 'with_settings', @@ -88,6 +89,7 @@ instrument_aiohttp_client = DEFAULT_LOGFIRE_INSTANCE.instrument_aiohttp_client instrument_sqlalchemy = DEFAULT_LOGFIRE_INSTANCE.instrument_sqlalchemy instrument_redis = DEFAULT_LOGFIRE_INSTANCE.instrument_redis instrument_pymongo = DEFAULT_LOGFIRE_INSTANCE.instrument_pymongo +instrument_mysql = DEFAULT_LOGFIRE_INSTANCE.instrument_mysql shutdown = DEFAULT_LOGFIRE_INSTANCE.shutdown with_tags = DEFAULT_LOGFIRE_INSTANCE.with_tags with_settings = DEFAULT_LOGFIRE_INSTANCE.with_settings diff --git a/logfire-api/logfire_api/_internal/integrations/mysql.pyi b/logfire-api/logfire_api/_internal/integrations/mysql.pyi new file mode 100644 index 000000000..ae2600d42 --- /dev/null +++ b/logfire-api/logfire_api/_internal/integrations/mysql.pyi @@ -0,0 +1,23 @@ +from mysql.connector.abstracts import MySQLConnectionAbstract +from mysql.connector.pooling import PooledMySQLConnection +from typing_extensions import TypeVar, TypedDict, Unpack + +MySQLConnection = TypeVar('MySQLConnection', bound=PooledMySQLConnection | MySQLConnectionAbstract | None) + +class MySQLInstrumentKwargs(TypedDict, total=False): + skip_dep_check: bool + +def instrument_mysql(conn: MySQLConnection = None, **kwargs: Unpack[MySQLInstrumentKwargs]) -> MySQLConnection: + """Instrument the `mysql` module or a specific MySQL connection so that spans are automatically created for each operation. + + This function uses the OpenTelemetry MySQL Instrumentation library to instrument either the entire `mysql` module or a specific MySQL connection. + + Args: + conn: The MySQL connection to instrument. If None, the entire `mysql` module is instrumented. + **kwargs: Additional keyword arguments to pass to the OpenTelemetry `instrument` methods. + + Returns: + If a connection is provided, returns the instrumented connection. If no connection is provided, returns None. + + See the `Logfire.instrument_mysql` method for details. + """ diff --git a/logfire-api/logfire_api/_internal/main.pyi b/logfire-api/logfire_api/_internal/main.pyi index 6b8447a5c..d58741f90 100644 --- a/logfire-api/logfire_api/_internal/main.pyi +++ b/logfire-api/logfire_api/_internal/main.pyi @@ -6,13 +6,14 @@ from . import async_ as async_ from ..version import VERSION as VERSION from .auto_trace import AutoTraceModule as AutoTraceModule, install_auto_tracing as install_auto_tracing from .config import GLOBAL_CONFIG as GLOBAL_CONFIG, LogfireConfig as LogfireConfig -from .constants import ATTRIBUTES_JSON_SCHEMA_KEY as ATTRIBUTES_JSON_SCHEMA_KEY, ATTRIBUTES_MESSAGE_KEY as ATTRIBUTES_MESSAGE_KEY, ATTRIBUTES_MESSAGE_TEMPLATE_KEY as ATTRIBUTES_MESSAGE_TEMPLATE_KEY, ATTRIBUTES_SAMPLE_RATE_KEY as ATTRIBUTES_SAMPLE_RATE_KEY, ATTRIBUTES_SPAN_TYPE_KEY as ATTRIBUTES_SPAN_TYPE_KEY, ATTRIBUTES_TAGS_KEY as ATTRIBUTES_TAGS_KEY, ATTRIBUTES_VALIDATION_ERROR_KEY as ATTRIBUTES_VALIDATION_ERROR_KEY, DISABLE_CONSOLE_KEY as DISABLE_CONSOLE_KEY, LevelName as LevelName, NULL_ARGS_KEY as NULL_ARGS_KEY, OTLP_MAX_INT_SIZE as OTLP_MAX_INT_SIZE, log_level_attributes as log_level_attributes +from .constants import ATTRIBUTES_JSON_SCHEMA_KEY as ATTRIBUTES_JSON_SCHEMA_KEY, ATTRIBUTES_LOG_LEVEL_NUM_KEY as ATTRIBUTES_LOG_LEVEL_NUM_KEY, ATTRIBUTES_MESSAGE_KEY as ATTRIBUTES_MESSAGE_KEY, ATTRIBUTES_MESSAGE_TEMPLATE_KEY as ATTRIBUTES_MESSAGE_TEMPLATE_KEY, ATTRIBUTES_SAMPLE_RATE_KEY as ATTRIBUTES_SAMPLE_RATE_KEY, ATTRIBUTES_SPAN_TYPE_KEY as ATTRIBUTES_SPAN_TYPE_KEY, ATTRIBUTES_TAGS_KEY as ATTRIBUTES_TAGS_KEY, ATTRIBUTES_VALIDATION_ERROR_KEY as ATTRIBUTES_VALIDATION_ERROR_KEY, DISABLE_CONSOLE_KEY as DISABLE_CONSOLE_KEY, LEVEL_NUMBERS as LEVEL_NUMBERS, LevelName as LevelName, NULL_ARGS_KEY as NULL_ARGS_KEY, OTLP_MAX_INT_SIZE as OTLP_MAX_INT_SIZE, log_level_attributes as log_level_attributes from .formatter import logfire_format as logfire_format, logfire_format_with_magic as logfire_format_with_magic from .instrument import LogfireArgs as LogfireArgs, instrument as instrument from .integrations.asyncpg import AsyncPGInstrumentKwargs as AsyncPGInstrumentKwargs from .integrations.celery import CeleryInstrumentKwargs as CeleryInstrumentKwargs from .integrations.flask import FlaskInstrumentKwargs as FlaskInstrumentKwargs from .integrations.httpx import HTTPXInstrumentKwargs as HTTPXInstrumentKwargs +from .integrations.mysql import MySQLConnection as MySQLConnection, MySQLInstrumentKwargs as MySQLInstrumentKwargs from .integrations.psycopg import PsycopgInstrumentKwargs as PsycopgInstrumentKwargs from .integrations.pymongo import PymongoInstrumentKwargs as PymongoInstrumentKwargs from .integrations.redis import RedisInstrumentKwargs as RedisInstrumentKwargs @@ -23,7 +24,7 @@ from .json_schema import JsonSchemaProperties as JsonSchemaProperties, attribute from .metrics import ProxyMeterProvider as ProxyMeterProvider from .stack_info import get_user_stack_info as get_user_stack_info from .tracer import ProxyTracerProvider as ProxyTracerProvider -from .utils import handle_internal_errors as handle_internal_errors, log_internal_error as log_internal_error, uniquify_sequence as uniquify_sequence +from .utils import SysExcInfo as SysExcInfo, handle_internal_errors as handle_internal_errors, log_internal_error as log_internal_error, uniquify_sequence as uniquify_sequence from django.http import HttpRequest as HttpRequest, HttpResponse as HttpResponse from fastapi import FastAPI from flask.app import Flask @@ -34,7 +35,6 @@ from opentelemetry.util import types as otel_types from starlette.applications import Starlette from starlette.requests import Request as Request from starlette.websockets import WebSocket as WebSocket -from types import TracebackType as TracebackType from typing import Any, Callable, ContextManager, Iterable, Literal, Sequence, TypeVar from typing_extensions import LiteralString, Unpack @@ -624,6 +624,21 @@ class Logfire: Uses the [OpenTelemetry Redis Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/redis/redis.html) library, specifically `RedisInstrumentor().instrument()`, to which it passes `**kwargs`. + """ + def instrument_mysql(self, conn: MySQLConnection = None, **kwargs: Unpack[MySQLInstrumentKwargs]) -> MySQLConnection: + """Instrument the `mysql` module or a specific MySQL connection so that spans are automatically created for each operation. + + Uses the + [OpenTelemetry MySQL Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/mysql/mysql.html) + library. + + Args: + conn: The `mysql` connection to instrument, or `None` to instrument all connections. + **kwargs: Additional keyword arguments to pass to the OpenTelemetry `instrument` methods. + + Returns: + If a connection is provided, returns the instrumented connection. If no connection is provided, returns None. + """ def metric_counter(self, name: str, *, unit: str = '', description: str = '') -> Counter: """Create a counter metric. diff --git a/logfire-api/logfire_api/_internal/stack_info.pyi b/logfire-api/logfire_api/_internal/stack_info.pyi index b6f3d5757..f41cecfa3 100644 --- a/logfire-api/logfire_api/_internal/stack_info.pyi +++ b/logfire-api/logfire_api/_internal/stack_info.pyi @@ -7,7 +7,7 @@ STACK_INFO_KEYS: Incomplete SITE_PACKAGES_DIR: Incomplete PYTHON_LIB_DIR: Incomplete LOGFIRE_DIR: Incomplete -PREFIXES: Incomplete +NON_USER_CODE_PREFIXES: Incomplete def get_filepath_attribute(file: str) -> StackInfo: ... def get_code_object_info(code: CodeType) -> StackInfo: ... diff --git a/logfire-api/logfire_api/_internal/utils.pyi b/logfire-api/logfire_api/_internal/utils.pyi index 6ad139178..3537d7ce2 100644 --- a/logfire-api/logfire_api/_internal/utils.pyi +++ b/logfire-api/logfire_api/_internal/utils.pyi @@ -1,5 +1,7 @@ +import typing from _typeshed import Incomplete from collections.abc import Generator +from logfire._internal.stack_info import is_user_code as is_user_code from opentelemetry import trace as trace_api from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import Event as Event, ReadableSpan @@ -82,5 +84,8 @@ def is_instrumentation_suppressed() -> bool: def suppress_instrumentation() -> Generator[None, None, None]: """Context manager to suppress all logs/spans generated by logfire or OpenTelemetry.""" def log_internal_error() -> None: ... + +SysExcInfo: typing.TypeAlias + def handle_internal_errors() -> Generator[None, None, None]: ... def maybe_capture_server_headers(capture: bool): ... diff --git a/logfire-api/pyproject.toml b/logfire-api/pyproject.toml index a7e114ae5..118150b46 100644 --- a/logfire-api/pyproject.toml +++ b/logfire-api/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "logfire-api" -version = "0.48.1" +version = "0.49.0" description = "Shim for the Logfire SDK which does nothing unless Logfire is installed" authors = [ { name = "Pydantic Team", email = "engineering@pydantic.dev" }, diff --git a/logfire/__init__.py b/logfire/__init__.py index 3bd887963..58fe21ab0 100644 --- a/logfire/__init__.py +++ b/logfire/__init__.py @@ -38,6 +38,7 @@ instrument_sqlalchemy = DEFAULT_LOGFIRE_INSTANCE.instrument_sqlalchemy instrument_redis = DEFAULT_LOGFIRE_INSTANCE.instrument_redis instrument_pymongo = DEFAULT_LOGFIRE_INSTANCE.instrument_pymongo +instrument_mysql = DEFAULT_LOGFIRE_INSTANCE.instrument_mysql shutdown = DEFAULT_LOGFIRE_INSTANCE.shutdown with_tags = DEFAULT_LOGFIRE_INSTANCE.with_tags # with_trace_sample_rate = DEFAULT_LOGFIRE_INSTANCE.with_trace_sample_rate @@ -112,6 +113,7 @@ def loguru_handler() -> dict[str, Any]: 'instrument_sqlalchemy', 'instrument_redis', 'instrument_pymongo', + 'instrument_mysql', 'AutoTraceModule', 'with_tags', 'with_settings', diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index 70fef90cb..7c8afdfdf 100644 --- a/logfire/_internal/config.py +++ b/logfire/_internal/config.py @@ -88,11 +88,11 @@ METRICS_PREFERRED_TEMPORALITY = { Counter: AggregationTemporality.DELTA, - UpDownCounter: AggregationTemporality.DELTA, + UpDownCounter: AggregationTemporality.CUMULATIVE, Histogram: AggregationTemporality.DELTA, ObservableCounter: AggregationTemporality.DELTA, - ObservableUpDownCounter: AggregationTemporality.DELTA, - ObservableGauge: AggregationTemporality.DELTA, + ObservableUpDownCounter: AggregationTemporality.CUMULATIVE, + ObservableGauge: AggregationTemporality.CUMULATIVE, } """This should be passed as the `preferred_temporality` argument of metric readers and exporters.""" diff --git a/logfire/_internal/integrations/mysql.py b/logfire/_internal/integrations/mysql.py new file mode 100644 index 000000000..b4e24dfde --- /dev/null +++ b/logfire/_internal/integrations/mysql.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from opentelemetry.instrumentation.mysql import MySQLInstrumentor + +if TYPE_CHECKING: + from mysql.connector.abstracts import MySQLConnectionAbstract + from mysql.connector.pooling import PooledMySQLConnection + from typing_extensions import TypedDict, TypeVar, Unpack + + MySQLConnection = TypeVar('MySQLConnection', bound=PooledMySQLConnection | MySQLConnectionAbstract | None) + + class MySQLInstrumentKwargs(TypedDict, total=False): + skip_dep_check: bool + + +def instrument_mysql( + conn: MySQLConnection = None, + **kwargs: Unpack[MySQLInstrumentKwargs], +) -> MySQLConnection: + """Instrument the `mysql` module or a specific MySQL connection so that spans are automatically created for each operation. + + This function uses the OpenTelemetry MySQL Instrumentation library to instrument either the entire `mysql` module or a specific MySQL connection. + + Args: + conn: The MySQL connection to instrument. If None, the entire `mysql` module is instrumented. + **kwargs: Additional keyword arguments to pass to the OpenTelemetry `instrument` methods. + + Returns: + If a connection is provided, returns the instrumented connection. If no connection is provided, returns None. + + See the `Logfire.instrument_mysql` method for details. + """ + if conn is not None: + return MySQLInstrumentor().instrument_connection(conn) # type: ignore[reportUnknownMemberType] + return MySQLInstrumentor().instrument(**kwargs) # type: ignore[reportUnknownMemberType] diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 8cef6bb53..16b4675b8 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -8,7 +8,6 @@ import warnings from functools import cached_property, partial from time import time -from types import TracebackType from typing import TYPE_CHECKING, Any, Callable, ContextManager, Iterable, Literal, Sequence, TypeVar, Union, cast import opentelemetry.context as context_api @@ -16,7 +15,7 @@ from opentelemetry.metrics import CallbackT, Counter, Histogram, UpDownCounter from opentelemetry.sdk.trace import ReadableSpan, Span from opentelemetry.semconv.trace import SpanAttributes -from opentelemetry.trace import Tracer +from opentelemetry.trace import StatusCode, Tracer from opentelemetry.util import types as otel_types from typing_extensions import LiteralString, ParamSpec @@ -26,6 +25,7 @@ from .config import GLOBAL_CONFIG, LogfireConfig from .constants import ( ATTRIBUTES_JSON_SCHEMA_KEY, + ATTRIBUTES_LOG_LEVEL_NUM_KEY, ATTRIBUTES_MESSAGE_KEY, ATTRIBUTES_MESSAGE_TEMPLATE_KEY, ATTRIBUTES_SAMPLE_RATE_KEY, @@ -33,6 +33,7 @@ ATTRIBUTES_TAGS_KEY, ATTRIBUTES_VALIDATION_ERROR_KEY, DISABLE_CONSOLE_KEY, + LEVEL_NUMBERS, NULL_ARGS_KEY, OTLP_MAX_INT_SIZE, LevelName, @@ -50,7 +51,7 @@ from .metrics import ProxyMeterProvider from .stack_info import get_user_stack_info from .tracer import ProxyTracerProvider -from .utils import handle_internal_errors, log_internal_error, uniquify_sequence +from .utils import SysExcInfo, handle_internal_errors, log_internal_error, uniquify_sequence if TYPE_CHECKING: import anthropic @@ -68,6 +69,7 @@ from .integrations.celery import CeleryInstrumentKwargs from .integrations.flask import FlaskInstrumentKwargs from .integrations.httpx import HTTPXInstrumentKwargs + from .integrations.mysql import MySQLConnection, MySQLInstrumentKwargs from .integrations.psycopg import PsycopgInstrumentKwargs from .integrations.pymongo import PymongoInstrumentKwargs from .integrations.redis import RedisInstrumentKwargs @@ -85,13 +87,7 @@ # 1. It's convenient to pass the result of sys.exc_info() directly # 2. It mirrors the exc_info argument of the stdlib logging methods # 3. The argument name exc_info is very suggestive of the sys function. -ExcInfo: typing.TypeAlias = Union[ - 'tuple[type[BaseException], BaseException, TracebackType | None]', - 'tuple[None, None, None]', - BaseException, - bool, - None, -] +ExcInfo: typing.TypeAlias = Union[SysExcInfo, BaseException, bool, None] class Logfire: @@ -662,6 +658,11 @@ def log( exc_info = exc_info[1] if isinstance(exc_info, BaseException): _record_exception(span, exc_info) + if otlp_attributes[ATTRIBUTES_LOG_LEVEL_NUM_KEY] >= LEVEL_NUMBERS['error']: # type: ignore + # Set the status description to the exception message. + # OTEL only lets us set the description when the status code is ERROR, + # which we only want to do when the log level is error. + _set_exception_status(span, exc_info) elif exc_info is not None: # pragma: no cover raise TypeError(f'Invalid type for exc_info: {exc_info.__class__.__name__}') @@ -1223,6 +1224,30 @@ def instrument_redis(self, **kwargs: Unpack[RedisInstrumentKwargs]) -> None: self._warn_if_not_initialized_for_instrumentation() return instrument_redis(**kwargs) + def instrument_mysql( + self, + conn: MySQLConnection = None, + **kwargs: Unpack[MySQLInstrumentKwargs], + ) -> MySQLConnection: + """Instrument the `mysql` module or a specific MySQL connection so that spans are automatically created for each operation. + + Uses the + [OpenTelemetry MySQL Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/mysql/mysql.html) + library. + + Args: + conn: The `mysql` connection to instrument, or `None` to instrument all connections. + **kwargs: Additional keyword arguments to pass to the OpenTelemetry `instrument` methods. + + Returns: + If a connection is provided, returns the instrumented connection. If no connection is provided, returns None. + + """ + from .integrations.mysql import instrument_mysql + + self._warn_if_not_initialized_for_instrumentation() + return instrument_mysql(conn, **kwargs) + def metric_counter(self, name: str, *, unit: str = '', description: str = '') -> Counter: """Create a counter metric. @@ -1748,6 +1773,15 @@ def _exit_span(span: trace_api.Span, exception: BaseException | None) -> None: _record_exception(span, exception, escaped=True) +def _set_exception_status(span: trace_api.Span, exception: BaseException): + span.set_status( + trace_api.Status( + status_code=StatusCode.ERROR, + description=f'{exception.__class__.__name__}: {exception}', + ) + ) + + @handle_internal_errors() def _record_exception( span: trace_api.Span, @@ -1763,12 +1797,7 @@ def _record_exception( # This means we know that the exception hasn't been handled, # so we can set the OTEL status and the log level to error. if escaped: - span.set_status( - trace_api.Status( - status_code=trace_api.StatusCode.ERROR, - description=f'{exception.__class__.__name__}: {exception}', - ) - ) + _set_exception_status(span, exception) span.set_attributes(log_level_attributes('error')) attributes = {**(attributes or {})} diff --git a/logfire/_internal/stack_info.py b/logfire/_internal/stack_info.py index 4a089e3d0..a19904060 100644 --- a/logfire/_internal/stack_info.py +++ b/logfire/_internal/stack_info.py @@ -22,7 +22,7 @@ SITE_PACKAGES_DIR = str(Path(opentelemetry.sdk.trace.__file__).parent.parent.parent.parent.absolute()) PYTHON_LIB_DIR = str(Path(inspect.__file__).parent.absolute()) LOGFIRE_DIR = str(Path(logfire.__file__).parent.absolute()) -PREFIXES = (SITE_PACKAGES_DIR, PYTHON_LIB_DIR, LOGFIRE_DIR) +NON_USER_CODE_PREFIXES = (SITE_PACKAGES_DIR, PYTHON_LIB_DIR, LOGFIRE_DIR) def get_filepath_attribute(file: str) -> StackInfo: @@ -95,7 +95,7 @@ def is_user_code(code: CodeType) -> bool: On the other hand, generator expressions and lambdas might be called far away from where they are defined. """ return not ( - str(Path(code.co_filename).absolute()).startswith(PREFIXES) + str(Path(code.co_filename).absolute()).startswith(NON_USER_CODE_PREFIXES) or code.co_name in ('', '', '') ) diff --git a/logfire/_internal/tracer.py b/logfire/_internal/tracer.py index b3bffc74c..42a42d2c4 100644 --- a/logfire/_internal/tracer.py +++ b/logfire/_internal/tracer.py @@ -201,7 +201,7 @@ def start_span( # This means that `with start_as_current_span(...):` # is roughly equivalent to `with use_span(start_span(...)):` - start_as_current_span = SDKTracer.start_as_current_span # type: ignore + start_as_current_span = SDKTracer.start_as_current_span @dataclass diff --git a/logfire/_internal/utils.py b/logfire/_internal/utils.py index 97143e216..421ecae02 100644 --- a/logfire/_internal/utils.py +++ b/logfire/_internal/utils.py @@ -1,11 +1,14 @@ from __future__ import annotations +import inspect import json import logging import os import sys +import typing from contextlib import contextmanager from pathlib import Path +from types import TracebackType from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Sequence, Tuple, TypedDict, TypeVar, Union from opentelemetry import context, trace as trace_api @@ -16,6 +19,8 @@ from opentelemetry.util import types as otel_types from requests import RequestException, Response +from logfire._internal.stack_info import is_user_code + if TYPE_CHECKING: from packaging.version import Version @@ -252,7 +257,81 @@ def log_internal_error(): raise with suppress_instrumentation(): # prevent infinite recursion from the logging integration - logger.exception('Internal error in Logfire') + logger.exception('Internal error in Logfire', exc_info=_internal_error_exc_info()) + + +SysExcInfo: typing.TypeAlias = Union[ + 'tuple[type[BaseException], BaseException, TracebackType | None]', + 'tuple[None, None, None]', +] +""" +The return type of sys.exc_info(): exc_type, exc_val, exc_tb. +""" + + +def _internal_error_exc_info() -> SysExcInfo: + """Returns an exc_info tuple with a nicely tweaked traceback.""" + original_exc_info: SysExcInfo = sys.exc_info() + exc_type, exc_val, original_tb = original_exc_info + try: + # First remove redundant frames already in the traceback about where the error was raised. + tb = original_tb + if tb and tb.tb_frame and tb.tb_frame.f_code is _HANDLE_INTERNAL_ERRORS_CODE: + # Skip the 'yield' line in _handle_internal_errors + tb = tb.tb_next + + if ( + tb + and tb.tb_frame + and tb.tb_frame.f_code.co_filename == contextmanager.__code__.co_filename + and tb.tb_frame.f_code.co_name == 'inner' + ): + # Skip the 'inner' function frame when handle_internal_errors is used as a decorator. + # It looks like `return func(*args, **kwds)` + tb = tb.tb_next + + # Now add useful outer frames that give context, but skipping frames that are just about handling the error. + frame = inspect.currentframe() + # Skip this frame right here. + assert frame + frame = frame.f_back + + if frame and frame.f_code is log_internal_error.__code__: # pragma: no branch + # This function is always called from log_internal_error, so skip that frame. + frame = frame.f_back + assert frame + + if frame.f_code is _HANDLE_INTERNAL_ERRORS_CODE: + # Skip the line in _handle_internal_errors that calls log_internal_error + frame = frame.f_back + # Skip the frame defining the _handle_internal_errors context manager + assert frame and frame.f_code.co_name == '__exit__' + frame = frame.f_back + assert frame + # Skip the frame calling the context manager, on the `with` line. + frame = frame.f_back + else: + # `log_internal_error()` was called directly, so just skip that frame. No context manager stuff. + frame = frame.f_back + + # Now add all remaining frames from internal logfire code. + while frame and not is_user_code(frame.f_code): + tb = TracebackType(tb_next=tb, tb_frame=frame, tb_lasti=frame.f_lasti, tb_lineno=frame.f_lineno) + frame = frame.f_back + + # Add up to 3 frames from user code. + for _ in range(3): + if not frame: # pragma: no cover + break + tb = TracebackType(tb_next=tb, tb_frame=frame, tb_lasti=frame.f_lasti, tb_lineno=frame.f_lineno) + frame = frame.f_back + + assert exc_type + assert exc_val + exc_val = exc_val.with_traceback(tb) + return exc_type, exc_val, tb + except Exception: # pragma: no cover + return original_exc_info @contextmanager @@ -263,6 +342,9 @@ def handle_internal_errors(): log_internal_error() +_HANDLE_INTERNAL_ERRORS_CODE = inspect.unwrap(handle_internal_errors).__code__ + + def maybe_capture_server_headers(capture: bool): if capture: os.environ['OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST'] = '.*' diff --git a/mkdocs.yml b/mkdocs.yml index 5b6249144..d87714b82 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -107,6 +107,7 @@ nav: - Asyncpg: integrations/asyncpg.md - Psycopg: integrations/psycopg.md - PyMongo: integrations/pymongo.md + - MySQL: integrations/mysql.md - Redis: integrations/redis.md - Celery: integrations/celery.md - System Metrics: integrations/system_metrics.md diff --git a/pyproject.toml b/pyproject.toml index 213b411a0..1fc75a049 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "logfire" -version = "0.48.1" +version = "0.49.0" description = "The best Python observability tool! 🪵🔥" authors = [ { name = "Pydantic Team", email = "engineering@pydantic.dev" }, @@ -66,6 +66,7 @@ psycopg2 = ["opentelemetry-instrumentation-psycopg2 >= 0.42b0", "packaging"] pymongo = ["opentelemetry-instrumentation-pymongo >= 0.42b0"] redis = ["opentelemetry-instrumentation-redis >= 0.42b0"] requests = ["opentelemetry-instrumentation-requests >= 0.42b0"] +mysql = ["opentelemetry-instrumentation-mysql >= 0.42b0"] [project.scripts] logfire = "logfire.cli:main" @@ -114,6 +115,7 @@ dev-dependencies = [ "opentelemetry-instrumentation-redis", "opentelemetry-instrumentation-pymongo", "opentelemetry-instrumentation-celery", + "opentelemetry-instrumentation-mysql", "eval-type-backport", "requests-mock", "inline-snapshot", @@ -136,6 +138,7 @@ dev-dependencies = [ "mypy>=1.10.0", "celery>=5.4.0", "testcontainers", + "mysql-connector-python~=8.0", ] [tool.rye.scripts] diff --git a/requirements-dev.lock b/requirements-dev.lock index 4a31866e4..07ddf363c 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -6,35 +6,37 @@ # features: [] # all-features: false # with-sources: false +# generate-hashes: false -e file:. -aiohttp==3.9.5 +aiohappyeyeballs==2.3.4 + # via aiohttp +aiohttp==3.10.1 aiosignal==1.3.1 # via aiohttp amqp==5.2.0 # via kombu annotated-types==0.7.0 # via pydantic -anthropic==0.31.2 +anthropic==0.32.0 anyio==4.3.0 # via anthropic # via httpx # via openai # via starlette - # via watchfiles asgiref==3.8.1 # via django # via opentelemetry-instrumentation-asgi asttokens==2.4.1 # via inline-snapshot asyncpg==0.29.0 -attrs==23.2.0 +attrs==24.1.0 # via aiohttp babel==2.15.0 # via mkdocs-material billiard==4.2.0 # via celery -black==24.4.2 +black==24.8.0 # via inline-snapshot blinker==1.8.2 # via flask @@ -57,8 +59,6 @@ click==8.1.7 # via inline-snapshot # via mkdocs # via mkdocstrings - # via typer - # via uvicorn click-didyoumean==0.3.1 # via celery click-plugins==1.1.1 @@ -69,7 +69,7 @@ cloudpickle==3.0.0 colorama==0.4.6 # via griffe # via mkdocs-material -coverage==7.6.0 +coverage==7.6.1 deprecated==1.2.14 # via opentelemetry-api # via opentelemetry-exporter-otlp-proto-http @@ -82,19 +82,14 @@ distro==1.9.0 # via openai django==5.0.7 dnspython==2.6.1 - # via email-validator # via pymongo docker==7.1.0 # via testcontainers -email-validator==2.2.0 - # via fastapi eval-type-backport==0.2.0 executing==2.0.1 # via inline-snapshot # via logfire -fastapi==0.111.1 -fastapi-cli==0.0.4 - # via fastapi +fastapi==0.112.0 filelock==3.15.4 # via huggingface-hub # via virtualenv @@ -112,22 +107,17 @@ griffe==0.48.0 # via mkdocstrings-python h11==0.14.0 # via httpcore - # via uvicorn httpcore==1.0.5 # via httpx -httptools==0.6.1 - # via uvicorn httpx==0.27.0 # via anthropic - # via fastapi # via openai -huggingface-hub==0.24.2 +huggingface-hub==0.24.5 # via tokenizers identify==2.6.0 # via pre-commit idna==3.7 # via anyio - # via email-validator # via httpx # via requests # via yarl @@ -140,7 +130,6 @@ inline-snapshot==0.12.0 itsdangerous==2.2.0 # via flask jinja2==3.1.4 - # via fastapi # via flask # via mkdocs # via mkdocs-material @@ -178,25 +167,26 @@ mkdocs-autorefs==1.0.1 mkdocs-get-deps==0.2.0 # via mkdocs mkdocs-glightbox==0.4.0 -mkdocs-material==9.5.30 +mkdocs-material==9.5.31 mkdocs-material-extensions==1.3.1 # via mkdocs-material -mkdocstrings==0.25.1 +mkdocstrings==0.25.2 # via mkdocstrings-python -mkdocstrings-python==1.10.5 +mkdocstrings-python==1.10.7 multidict==6.0.5 # via aiohttp # via yarl -mypy==1.11.0 +mypy==1.11.1 mypy-extensions==1.0.0 # via black # via mypy +mysql-connector-python==8.4.0 nodeenv==1.9.1 # via pre-commit # via pyright numpy==2.0.1 # via pandas -openai==1.37.0 +openai==1.38.0 opentelemetry-api==1.26.0 # via opentelemetry-exporter-otlp-proto-http # via opentelemetry-instrumentation @@ -209,6 +199,7 @@ opentelemetry-api==1.26.0 # via opentelemetry-instrumentation-fastapi # via opentelemetry-instrumentation-flask # via opentelemetry-instrumentation-httpx + # via opentelemetry-instrumentation-mysql # via opentelemetry-instrumentation-psycopg # via opentelemetry-instrumentation-psycopg2 # via opentelemetry-instrumentation-pymongo @@ -235,6 +226,7 @@ opentelemetry-instrumentation==0.47b0 # via opentelemetry-instrumentation-fastapi # via opentelemetry-instrumentation-flask # via opentelemetry-instrumentation-httpx + # via opentelemetry-instrumentation-mysql # via opentelemetry-instrumentation-psycopg # via opentelemetry-instrumentation-psycopg2 # via opentelemetry-instrumentation-pymongo @@ -251,12 +243,14 @@ opentelemetry-instrumentation-asgi==0.47b0 opentelemetry-instrumentation-asyncpg==0.47b0 opentelemetry-instrumentation-celery==0.47b0 opentelemetry-instrumentation-dbapi==0.47b0 + # via opentelemetry-instrumentation-mysql # via opentelemetry-instrumentation-psycopg # via opentelemetry-instrumentation-psycopg2 opentelemetry-instrumentation-django==0.47b0 opentelemetry-instrumentation-fastapi==0.47b0 opentelemetry-instrumentation-flask==0.47b0 opentelemetry-instrumentation-httpx==0.47b0 +opentelemetry-instrumentation-mysql==0.47b0 opentelemetry-instrumentation-psycopg==0.47b0 opentelemetry-instrumentation-psycopg2==0.47b0 opentelemetry-instrumentation-pymongo==0.47b0 @@ -321,7 +315,7 @@ platformdirs==4.2.2 # via virtualenv pluggy==1.5.0 # via pytest -pre-commit==3.7.1 +pre-commit==3.8.0 prompt-toolkit==3.0.47 # via click-repl protobuf==4.25.4 @@ -344,12 +338,12 @@ pydantic-core==2.20.1 pygments==2.18.0 # via mkdocs-material # via rich -pymdown-extensions==10.8.1 +pymdown-extensions==10.9 # via mkdocs-material # via mkdocstrings pymongo==4.8.0 -pyright==1.1.373 -pytest==8.3.1 +pyright==1.1.374 +pytest==8.3.2 # via pytest-django # via pytest-pretty pytest-django==4.8.0 @@ -358,10 +352,6 @@ python-dateutil==2.9.0.post0 # via celery # via ghp-import # via pandas -python-dotenv==1.0.1 - # via uvicorn -python-multipart==0.0.9 - # via fastapi pytz==2024.1 # via dirty-equals # via pandas @@ -372,10 +362,9 @@ pyyaml==6.0.1 # via pre-commit # via pymdown-extensions # via pyyaml-env-tag - # via uvicorn pyyaml-env-tag==0.1 # via mkdocs -redis==5.0.7 +redis==5.0.8 regex==2024.7.24 # via mkdocs-material requests==2.32.3 @@ -389,12 +378,9 @@ rich==13.7.1 # via inline-snapshot # via logfire # via pytest-pretty - # via typer -ruff==0.5.4 -setuptools==71.1.0 +ruff==0.5.6 +setuptools==72.1.0 # via opentelemetry-instrumentation -shellingham==1.5.4 - # via typer six==1.16.0 # via asttokens # via python-dateutil @@ -416,11 +402,9 @@ tokenizers==0.19.1 # via anthropic toml==0.10.2 # via inline-snapshot -tqdm==4.66.4 +tqdm==4.66.5 # via huggingface-hub # via openai -typer==0.12.3 - # via fastapi-cli types-toml==0.10.8.20240310 # via inline-snapshot typing-extensions==4.12.2 @@ -437,7 +421,6 @@ typing-extensions==4.12.2 # via pydantic-core # via sqlalchemy # via testcontainers - # via typer tzdata==2024.1 # via celery # via pandas @@ -445,10 +428,6 @@ urllib3==2.2.2 # via docker # via requests # via testcontainers -uvicorn==0.30.3 - # via fastapi -uvloop==0.19.0 - # via uvicorn vine==5.1.0 # via amqp # via celery @@ -457,12 +436,8 @@ virtualenv==20.26.3 # via pre-commit watchdog==4.0.1 # via mkdocs -watchfiles==0.22.0 - # via uvicorn wcwidth==0.2.13 # via prompt-toolkit -websockets==12.0 - # via uvicorn werkzeug==3.0.3 # via flask wrapt==1.16.0 diff --git a/requirements.lock b/requirements.lock index 3e70e3dab..5f5524a7c 100644 --- a/requirements.lock +++ b/requirements.lock @@ -6,6 +6,7 @@ # features: [] # all-features: false # with-sources: false +# generate-hashes: false -e file:. certifi==2024.7.4 @@ -57,7 +58,7 @@ requests==2.32.3 # via opentelemetry-exporter-otlp-proto-http rich==13.7.1 # via logfire -setuptools==71.1.0 +setuptools==72.1.0 # via opentelemetry-instrumentation typing-extensions==4.12.2 # via logfire diff --git a/tests/import_used_for_tests/internal_error_handling/__init__.py b/tests/import_used_for_tests/internal_error_handling/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/import_used_for_tests/internal_error_handling/internal_logfire_code_example.py b/tests/import_used_for_tests/internal_error_handling/internal_logfire_code_example.py new file mode 100644 index 000000000..9bc5fa79a --- /dev/null +++ b/tests/import_used_for_tests/internal_error_handling/internal_logfire_code_example.py @@ -0,0 +1,36 @@ +from typing import Any + +from logfire._internal.utils import handle_internal_errors, log_internal_error + + +def inner1(): + raise ValueError('inner1') + + +def inner2(): + inner1() + + +@handle_internal_errors() +def using_decorator(): + inner2() + + +def using_context_manager(): + with handle_internal_errors(): + inner2() + + +def using_try_except(): + try: + inner2() + except Exception: + log_internal_error() + + +def outer1(func: Any): + func() + + +def outer2(func: Any): + outer1(func) diff --git a/tests/import_used_for_tests/internal_error_handling/user_code_example.py b/tests/import_used_for_tests/internal_error_handling/user_code_example.py new file mode 100644 index 000000000..825605f83 --- /dev/null +++ b/tests/import_used_for_tests/internal_error_handling/user_code_example.py @@ -0,0 +1,32 @@ +from tests.import_used_for_tests.internal_error_handling.internal_logfire_code_example import ( + outer2, + using_context_manager, + using_decorator, + using_try_except, +) + + +def user1(): + user2() + + +def user2(): + user3() + + +def user3(): + user4() + + +def user4(): + user5() + + +def user5(): + user6() + + +def user6(): + outer2(using_decorator) + outer2(using_context_manager) + outer2(using_try_except) diff --git a/tests/otel_integrations/test_django.py b/tests/otel_integrations/test_django.py index db4d4b31a..c784c4b7a 100644 --- a/tests/otel_integrations/test_django.py +++ b/tests/otel_integrations/test_django.py @@ -46,7 +46,7 @@ def test_good_route(client: Client, exporter: TestExporter, metrics_reader: InMe 'value': 0, } ], - 'aggregation_temporality': 1, + 'aggregation_temporality': 2, 'is_monotonic': False, }, }, diff --git a/tests/otel_integrations/test_mysql.py b/tests/otel_integrations/test_mysql.py new file mode 100644 index 000000000..d39cfaa14 --- /dev/null +++ b/tests/otel_integrations/test_mysql.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import sys + +import mysql.connector +import pytest +from dirty_equals import IsInt +from inline_snapshot import snapshot +from opentelemetry.instrumentation.mysql import MySQLInstrumentor +from testcontainers.mysql import MySqlContainer + +import logfire +from logfire.testing import TestExporter + +pytestmark = pytest.mark.skipif(sys.version_info < (3, 9), reason='MySQL testcontainers has problems in 3.8') + + +@pytest.fixture(scope='module') +def mysql_container(): + with MySqlContainer() as mysql_container: + yield mysql_container + + +def get_mysql_connection(mysql_container: MySqlContainer): + host = mysql_container.get_container_host_ip() + port = mysql_container.get_exposed_port(3306) + connection = mysql.connector.connect(host=host, port=port, user='test', password='test', database='test') + return connection + + +def test_mysql_instrumentation(exporter: TestExporter, mysql_container: MySqlContainer): + logfire.instrument_mysql() + + with get_mysql_connection(mysql_container) as conn: + with conn.cursor() as cursor: + cursor.execute('DROP TABLE IF EXISTS test') + cursor.execute('CREATE TABLE test (id INT PRIMARY KEY, name VARCHAR(255))') + + assert exporter.exported_spans_as_dict() == snapshot( + [ + { + 'name': 'DROP', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 2000000000, + 'attributes': { + 'logfire.span_type': 'span', + 'logfire.msg': 'DROP TABLE IF EXISTS test', + 'db.system': 'mysql', + 'db.name': 'test', + 'db.statement': 'DROP TABLE IF EXISTS test', + 'db.user': 'test', + 'net.peer.name': 'localhost', + 'net.peer.port': IsInt(), + }, + }, + { + 'name': 'CREATE', + 'context': {'trace_id': 2, 'span_id': 3, 'is_remote': False}, + 'parent': None, + 'start_time': 3000000000, + 'end_time': 4000000000, + 'attributes': { + 'logfire.span_type': 'span', + 'logfire.msg': 'CREATE TABLE test (id INT PRIMARY KEY, name VARCHAR(255))', + 'db.system': 'mysql', + 'db.name': 'test', + 'db.statement': 'CREATE TABLE test (id INT PRIMARY KEY, name VARCHAR(255))', + 'db.user': 'test', + 'net.peer.name': 'localhost', + 'net.peer.port': IsInt(), + }, + }, + ] + ) + MySQLInstrumentor().uninstrument() # type: ignore + + +def test_instrument_mysql_connection(exporter: TestExporter, mysql_container: MySqlContainer): + with get_mysql_connection(mysql_container) as conn: + with conn.cursor() as cursor: + cursor.execute('DROP TABLE IF EXISTS test') + cursor.execute('CREATE TABLE test (id INT PRIMARY KEY, name VARCHAR(255))') + + assert exporter.exported_spans_as_dict() == [] + + conn = logfire.instrument_mysql(conn) + with conn.cursor() as cursor: + cursor.execute('INSERT INTO test (id, name) VALUES (1, "test")') + + assert exporter.exported_spans_as_dict() == snapshot( + [ + { + 'name': 'INSERT', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 2000000000, + 'attributes': { + 'logfire.span_type': 'span', + 'logfire.msg': 'INSERT INTO test (id, name) VALUES (1, "test")', + 'db.system': 'mysql', + 'db.name': 'test', + 'db.statement': 'INSERT INTO test (id, name) VALUES (1, "test")', + 'db.user': 'test', + 'net.peer.name': 'localhost', + 'net.peer.port': IsInt(), + }, + } + ] + ) + + conn = MySQLInstrumentor().uninstrument_connection(conn) # type: ignore + with conn.cursor() as cursor: # type: ignore + cursor.execute('INSERT INTO test (id, name) VALUES (2, "test-2")') # type: ignore + + assert len(exporter.exported_spans_as_dict()) == 1 diff --git a/tests/test_logfire.py b/tests/test_logfire.py index b08b37d9f..cace5770a 100644 --- a/tests/test_logfire.py +++ b/tests/test_logfire.py @@ -1647,6 +1647,13 @@ def test_exc_info(exporter: TestExporter): 'exception.escaped': 'False', } + for span in exporter.exported_spans[:-3]: + assert span.status.description is None + + for span in exporter.exported_spans[-3:]: + assert span.status.status_code == StatusCode.ERROR + assert span.status.description == 'ValueError: an error' + def test_span_level(exporter: TestExporter): with logfire.span('foo', _level='debug') as span: diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 8f6a31e38..e62050c6c 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -211,10 +211,10 @@ def test_create_metric_up_down_counter(metrics_reader: InMemoryMetricReader) -> 'attributes': {}, 'start_time_unix_nano': IsInt(), 'time_unix_nano': IsInt(), - 'value': 4300, + 'value': 4321, } ], - 'aggregation_temporality': AggregationTemporality.DELTA, + 'aggregation_temporality': AggregationTemporality.CUMULATIVE, 'is_monotonic': False, }, } @@ -313,10 +313,10 @@ def observable_counter(options: CallbackOptions): 'attributes': {}, 'start_time_unix_nano': IsInt(), 'time_unix_nano': IsInt(), - 'value': 4300, + 'value': 4321, } ], - 'aggregation_temporality': AggregationTemporality.DELTA, + 'aggregation_temporality': AggregationTemporality.CUMULATIVE, 'is_monotonic': False, }, } diff --git a/tests/test_utils.py b/tests/test_utils.py index 58c6494cf..fcfa8bc75 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,8 +1,11 @@ import pytest import requests import requests_mock +from inline_snapshot import snapshot +import logfire._internal.stack_info from logfire._internal.utils import UnexpectedResponse, handle_internal_errors +from tests.import_used_for_tests.internal_error_handling import internal_logfire_code_example, user_code_example def test_raise_for_status() -> None: @@ -21,3 +24,94 @@ def test_reraise_internal_exception(): with pytest.raises(ZeroDivisionError): with handle_internal_errors(): str(1 / 0) + + +def test_internal_exception_tb(caplog: pytest.LogCaptureFixture): + # Pretend that `internal_logfire_code_example` is a module within logfire, + # so all frames from it should be included. + logfire._internal.stack_info.NON_USER_CODE_PREFIXES += (internal_logfire_code_example.__file__,) + + user_code_example.user1() + + tracebacks = [ + r.exc_text.replace( # type: ignore + user_code_example.__file__, + 'user_code_example.py', + ).replace( + internal_logfire_code_example.__file__, + 'internal_logfire_code_example.py', + ) + for r in caplog.records + ] + + # Important notes about these tracebacks: + # - They should look very similar to each other, regardless of how log_internal_error was called. + # - They should include all frames from internal_logfire_code_example.py. + # - They should include exactly 3 frames from user_code_example.py. + # - They should look seamless, with each frame pointing to the next one. + # - There should be no sign of logfire's internal error handling code. + # - The two files should be isolated and stable so that the exact traceback contents can be asserted. + assert tracebacks == snapshot( + [ + """\ +Traceback (most recent call last): + File "user_code_example.py", line 22, in user4 + user5() + File "user_code_example.py", line 26, in user5 + user6() + File "user_code_example.py", line 30, in user6 + outer2(using_decorator) + File "internal_logfire_code_example.py", line 36, in outer2 + outer1(func) + File "internal_logfire_code_example.py", line 32, in outer1 + func() + File "internal_logfire_code_example.py", line 16, in using_decorator + inner2() + File "internal_logfire_code_example.py", line 11, in inner2 + inner1() + File "internal_logfire_code_example.py", line 7, in inner1 + raise ValueError('inner1') +ValueError: inner1\ +""", + """\ +Traceback (most recent call last): + File "user_code_example.py", line 22, in user4 + user5() + File "user_code_example.py", line 26, in user5 + user6() + File "user_code_example.py", line 31, in user6 + outer2(using_context_manager) + File "internal_logfire_code_example.py", line 36, in outer2 + outer1(func) + File "internal_logfire_code_example.py", line 32, in outer1 + func() + File "internal_logfire_code_example.py", line 21, in using_context_manager + inner2() + File "internal_logfire_code_example.py", line 11, in inner2 + inner1() + File "internal_logfire_code_example.py", line 7, in inner1 + raise ValueError('inner1') +ValueError: inner1\ +""", + """\ +Traceback (most recent call last): + File "user_code_example.py", line 22, in user4 + user5() + File "user_code_example.py", line 26, in user5 + user6() + File "user_code_example.py", line 32, in user6 + outer2(using_try_except) + File "internal_logfire_code_example.py", line 36, in outer2 + outer1(func) + File "internal_logfire_code_example.py", line 32, in outer1 + func() + File "internal_logfire_code_example.py", line 26, in using_try_except + inner2() + File "internal_logfire_code_example.py", line 11, in inner2 + inner1() + File "internal_logfire_code_example.py", line 7, in inner1 + raise ValueError('inner1') +ValueError: inner1\ +""", + ] + )