Skip to content

Commit d654bed

Browse files
Kludexclaude
andcommitted
feat: Add logfire.url_from_eval(report) to generate Logfire dashboard links for eval reports
Users running pydantic-evals evaluations can now easily get a Logfire dashboard link to view their evaluation report by calling `logfire.url_from_eval(report)`. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent b6fb3fc commit d654bed

File tree

8 files changed

+162
-14
lines changed

8 files changed

+162
-14
lines changed

logfire-api/logfire_api/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ def instrument_system_metrics(self, *args, **kwargs) -> None: ...
201201

202202
def instrument_mcp(self, *args, **kwargs) -> None: ...
203203

204+
def url_from_eval(self, *args, **kwargs) -> None: ...
205+
204206
def shutdown(self, *args, **kwargs) -> None: ...
205207

206208
DEFAULT_LOGFIRE_INSTANCE = Logfire()
@@ -254,6 +256,7 @@ def shutdown(self, *args, **kwargs) -> None: ...
254256
instrument_mcp = DEFAULT_LOGFIRE_INSTANCE.instrument_mcp
255257
shutdown = DEFAULT_LOGFIRE_INSTANCE.shutdown
256258
suppress_scopes = DEFAULT_LOGFIRE_INSTANCE.suppress_scopes
259+
url_from_eval = DEFAULT_LOGFIRE_INSTANCE.url_from_eval
257260

258261
def loguru_handler() -> dict[str, Any]:
259262
return {}

logfire/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
with_tags = DEFAULT_LOGFIRE_INSTANCE.with_tags
6464
# with_trace_sample_rate = DEFAULT_LOGFIRE_INSTANCE.with_trace_sample_rate
6565
with_settings = DEFAULT_LOGFIRE_INSTANCE.with_settings
66+
url_from_eval = DEFAULT_LOGFIRE_INSTANCE.url_from_eval
6667

6768
# Logging
6869
log = DEFAULT_LOGFIRE_INSTANCE.log
@@ -176,4 +177,5 @@ def loguru_handler() -> Any:
176177
'set_baggage',
177178
'get_context',
178179
'attach_context',
180+
'url_from_eval',
179181
)

logfire/_internal/config.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,7 @@ def _load_configuration(
696696
self.advanced = advanced
697697

698698
self.additional_span_processors = additional_span_processors
699+
self.project_url: str | None = None
699700

700701
if metrics is None:
701702
metrics = MetricsOptions()
@@ -969,6 +970,7 @@ def add_span_processor(span_processor: SpanProcessor) -> None:
969970
# This means that e.g. a token in an env var takes priority over a token in a creds file.
970971
self.token = self.token or credentials.token
971972
self.advanced.base_url = self.advanced.base_url or credentials.logfire_api_url
973+
self.project_url = self.project_url or credentials.project_url
972974

973975
if self.token:
974976
# Convert to list for iteration (handles both str and list[str])
@@ -994,12 +996,10 @@ def check_tokens():
994996
with suppress_instrumentation():
995997
for token in token_list:
996998
validated_credentials = self._initialize_credentials_from_token(token)
997-
if (
998-
validated_credentials is not None
999-
and show_project_link
1000-
and token not in printed_tokens
1001-
):
1002-
validated_credentials.print_token_summary()
999+
if validated_credentials is not None:
1000+
self.project_url = self.project_url or validated_credentials.project_url
1001+
if show_project_link and token not in printed_tokens:
1002+
validated_credentials.print_token_summary()
10031003

10041004
if emscripten: # pragma: no cover
10051005
check_tokens()

logfire/_internal/main.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
from flask.app import Flask
8383
from opentelemetry.instrumentation.asgi.types import ClientRequestHook, ClientResponseHook, ServerRequestHook
8484
from opentelemetry.metrics import _Gauge as Gauge
85+
from pydantic_evals.reporting import EvaluationReport
8586
from pymongo.monitoring import CommandFailedEvent, CommandStartedEvent, CommandSucceededEvent
8687
from sqlalchemy import Engine
8788
from sqlalchemy.ext.asyncio import AsyncEngine
@@ -876,6 +877,22 @@ def force_flush(self, timeout_millis: int = 3_000) -> bool: # pragma: no cover
876877
"""
877878
return self._config.force_flush(timeout_millis)
878879

880+
def url_from_eval(self, report: EvaluationReport[Any, Any, Any]) -> str | None:
881+
"""Generate a Logfire URL to view an evaluation report.
882+
883+
Args:
884+
report: An evaluation report from `pydantic_evals`.
885+
886+
Returns:
887+
The URL string, or `None` if the project URL or trace/span IDs are not available.
888+
"""
889+
project_url = self._config.project_url
890+
trace_id = report.trace_id
891+
span_id = report.span_id
892+
if not project_url or not trace_id or not span_id:
893+
return None
894+
return f'{project_url}/evals/compare?experiment={trace_id}-{span_id}'
895+
879896
def log_slow_async_callbacks(self, slow_duration: float = 0.1) -> AbstractContextManager[None]:
880897
"""Log a warning whenever a function running in the asyncio event loop blocks for too long.
881898

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ dev = [
180180
"pytest-xdist>=3.6.1",
181181
"openai-agents[voice]>=0.0.7",
182182
"pydantic-ai-slim>=0.0.39",
183+
"pydantic-evals>=0.0.39",
183184
"langchain>=0.0.27",
184185
"langchain-openai>=0.3.17",
185186
"langgraph >= 0",

tests/test_logfire_api.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,10 @@ def func() -> None: ...
289289
pass
290290
logfire__all__.remove('attach_context')
291291

292+
assert hasattr(logfire_api, 'url_from_eval')
293+
logfire_api.url_from_eval(MagicMock(trace_id='abc', span_id='def'))
294+
logfire__all__.remove('url_from_eval')
295+
292296
# If it's not empty, it means that some of the __all__ members are not tested.
293297
assert logfire__all__ == set(), logfire__all__
294298

tests/test_url_from_eval.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
try:
6+
from pydantic_evals.reporting import EvaluationReport
7+
except Exception:
8+
pytest.skip('pydantic_evals not importable (likely pydantic < 2.8)', allow_module_level=True)
9+
10+
import logfire
11+
from logfire._internal.config import LogfireConfig
12+
13+
14+
def _make_report(trace_id: str | None = None, span_id: str | None = None) -> EvaluationReport:
15+
return EvaluationReport(name='test', cases=[], trace_id=trace_id, span_id=span_id)
16+
17+
18+
def test_url_from_eval_with_project_url() -> None:
19+
config = LogfireConfig(send_to_logfire=False, console=False)
20+
config.project_url = 'https://logfire.pydantic.dev/my-org/my-project'
21+
instance = logfire.Logfire(config=config)
22+
23+
report = _make_report(trace_id='abc123', span_id='def456')
24+
result = instance.url_from_eval(report)
25+
assert result == 'https://logfire.pydantic.dev/my-org/my-project/evals/compare?experiment=abc123-def456'
26+
27+
28+
def test_url_from_eval_no_project_url() -> None:
29+
config = LogfireConfig(send_to_logfire=False, console=False)
30+
instance = logfire.Logfire(config=config)
31+
32+
report = _make_report(trace_id='abc123', span_id='def456')
33+
result = instance.url_from_eval(report)
34+
assert result is None
35+
36+
37+
def test_url_from_eval_no_trace_id() -> None:
38+
config = LogfireConfig(send_to_logfire=False, console=False)
39+
config.project_url = 'https://logfire.pydantic.dev/my-org/my-project'
40+
instance = logfire.Logfire(config=config)
41+
42+
report = _make_report(span_id='def456')
43+
result = instance.url_from_eval(report)
44+
assert result is None
45+
46+
47+
def test_url_from_eval_no_span_id() -> None:
48+
config = LogfireConfig(send_to_logfire=False, console=False)
49+
config.project_url = 'https://logfire.pydantic.dev/my-org/my-project'
50+
instance = logfire.Logfire(config=config)
51+
52+
report = _make_report(trace_id='abc123')
53+
result = instance.url_from_eval(report)
54+
assert result is None
55+
56+
57+
def test_url_from_eval_no_ids() -> None:
58+
config = LogfireConfig(send_to_logfire=False, console=False)
59+
config.project_url = 'https://logfire.pydantic.dev/my-org/my-project'
60+
instance = logfire.Logfire(config=config)
61+
62+
report = _make_report()
63+
result = instance.url_from_eval(report)
64+
assert result is None

uv.lock

Lines changed: 65 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)