Skip to content
Draft
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
34 changes: 34 additions & 0 deletions logfire/_internal/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@
from .integrations.executors import instrument_executors
from .logs import ProxyLoggerProvider
from .metrics import ProxyMeterProvider
from .profiling.exporter import PROFILES_PATH, ProfilesExporter
from .profiling.otlp import resource_from_attributes
from .profiling.supervisor import ProfilingSupervisor
from .scrubbing import NOOP_SCRUBBER, BaseScrubber, Scrubber, ScrubbingOptions
from .server_response import ServerResponseCallback, install_logfire_response_hook
from .stack_info import warn_at_user_stacklevel
Expand Down Expand Up @@ -438,6 +441,7 @@ def configure(
variables: VariablesOptions | LocalVariablesOptions | None = None,
distributed_tracing: bool | None = None,
advanced: AdvancedOptions | None = None,
profiling: bool = False,
**deprecated_kwargs: Unpack[DeprecatedKwargs],
) -> Logfire:
"""Configure the logfire SDK.
Expand Down Expand Up @@ -513,6 +517,9 @@ def configure(
for more information.
This setting always applies globally, and the last value set is used, including the default value.
advanced: Advanced options primarily used for testing by Logfire developers.
profiling: **Experimental.** Set to `True` to continuously profile this process with the
Python 3.15+ sampling profiler and send CPU profiles to Logfire. A no-op with a
warning on older Pythons or unsupported platforms.
"""
from .. import DEFAULT_LOGFIRE_INSTANCE, Logfire

Expand Down Expand Up @@ -646,6 +653,7 @@ def configure(
variables=variables,
distributed_tracing=distributed_tracing,
advanced=advanced,
profiling=profiling,
)

if local:
Expand Down Expand Up @@ -731,6 +739,9 @@ class _LogfireConfigData:
advanced: AdvancedOptions
"""Advanced options primarily used for testing by Logfire developers."""

profiling: bool
"""Whether to continuously profile this process (experimental)."""

def _load_configuration(
self,
# note that there are no defaults here so that the only place
Expand All @@ -756,6 +767,7 @@ def _load_configuration(
variables: VariablesOptions | LocalVariablesOptions | None,
distributed_tracing: bool | None,
advanced: AdvancedOptions | None,
profiling: bool,
) -> None:
"""Merge the given parameters with the environment variables file configurations."""
self.param_manager = param_manager = ParamManager.create(config_dir)
Expand Down Expand Up @@ -861,6 +873,8 @@ def _load_configuration(
metrics = MetricsOptions()
self.metrics = metrics

self.profiling = profiling

if self.service_version is None:
try:
self.service_version = get_git_revision_hash()
Expand Down Expand Up @@ -893,6 +907,7 @@ def __init__(
code_source: CodeSource | None = None,
distributed_tracing: bool | None = None,
advanced: AdvancedOptions | None = None,
profiling: bool = False,
) -> None:
"""Create a new LogfireConfig.

Expand Down Expand Up @@ -923,6 +938,7 @@ def __init__(
variables=variables,
distributed_tracing=distributed_tracing,
advanced=advanced,
profiling=profiling,
)
# initialize with no-ops so that we don't impact OTEL's global config just because logfire is installed
# that is, we defer setting logfire as the otel global config until `configure` is called
Expand All @@ -935,6 +951,7 @@ def __init__(
# This ensures that we only call OTEL's global set_tracer_provider once to avoid warnings.
self._has_set_providers = False
self._initialized = False
self._profiling_supervisor: ProfilingSupervisor | None = None
self._lock = RLock()

def configure(
Expand All @@ -959,6 +976,7 @@ def configure(
variables: VariablesOptions | LocalVariablesOptions | None,
distributed_tracing: bool | None,
advanced: AdvancedOptions | None,
profiling: bool,
) -> None:
with self._lock:
self._initialized = False
Expand All @@ -983,6 +1001,7 @@ def configure(
variables,
distributed_tracing,
advanced,
profiling,
)
self.initialize()

Expand All @@ -995,6 +1014,10 @@ def _initialize(self) -> None:
if self._initialized: # pragma: no cover
return

if self._profiling_supervisor is not None: # pragma: no cover
self._profiling_supervisor.shutdown()
self._profiling_supervisor = None

emscripten = platform_is_emscripten()

with suppress_instrumentation():
Expand Down Expand Up @@ -1042,6 +1065,7 @@ def _initialize(self) -> None:
# https://github.com/open-telemetry/semantic-conventions/blob/e44693245eef815071402b88c3a44a8f7f8f24c8/docs/resource/README.md#service-experimental
# Both recommend generating a UUID.
resource = Resource({'service.instance.id': uuid4().hex}).merge(resource)
profiles_exporter: ProfilesExporter | None = None

head = self.sampling.head
sampler: Sampler | None = None
Expand Down Expand Up @@ -1176,6 +1200,8 @@ def check_tokens():
headers = {'User-Agent': f'logfire/{VERSION}', 'Authorization': token}
session = OTLPExporterHttpSession()
install_logfire_response_hook(session, self.advanced.server_response_hook)
if self.profiling and profiles_exporter is None: # pragma: no cover
profiles_exporter = ProfilesExporter(session, urljoin(base_url, PROFILES_PATH))
span_exporter = BodySizeCheckingOTLPSpanExporter(
endpoint=urljoin(base_url, '/v1/traces'),
session=session,
Expand Down Expand Up @@ -1400,6 +1426,14 @@ def fix_pid(): # pragma: no cover

self._ensure_flush_after_aws_lambda()

if profiles_exporter is not None: # pragma: no cover
self._profiling_supervisor = ProfilingSupervisor(
profiles_exporter,
resource=resource_from_attributes(resource.attributes),
scope_version=VERSION,
)
self._profiling_supervisor.start()

def force_flush(self, timeout_millis: int = 30_000) -> bool:
"""Force flush all spans and metrics.

Expand Down
4 changes: 4 additions & 0 deletions logfire/_internal/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2491,6 +2491,10 @@ def remaining_ms() -> int:
if not remaining: # pragma: no cover
return False

profiling_supervisor = self.config._profiling_supervisor # pyright: ignore[reportPrivateUsage]
if profiling_supervisor is not None:
profiling_supervisor.shutdown(timeout=remaining_ms() / 1000.0)

if flush: # pragma: no branch
self._tracer_provider.force_flush(remaining)
remaining = remaining_ms()
Expand Down
10 changes: 10 additions & 0 deletions logfire/_internal/profiling/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Experimental: ship Python sampling-profiler data to the OpenTelemetry profiles signal.

This package converts the output of the Python 3.15 `profiling.sampling`
profiler (Tachyon) into OTLP profiles and exports them over HTTP.

Status: work in progress. Implemented here are the conversion (`collapsed` +
`otlp`) and the HTTP `exporter`. Still to come: the supervisor that actually
runs the profiler subprocess, wiring into `logfire.configure()`, and per-span
correlation.
"""
48 changes: 48 additions & 0 deletions logfire/_internal/profiling/_proto/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Vendored OTLP profiles protobuf bindings

`profiles_pb2.py` / `profiles_service_pb2.py` (and their `.pyi` stubs) are
**generated** code, vendored here rather than imported from the
`opentelemetry-proto` PyPI package.

## Why vendored

The OpenTelemetry profiles signal is alpha (`v1development`). The
`opentelemetry-proto` PyPI package (≤ 1.41.x) is generated from the
`opentelemetry-proto` repo pinned at `v1.9.0`, which carries an **outdated**
`Sample` message: its field numbers (`values`, `attribute_indices`,
`link_index`) were renumbered in proto `v1.10.0`. A `Sample` serialized with
the stale numbering is silently misread by current consumers (e.g. Grafana
Pyroscope), so we cannot use the PyPI bindings.

These files are generated from `opentelemetry-proto` **v1.10.0** instead.

## When this directory can be deleted

Once the `opentelemetry-proto` PyPI package ships bindings generated from
proto ≥ `v1.10.0`, delete this directory and import from
`opentelemetry.proto.profiles...` directly. The upstream pin bump is tracked
in <https://github.com/open-telemetry/opentelemetry-python/pull/5223>.

## Regeneration

The proto `package` / file paths are renamed to `lf_otlp.*` so the descriptors
do not collide with the (stale) copy registered by the installed
`opentelemetry-proto` package — protobuf's descriptor pool rejects two files
with the same name. This rename is purely about the descriptor registry; it
does not affect the wire format (only field numbers do).

To regenerate, fetch `profiles.proto` and `profiles_service.proto` from
`opentelemetry-proto` at the desired tag, place them under an `lf_otlp/`
directory with `opentelemetry.proto.{profiles.v1development,collector.profiles.v1development}`
rewritten to `lf_otlp.{profiles,collector}`, then:

```sh
uvx --from grpcio-tools --with mypy-protobuf python -m grpc_tools.protoc \
--proto_path=<root> --python_out=<out> --mypy_out=<out> \
lf_otlp/profiles.proto lf_otlp/profiles_service.proto
```

Finally rewrite `from lf_otlp import profiles_pb2` to
`from logfire._internal.profiling._proto import profiles_pb2` in the generated
`profiles_service_pb2.{py,pyi}`. `common`/`resource` come from the installed
`opentelemetry-proto` package (those signals are stable).
11 changes: 11 additions & 0 deletions logfire/_internal/profiling/_proto/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Vendored OTLP profiles protobuf bindings.

Generated from the canonical `open-telemetry/opentelemetry-proto` repo (tag
`v1.10.0`, the `v1development` profiles signal). Vendored because the
`opentelemetry-proto` PyPI package ships an outdated snapshot of this
still-alpha proto: its `Sample` field numbers predate a renumbering and are
wire-incompatible with current consumers (e.g. Grafana Pyroscope).

See `README.md` in this directory for the regeneration recipe and the
condition under which this directory can be deleted.
"""
65 changes: 65 additions & 0 deletions logfire/_internal/profiling/_proto/profiles_pb2.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading