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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ Settings class: `LogSettings`
| `{PREFIX}LOG_FILE_NAME` | platform log dir | Path to the log file (validated on startup when `FILE_ENABLED` is true). |
| `{PREFIX}LOG_REDIRECT_LOGGING` | `true` | Redirect stdlib `logging` to loguru via `InterceptHandler`. |

`logging_initialize()` sets the stdlib loggers `httpx`, `httpx2` and `urllib3` to `WARNING`. These
HTTP clients log each request URL at `INFO`, and the URL can contain credentials in its query string
(for example, the signature of a signed URL). At `WARNING`, these request lines do not get to the log
sinks or to Sentry. The `psycopg` and `psycopg.pool` loggers are also at `WARNING`.

#### Sentry (`{PREFIX}SENTRY_`)

Settings class: `SentrySettings`. Sentry is only initialised when `ENABLED=true` **and** `DSN` is
Expand All @@ -155,13 +160,20 @@ set.
| `{PREFIX}SENTRY_DSN` | unset | Sentry DSN (must be an HTTPS URL with a valid `ingest.*.sentry.io` domain). |
| `{PREFIX}SENTRY_DEBUG` | `false` | Enable Sentry SDK debug mode. |
| `{PREFIX}SENTRY_SEND_DEFAULT_PII` | `false` | Include personally-identifiable information in events. |
| `{PREFIX}SENTRY_INCLUDE_LOCAL_VARIABLES` | `false` | Include the local variables of each stack frame in error events. Locals can contain credentials and personal data. |
| `{PREFIX}SENTRY_MAX_REQUEST_BODY_SIZE` | `"never"` | Maximum size of HTTP request bodies in error events: `"never"`, `"small"` (up to 1 KB), `"medium"` (up to 10 KB) or `"always"`. Request bodies can contain credentials and personal data. The FastAPI integration sends JSON bodies even when `SEND_DEFAULT_PII` is `false`. |
| `{PREFIX}SENTRY_TRACE_PROPAGATION_TARGETS` | `[]` | JSON list of regexes. Sentry adds the `sentry-trace` and `baggage` headers only to outbound requests whose URL matches one of them. The `baggage` header contains the release, the environment and the public key of the DSN. With the default, no outbound request gets these headers. Example: `{PREFIX}SENTRY_TRACE_PROPAGATION_TARGETS='["internal\\.example\\.com"]'`. |
| `{PREFIX}SENTRY_MAX_BREADCRUMBS` | `50` | Maximum breadcrumbs stored per event. |
| `{PREFIX}SENTRY_SAMPLE_RATE` | `1.0` | Error event sample rate (0.0–1.0). |
| `{PREFIX}SENTRY_TRACES_SAMPLE_RATE` | `0.1` | Transaction/trace sample rate. |
| `{PREFIX}SENTRY_PROFILES_SAMPLE_RATE` | `0.1` | Profiler sample rate. |
| `{PREFIX}SENTRY_PROFILE_SESSION_SAMPLE_RATE` | `0.1` | Profile session sample rate. |
| `{PREFIX}SENTRY_PROFILE_LIFECYCLE` | `"trace"` | Profile lifecycle mode: `"trace"` or `"manual"`. |
| `{PREFIX}SENTRY_ENABLE_LOGS` | `true` | Forward log records to Sentry. |

Log records do not go to Sentry Logs, because they can contain credentials and personal data.
To send them, pass `LoggingIntegration(capture_sentry_logs=True)` or
`LoguruIntegration(capture_sentry_logs=True)` to `boot(sentry_integrations=...)`. foundry-core
requires sentry-sdk 2.68.0 or later, and it has no `{PREFIX}SENTRY_ENABLE_LOGS` setting.

#### OpenTelemetry (`{PREFIX}OTEL_`)

Expand All @@ -178,6 +190,10 @@ via OTLP/gRPC, e.g. to the internal OTel gateway backing the
| `{PREFIX}OTEL_METRICS_ENABLED` | `true` | Export metrics (once `ENABLED`). |
| `{PREFIX}OTEL_LOGS_ENABLED` | `false` | Bridge loguru records into OTLP log export (once `ENABLED`). |

The OTLP log sink uses the same `{PREFIX}LOG_LEVEL` as the stderr and file sinks. It also uses the
`log_filter` that the service passes to `boot()`. Records below the log level, and records that the
filter rejects, do not go to the OTLP endpoint.

Endpoint, service name, and all other exporter behaviour come from the **standard, unprefixed**
[OpenTelemetry environment variables](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/)
the SDK reads itself — not project-prefixed settings:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ dependencies = [
"pydantic-settings>=2,<3",
"python-dotenv>=1,<2",
"rich>=15,<16",
"sentry-sdk>=2,<3",
"sentry-sdk>=2.68.0,<3",
"sqlalchemy[postgresql-psycopgbinary,asyncio]>=2.0.31,<3", # >=2.0.31: first version with postgresql-psycopgbinary extra
"chancy[cron]>=0.25.1,<1",
"nicegui>=3,<4",
Expand Down
30 changes: 23 additions & 7 deletions src/aignostics_foundry_core/AGENTS.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions src/aignostics_foundry_core/boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ def boot(
default) uses that function's own best-practice defaults
(currently ``HTTPXClientInstrumentor``); pass ``[]`` to opt out.
log_filter: Optional loguru filter callable forwarded to
:func:`~aignostics_foundry_core.log.logging_initialize`.
:func:`~aignostics_foundry_core.log.logging_initialize` (stderr and
file sinks) and to :func:`~aignostics_foundry_core.otel.otel_initialize`
(OTLP log sink).
show_cmdline: Whether to include the process command line in the
boot log message (default: ``True``).
"""
Expand All @@ -100,7 +102,7 @@ def boot(
integrations=sentry_integrations,
context=ctx,
)
otel_initialized = otel_initialize(context=ctx, instrumentors=otel_instrumentors)
otel_initialized = otel_initialize(context=ctx, instrumentors=otel_instrumentors, log_filter=log_filter)
_log_boot_message(
context=ctx,
show_cmdline=show_cmdline,
Expand Down
13 changes: 13 additions & 0 deletions src/aignostics_foundry_core/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
- psycopg.pool: Set to WARNING level to suppress verbose INFO logs from
connection pool operations (getconn/putconn). Only non-nominal pool
messages (warnings and errors) are logged.
- httpx, httpx2 and urllib3: Set to WARNING level. These HTTP clients log each
request URL at INFO, and the URL can include the query string of a signed URL.
At WARNING, these request lines do not get to the log sinks or to Sentry.
"""

import contextlib
Expand All @@ -31,6 +34,7 @@
from aignostics_foundry_core.foundry import FoundryContext

_DEFAULT_PROJECT = "foundry"
_HTTP_CLIENT_LOGGERS = ("httpx", "httpx2", "urllib3")


def _validate_file_name(file_name: str | None) -> str | None:
Expand Down Expand Up @@ -186,6 +190,9 @@ def logging_initialize(
handlers based on settings read from environment variables with the
``{ctx.env_prefix}LOG_`` prefix (derived from the context).

Sets the stdlib loggers ``psycopg``, ``psycopg.pool``, ``httpx``, ``httpx2``
and ``urllib3`` to WARNING. These levels are process-wide.

Args:
filter_func: Optional loguru filter callable; receives a ``Record``
and returns ``True`` to keep the message, ``False`` to drop it.
Expand Down Expand Up @@ -234,4 +241,10 @@ def logging_initialize(
logging.getLogger("psycopg.pool").setLevel(logging.WARNING)
logger.trace("psycopg and psycopg.pool loggers set to WARNING to prevent deadlock and reduce noise")

# Suppress the INFO request lines of the HTTP clients. Each line contains the full request URL,
# which can include credentials in the query string (for example, the signature of a signed URL).
for http_client_logger in _HTTP_CLIENT_LOGGERS:
logging.getLogger(http_client_logger).setLevel(logging.WARNING)
logger.trace("{} loggers set to WARNING to keep request URLs out of the logs", ", ".join(_HTTP_CLIENT_LOGGERS))

logger.trace("Logging initialized with level: {}", settings.level)
34 changes: 30 additions & 4 deletions src/aignostics_foundry_core/otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
from pydantic_settings import SettingsConfigDict

from aignostics_foundry_core.foundry import get_context
from aignostics_foundry_core.log import LogSettings
from aignostics_foundry_core.settings import OpaqueSettings

if TYPE_CHECKING:
Expand Down Expand Up @@ -329,6 +330,7 @@ def otel_initialize(
*,
context: FoundryContext | None = None,
instrumentors: list[BaseInstrumentor] | None = None,
log_filter: Callable[[Record], bool] | None = None,
) -> bool:
"""Initialize OpenTelemetry tracing, metrics, and/or logs.

Expand All @@ -344,7 +346,10 @@ def otel_initialize(
``logging``) via a loguru sink that forwards each record into OTel's own
``LoggingHandler`` — loguru has no first-party OTel integration, so this
is the simplest way to reuse the SDK's own record conversion and trace
correlation instead of duplicating it.
correlation instead of duplicating it. The sink drops records below
:attr:`~aignostics_foundry_core.log.LogSettings.level` (``{PREFIX}LOG_LEVEL``)
and records that *log_filter* rejects, the same as the stderr and file sinks
that :func:`~aignostics_foundry_core.log.logging_initialize` adds.

Args:
context: :class:`~aignostics_foundry_core.foundry.FoundryContext` providing
Expand All @@ -356,6 +361,11 @@ def otel_initialize(
``instrument_fastapi``'s purpose). ``None`` (the default) uses
:func:`default_otel_instrumentors`; pass ``[]`` to opt out entirely,
or a longer list to opt into more than the default.
log_filter: Optional loguru filter callable for the OTLP log sink; receives
a ``Record`` and returns ``True`` to keep it, ``False`` to drop it.
:func:`~aignostics_foundry_core.boot.boot` passes the same filter that
it gives to :func:`~aignostics_foundry_core.log.logging_initialize`.
``None`` (the default) keeps all records at or above the log level.

Returns:
bool: ``True`` if OpenTelemetry was initialised successfully, ``False`` otherwise.
Expand Down Expand Up @@ -413,7 +423,8 @@ def otel_initialize(
_otel_metrics_initialize(resource)

if settings.logs_enabled:
_otel_logs_initialize(resource)
log_level = LogSettings(_env_file=ctx.env_file).level # pyright: ignore[reportCallIssue]
_otel_logs_initialize(resource, level=log_level, log_filter=log_filter)

logger.trace("OpenTelemetry integration initialized, exporting to {}.", os.environ[_OTEL_EXPORTER_OTLP_ENDPOINT])

Expand Down Expand Up @@ -511,7 +522,12 @@ def _otel_metrics_initialize(resource: Resource) -> None:
atexit.register(meter_provider.shutdown)


def _otel_logs_initialize(resource: Resource) -> None:
def _otel_logs_initialize(
resource: Resource,
*,
level: str,
log_filter: Callable[[Record], bool] | None,
) -> None:
"""Set up the OTLP ``LoggerProvider`` and bridge loguru records into it.

Split out of :func:`otel_initialize` since logs export is opt-in via
Expand All @@ -524,8 +540,13 @@ def _otel_logs_initialize(resource: Resource) -> None:
often what explains *why* the process is exiting), so this flush matters
most here.

The loguru sink uses *level* and drops a record if
:func:`_otel_log_sink_filter` or *log_filter* rejects it.

Args:
resource: The shared OTel ``Resource`` used by the tracer/meter providers.
level: Minimum loguru level name for the OTLP log sink.
log_filter: Optional service filter; ``None`` keeps all records.
"""
# opentelemetry-python's logs API is still under a leading-underscore module path
# even in stable releases (see open-telemetry/opentelemetry-python#3565) — noqa/
Expand All @@ -550,9 +571,14 @@ def _otel_logs_initialize(resource: Resource) -> None:
logger_provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))
logs.set_logger_provider(logger_provider)
atexit.register(logger_provider.shutdown)

def sink_filter(record: Record) -> bool:
return _otel_log_sink_filter(record) and (log_filter is None or log_filter(record))

logger.add(
_make_otel_log_sink(LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)),
filter=_otel_log_sink_filter,
level=level,
filter=sink_filter,
)


Expand Down
Loading
Loading