Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changesets/apply-the-log-level-set-with-basic-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
bump: patch
type: fix
---

`logging.basicConfig()` applies the log level it is given. An application that calls it with a level below `WARNING`, such as `logging.INFO`, starts sending its log lines at that level.
6 changes: 6 additions & 0 deletions .changesets/keep-internal-logs-out-of-your-logs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
bump: patch
type: fix
---

When `disable_default_instrumentations` is used to disable the `logging` instrumentation, prevent the log lines emitted internally by AppSignal from propagating to a manually configured OpenTelemetry handler in the root logger.
8 changes: 8 additions & 0 deletions .changesets/keep-sending-logs-when-logging-is-configured.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
bump: patch
type: fix
---

Logs are now still sent after your application configures the logging module. `logging.config.dictConfig()`, `logging.config.fileConfig()` and `logging.basicConfig()` keep AppSignal's log handler attached, so a configuration that sets its own handlers on the root logger, such as Django's `LOGGING` setting, will now send its logs to AppSignal as well.

If you have manually added an OpenTelemetry log handler to your `LOGGING` setting or to the root logger elsewhere, it should now be removed, as AppSignal's handler will now send those log lines as well, causing them to be sent twice. AppSignal will log a warning when it detects a redundant OpenTelemetry handler.
6 changes: 6 additions & 0 deletions .changesets/require-python-3-10.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
bump: minor
type: remove
---

AppSignal for Python no longer supports Python 3.8 and 3.9, requiring Python 3.10 or newer.
12 changes: 12 additions & 0 deletions .changesets/send-a-non-string-log-message-as-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
bump: minor
type: change
---

A log message that is not a string, such as the dictionary in `logger.info({"message": "Order placed", "order_id": 1234})`, is sent as text by the `logging` instrumentation. Pass the structured values in the `extra` argument to send a structured log line:

```python
logger.info("Order placed", extra={"order_id": 1234})
```

[The OpenTelemetry logs API](https://docs.appsignal.com/logging/integrations/python#sending-logs-with-opentelemetry) sends a structured log line from a dictionary body.
6 changes: 6 additions & 0 deletions .changesets/send-one-log-line-per-forked-process.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
bump: patch
type: fix
---

Fix an issue where AppSignal would configure redundant handlers when starting again in a forked process, such as when a Celery worker calls `appsignal.start()` from the `worker_process_init` signal.
6 changes: 6 additions & 0 deletions .changesets/warn-about-the-deprecated-log-handler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
bump: patch
type: add
---

AppSignal warns when your application attaches the OpenTelemetry log handler from `opentelemetry.sdk._logs`, which is deprecated and is removed in a future release of the OpenTelemetry SDK. Its replacement is in `opentelemetry.instrumentation.logging.handler`.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand Down
4 changes: 2 additions & 2 deletions .python-version
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
3.14.3
3.13.12
3.12.2
3.11.3
3.10.11
3.9.16
3.8.16
83 changes: 82 additions & 1 deletion conftest.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
from __future__ import annotations

import logging
import logging.config
import os
import platform
import tempfile
import threading
from collections.abc import Callable, Generator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Callable, Generator
from typing import Any

import pytest
from opentelemetry._logs import LogRecord, set_logger_provider
from opentelemetry.metrics import set_meter_provider
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs.export import (
InMemoryLogRecordExporter,
SimpleLogRecordProcessor,
)
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
from opentelemetry.sdk.resources import Resource
Expand Down Expand Up @@ -137,6 +146,78 @@ def get_and_clear_spans() -> tuple[ReadableSpan, ...]:
yield get_and_clear_spans


@pytest.fixture(scope="session", autouse=True)
def start_in_memory_log_record_exporter() -> (
Generator[InMemoryLogRecordExporter, None, None]
):
log_record_exporter = InMemoryLogRecordExporter()
provider = LoggerProvider()
provider.add_log_record_processor(SimpleLogRecordProcessor(log_record_exporter))
set_logger_provider(provider)

yield log_record_exporter


@pytest.fixture(scope="function")
def log_records(
start_in_memory_log_record_exporter: InMemoryLogRecordExporter,
) -> Generator[Callable[[], tuple[LogRecord, ...]], None, None]:
start_in_memory_log_record_exporter.clear()

def get_and_clear_log_records() -> tuple[LogRecord, ...]:
log_records = tuple(
log_record.log_record
for log_record in start_in_memory_log_record_exporter.get_finished_logs()
)
start_in_memory_log_record_exporter.clear()
return log_records

yield get_and_clear_log_records


# Starting logging attaches a handler to the root logger, wraps the functions
# that configure the logging module, stops the internal loggers propagating
# and marks the instrumentation as installed. All of that lives on the logging
# module, so it outlives the test that started it and is put back here.
@pytest.fixture(scope="function", autouse=True)
def reset_logging_instrumentation() -> Any:
configure_functions = (
logging.basicConfig,
logging.config.dictConfig,
logging.config.fileConfig,
)
propagate = {
name: logging.getLogger(name).propagate
for name in ["appsignal", "opentelemetry"]
}

yield

from opentelemetry.instrumentation.logging import LoggingInstrumentor

instrumentor = LoggingInstrumentor()
if instrumentor.is_instrumented_by_opentelemetry:
instrumentor.uninstrument()

(
logging.basicConfig,
logging.config.dictConfig,
logging.config.fileConfig,
) = configure_functions

for name, propagates in propagate.items():
logging.getLogger(name).propagate = propagates

from appsignal.opentelemetry import _warned_logger_names

_warned_logger_names.clear()

for name in ["duplicate", "duplicate_not_propagating", "duplicate_elsewhere"]:
logger = logging.getLogger(name)
logger.handlers.clear()
logger.propagate = True


@pytest.fixture(scope="function", autouse=True)
def reset_environment_between_tests() -> Any:
old_environ = dict(os.environ)
Expand Down
14 changes: 8 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "hatchling.build"
name = "appsignal"
description = 'The AppSignal integration for the Python programming language'
readme = "README.md"
requires-python = ">=3.8"
requires-python = ">=3.10"
keywords = []
authors = [
{ name = "Tom de Bruijn", email = "tom@tomdebruijn.com" },
Expand All @@ -15,20 +15,21 @@ authors = [
classifiers = [
# Python versions
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
# Application version
"Development Status :: 5 - Production/Stable"
]
dependencies = [
"opentelemetry-api>=1.26.0",
"opentelemetry-sdk>=1.26.0",
"opentelemetry-api>=1.42.0",
"opentelemetry-sdk>=1.42.0",
"opentelemetry-exporter-otlp-proto-http",
"opentelemetry-instrumentation-logging>=0.63b0",
"requests",
"typing-extensions"
]
Expand Down Expand Up @@ -74,7 +75,7 @@ dependencies = [
]

[[tool.hatch.envs.test.matrix]]
python = ["38", "39", "310", "311", "312"]
python = ["310", "311", "312", "313", "314"]

[tool.hatch.envs.lint]
detached = true
Expand All @@ -87,6 +88,7 @@ dependencies = [
"opentelemetry-api",
"opentelemetry-sdk",
"opentelemetry-exporter-otlp-proto-http",
"opentelemetry-instrumentation-logging",

"hatchling",
"types-deprecated",
Expand Down
3 changes: 2 additions & 1 deletion src/appsignal/_once.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from typing import Any, Callable
from collections.abc import Callable
from typing import Any

from . import internal_logger as logger

Expand Down
3 changes: 2 additions & 1 deletion src/appsignal/check_in/cron.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

from binascii import hexlify
from collections.abc import Callable
from os import urandom
from typing import Any, Callable, Literal, TypeVar
from typing import Any, Literal, TypeVar

from .event import cron as cron_event
from .scheduler import scheduler
Expand Down
6 changes: 3 additions & 3 deletions src/appsignal/check_in/event.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
from __future__ import annotations

from time import time
from typing import Literal, TypedDict, Union
from typing import Literal, TypedDict

from typing_extensions import NotRequired


EventKind = Union[Literal["start"], Literal["finish"]]
EventKind = Literal["start"] | Literal["finish"]

EventCheckInType = Union[Literal["cron"], Literal["heartbeat"]]
EventCheckInType = Literal["cron"] | Literal["heartbeat"]


class Event(TypedDict):
Expand Down
3 changes: 2 additions & 1 deletion src/appsignal/cli/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import sys
from argparse import ArgumentParser
from typing import Mapping, NoReturn
from collections.abc import Mapping
from typing import NoReturn

from .command import AppsignalCLICommand
from .demo import DemoCommand
Expand Down
6 changes: 3 additions & 3 deletions src/appsignal/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import tempfile
import urllib.parse
import urllib.request
from typing import Any, ClassVar, List, Literal, TypedDict, cast, get_args
from typing import Any, ClassVar, Literal, TypedDict, cast, get_args

from . import internal_logger as logger
from .__about__ import __version__
Expand Down Expand Up @@ -161,7 +161,7 @@ class Config:
"logging",
]
DEFAULT_INSTRUMENTATIONS = cast(
List[DefaultInstrumentation], list(get_args(DefaultInstrumentation))
list[DefaultInstrumentation], list(get_args(DefaultInstrumentation))
)

DEPRECATED_COLLECTOR_OPTIONS: ClassVar[dict[str, list[str]]] = {
Expand Down Expand Up @@ -704,7 +704,7 @@ def parse_disable_default_instrumentations(
return False

return cast(
List[Config.DefaultInstrumentation],
list[Config.DefaultInstrumentation],
[x for x in value.split(",") if x in Config.DEFAULT_INSTRUMENTATIONS],
)

Expand Down
3 changes: 2 additions & 1 deletion src/appsignal/heartbeat.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from typing import Any, Callable, TypeVar
from collections.abc import Callable
from typing import Any, TypeVar

from ._once import _Once, _warn_logger_and_stdout
from .check_in import Cron, cron
Expand Down
Loading
Loading