Merge pull request #492 from ggozad/feat/telemetry-improvements
Improve logfire telemetry
This commit is contained in:
commit
3396394468
11 changed files with 163 additions and 22 deletions
|
|
@ -1,6 +1,11 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Logfire spans carry `service.version` and a per-process `service.name` (`haiku-ingester`, `haiku-rag`, `haiku-rag-app`); `OTEL_SERVICE_NAME` / `LOGFIRE_SERVICE_NAME` override the default.
|
||||||
|
- Each docling-serve request emits a `docling_serve.request` span carrying the instance `url` and `attempt`.
|
||||||
|
|
||||||
## [0.65.0] - 2026-07-09
|
## [0.65.0] - 2026-07-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from haiku.rag.client import HaikuRAG
|
||||||
from haiku.rag.config import load_yaml_config
|
from haiku.rag.config import load_yaml_config
|
||||||
from haiku.rag.config.models import AppConfig
|
from haiku.rag.config.models import AppConfig
|
||||||
from haiku.rag.skills.rag import create_skill, get_agent_preamble
|
from haiku.rag.skills.rag import create_skill, get_agent_preamble
|
||||||
|
from haiku.rag.telemetry import configure as configure_telemetry
|
||||||
from haiku.rag.utils import get_model
|
from haiku.rag.utils import get_model
|
||||||
from haiku.skills import (
|
from haiku.skills import (
|
||||||
SkillDeps,
|
SkillDeps,
|
||||||
|
|
@ -30,14 +31,7 @@ from haiku.skills.prompts import build_system_prompt
|
||||||
|
|
||||||
load_dotenv(find_dotenv(usecwd=True))
|
load_dotenv(find_dotenv(usecwd=True))
|
||||||
|
|
||||||
# Configure logfire (only sends data if LOGFIRE_TOKEN is present)
|
configure_telemetry(service_name="haiku-rag-app")
|
||||||
try:
|
|
||||||
import logfire
|
|
||||||
|
|
||||||
logfire.configure(send_to_logfire="if-token-present", console=False)
|
|
||||||
logfire.instrument_pydantic_ai()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
|
|
||||||
|
|
@ -640,7 +640,21 @@ INFO Processing upsert file:///.../a.md (job 5d9a...)
|
||||||
INFO Job 5d9a... succeeded in 0.34s: file:///.../a.md
|
INFO Job 5d9a... succeeded in 0.34s: file:///.../a.md
|
||||||
```
|
```
|
||||||
|
|
||||||
When `LOGFIRE_TOKEN` is set, spans are also shipped to Logfire.
|
When `LOGFIRE_TOKEN` is set, spans are also shipped to Logfire. Spans carry
|
||||||
|
`service.name` (`haiku-ingester`) and `service.version`. To tell concurrent
|
||||||
|
ingestions apart in Logfire, give each process a distinct name via the standard
|
||||||
|
`OTEL_SERVICE_NAME` (or `LOGFIRE_SERVICE_NAME`) environment variable, which
|
||||||
|
overrides the default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OTEL_SERVICE_NAME=ingester-tenant-a haiku-ingester serve
|
||||||
|
```
|
||||||
|
|
||||||
|
The span tree is `ingester.poller.sweep` -> `ingester.job` (tagged with
|
||||||
|
`source_id` and `uri`) -> `document.convert` / `document.chunk`. When a source
|
||||||
|
uses docling-serve, each request emits a `docling_serve.request` span carrying
|
||||||
|
the instance `url` and `attempt`, so a failed conversion can be traced to the
|
||||||
|
exact instance that served it.
|
||||||
|
|
||||||
### Operating against the API
|
### Operating against the API
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ from collections.abc import Awaitable, Callable, Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, cast
|
from typing import Any, Literal, cast
|
||||||
|
|
||||||
import logfire
|
|
||||||
import typer
|
import typer
|
||||||
from dotenv import find_dotenv, load_dotenv
|
from dotenv import find_dotenv, load_dotenv
|
||||||
from huggingface_hub import HfApi, snapshot_download
|
from huggingface_hub import HfApi, snapshot_download
|
||||||
|
|
@ -26,6 +25,7 @@ from haiku.rag.client import HaikuRAG
|
||||||
from haiku.rag.config import AppConfig, find_config_file, load_yaml_config
|
from haiku.rag.config import AppConfig, find_config_file, load_yaml_config
|
||||||
from haiku.rag.config.models import ModelConfig
|
from haiku.rag.config.models import ModelConfig
|
||||||
from haiku.rag.logging import configure_cli_logging
|
from haiku.rag.logging import configure_cli_logging
|
||||||
|
from haiku.rag.telemetry import configure as configure_telemetry
|
||||||
from haiku.rag.utils import get_model, parse_model_option
|
from haiku.rag.utils import get_model, parse_model_option
|
||||||
|
|
||||||
Target = Literal["rag-skill", "analysis-skill"]
|
Target = Literal["rag-skill", "analysis-skill"]
|
||||||
|
|
@ -42,10 +42,7 @@ HF_REPO_ID = "ggozad/haiku-rag-eval-dbs"
|
||||||
|
|
||||||
# Scrubbing off: eval outputs are financial answers with words like "authorized"
|
# Scrubbing off: eval outputs are financial answers with words like "authorized"
|
||||||
# that trip Logfire's secret scrubber and redact the model's answer text.
|
# that trip Logfire's secret scrubber and redact the model's answer text.
|
||||||
logfire.configure(
|
configure_telemetry(service_name="evals", scrubbing=False)
|
||||||
send_to_logfire="if-token-present", service_name="evals", scrubbing=False
|
|
||||||
)
|
|
||||||
logfire.instrument_pydantic_ai()
|
|
||||||
configure_cli_logging()
|
configure_cli_logging()
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ Adds support for 40+ file formats including PDF, DOCX, HTML, and more.
|
||||||
```bash
|
```bash
|
||||||
# Common combinations
|
# Common combinations
|
||||||
uv pip install haiku.rag-slim[docling,anthropic,mxbai]
|
uv pip install haiku.rag-slim[docling,anthropic,mxbai]
|
||||||
uv pip install haiku.rag-slim[docling,groq,logfire]
|
uv pip install haiku.rag-slim[docling,groq]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ from haiku.skills.agent import (
|
||||||
from haiku.skills.models import Skill
|
from haiku.skills.models import Skill
|
||||||
from haiku.skills.prompts import build_system_prompt
|
from haiku.skills.prompts import build_system_prompt
|
||||||
|
|
||||||
configure_telemetry()
|
configure_telemetry(service_name="haiku-rag")
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,9 @@ def main(
|
||||||
from haiku.rag.telemetry import configure as configure_telemetry
|
from haiku.rag.telemetry import configure as configure_telemetry
|
||||||
|
|
||||||
is_production = get_config().environment != "development"
|
is_production = get_config().environment != "development"
|
||||||
configure_telemetry(console=False if is_production else None)
|
configure_telemetry(
|
||||||
|
service_name="haiku-rag", console=False if is_production else None
|
||||||
|
)
|
||||||
|
|
||||||
if get_config().environment != "development":
|
if get_config().environment != "development":
|
||||||
# Suppress warnings in production
|
# Suppress warnings in production
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import httpx
|
||||||
|
|
||||||
from haiku.rag.circuit_breaker import CircuitBreaker
|
from haiku.rag.circuit_breaker import CircuitBreaker
|
||||||
from haiku.rag.config import CircuitBreakerConfig, DoclingServeConfig
|
from haiku.rag.config import CircuitBreakerConfig, DoclingServeConfig
|
||||||
|
from haiku.rag.telemetry import logfire
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -156,8 +157,14 @@ class DoclingServeClient:
|
||||||
base_url = self._pick_url(exclude=frozenset(tried))
|
base_url = self._pick_url(exclude=frozenset(tried))
|
||||||
breaker = self._breaker_for(base_url)
|
breaker = self._breaker_for(base_url)
|
||||||
try:
|
try:
|
||||||
async with self._httpx_client() as client:
|
with logfire.span(
|
||||||
result = await attempt(client, base_url)
|
"docling_serve.request",
|
||||||
|
name=name,
|
||||||
|
url=base_url,
|
||||||
|
attempt=attempt_no,
|
||||||
|
):
|
||||||
|
async with self._httpx_client() as client:
|
||||||
|
result = await attempt(client, base_url)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if not _is_retryable(exc):
|
if not _is_retryable(exc):
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import os
|
||||||
|
from importlib import metadata
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from logfire import Logfire, attach_context, get_context
|
from logfire import Logfire, attach_context, get_context
|
||||||
|
|
@ -19,25 +21,45 @@ def configure(
|
||||||
*,
|
*,
|
||||||
service_name: str | None = None,
|
service_name: str | None = None,
|
||||||
console: Literal[False] | None = False,
|
console: Literal[False] | None = False,
|
||||||
|
scrubbing: Literal[False] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Configure Logfire and enable pydantic-ai instrumentation for the
|
"""Configure Logfire and enable pydantic-ai instrumentation for the
|
||||||
running process. Each CLI entry point calls this once at startup.
|
running process. Each CLI entry point calls this once at startup.
|
||||||
Silently no-ops on failure so a missing/misconfigured LOGFIRE_TOKEN
|
Silently no-ops on failure so a missing/misconfigured LOGFIRE_TOKEN
|
||||||
never crashes the app.
|
never crashes the app.
|
||||||
|
|
||||||
- service_name: identifies the process in the Logfire UI (e.g.
|
- service_name: the default name for this process in the Logfire UI
|
||||||
"haiku-ingester"). Falls back to logfire's default when None.
|
(e.g. "haiku-ingester"). The OTEL_SERVICE_NAME / LOGFIRE_SERVICE_NAME
|
||||||
|
env vars, when set, take precedence so operators can distinguish
|
||||||
|
concurrent processes.
|
||||||
- console: False (default) suppresses span lines on stderr so they
|
- console: False (default) suppresses span lines on stderr so they
|
||||||
don't interleave with RichHandler logs. Pass None to let logfire
|
don't interleave with RichHandler logs. Pass None to let logfire
|
||||||
decide (its own default applies).
|
decide (its own default applies).
|
||||||
|
- scrubbing: None (default) keeps logfire's secret scrubbing on. Pass
|
||||||
|
False to disable it when span content legitimately contains tokens
|
||||||
|
that trip the scrubber (e.g. eval answer text).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import logfire as _lf
|
import logfire as _lf
|
||||||
|
|
||||||
|
# An explicit service_name arg would beat the env vars in logfire's
|
||||||
|
# precedence; deferring to None when an env var is set lets the
|
||||||
|
# operator's OTEL_SERVICE_NAME / LOGFIRE_SERVICE_NAME win over our
|
||||||
|
# per-process default.
|
||||||
|
env_service = os.environ.get("OTEL_SERVICE_NAME") or os.environ.get(
|
||||||
|
"LOGFIRE_SERVICE_NAME"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
service_version = metadata.version("haiku.rag-slim")
|
||||||
|
except metadata.PackageNotFoundError: # pragma: no cover
|
||||||
|
service_version = None
|
||||||
|
|
||||||
_lf.configure(
|
_lf.configure(
|
||||||
service_name=service_name,
|
service_name=None if env_service else service_name,
|
||||||
|
service_version=service_version,
|
||||||
send_to_logfire="if-token-present",
|
send_to_logfire="if-token-present",
|
||||||
console=console,
|
console=console,
|
||||||
|
scrubbing=scrubbing,
|
||||||
)
|
)
|
||||||
_lf.instrument_pydantic_ai()
|
_lf.instrument_pydantic_ai()
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,36 @@ async def test_task_failure_is_not_retried():
|
||||||
assert set(seen) == {"h"}
|
assert set(seen) == {"h"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_span_records_instance_per_attempt(monkeypatch):
|
||||||
|
"""Each attempt opens a docling_serve.request span tagged with the
|
||||||
|
instance URL, so failover is traceable in Logfire."""
|
||||||
|
from contextlib import nullcontext
|
||||||
|
|
||||||
|
from haiku.rag.providers import docling_serve as ds_module
|
||||||
|
|
||||||
|
spans: list[dict] = []
|
||||||
|
|
||||||
|
def _fake_span(span_name, /, **attrs):
|
||||||
|
spans.append({"span_name": span_name, **attrs})
|
||||||
|
return nullcontext()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ds_module.logfire, "span", _fake_span)
|
||||||
|
|
||||||
|
transport, _ = _failover_transport({"down-s"}, "t", {"ok": True})
|
||||||
|
client = DoclingServeClient(
|
||||||
|
base_urls=["http://down-s:5001", "http://up-s:5001"],
|
||||||
|
transport=transport,
|
||||||
|
retry_base_delay=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
await _poll(client)
|
||||||
|
|
||||||
|
requests = [s for s in spans if s["span_name"] == "docling_serve.request"]
|
||||||
|
assert [s["url"] for s in requests] == ["http://down-s:5001", "http://up-s:5001"]
|
||||||
|
assert [s["attempt"] for s in requests] == [0, 1]
|
||||||
|
|
||||||
|
|
||||||
def test_pick_url_skips_excluded_instances():
|
def test_pick_url_skips_excluded_instances():
|
||||||
"""On retry, _pick_url advances past every excluded instance."""
|
"""On retry, _pick_url advances past every excluded instance."""
|
||||||
urls = ["http://p1:5001", "http://p2:5001", "http://p3:5001"]
|
urls = ["http://p1:5001", "http://p2:5001", "http://p3:5001"]
|
||||||
|
|
|
||||||
70
tests/test_telemetry.py
Normal file
70
tests/test_telemetry.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
from importlib import metadata
|
||||||
|
|
||||||
|
import logfire
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from haiku.rag import telemetry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def captured_configure(monkeypatch):
|
||||||
|
"""Capture the kwargs telemetry.configure() passes to logfire.configure,
|
||||||
|
and no-op the instrumentation so tests don't touch a real exporter."""
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _fake_configure(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(logfire, "configure", _fake_configure)
|
||||||
|
monkeypatch.setattr(logfire, "instrument_pydantic_ai", lambda: None)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_service_name_used_when_env_unset(captured_configure, monkeypatch):
|
||||||
|
monkeypatch.delenv("OTEL_SERVICE_NAME", raising=False)
|
||||||
|
monkeypatch.delenv("LOGFIRE_SERVICE_NAME", raising=False)
|
||||||
|
|
||||||
|
telemetry.configure(service_name="haiku-ingester")
|
||||||
|
|
||||||
|
assert captured_configure["service_name"] == "haiku-ingester"
|
||||||
|
|
||||||
|
|
||||||
|
def test_otel_service_name_overrides_default(captured_configure, monkeypatch):
|
||||||
|
monkeypatch.setenv("OTEL_SERVICE_NAME", "customer-ingester")
|
||||||
|
|
||||||
|
telemetry.configure(service_name="haiku-ingester")
|
||||||
|
|
||||||
|
# Deferring to logfire (service_name=None) lets it read the env var,
|
||||||
|
# so the customer's OTEL_SERVICE_NAME wins over our default.
|
||||||
|
assert captured_configure["service_name"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_logfire_service_name_overrides_default(captured_configure, monkeypatch):
|
||||||
|
monkeypatch.delenv("OTEL_SERVICE_NAME", raising=False)
|
||||||
|
monkeypatch.setenv("LOGFIRE_SERVICE_NAME", "customer-ingester")
|
||||||
|
|
||||||
|
telemetry.configure(service_name="haiku-ingester")
|
||||||
|
|
||||||
|
assert captured_configure["service_name"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_version_is_package_version(captured_configure, monkeypatch):
|
||||||
|
monkeypatch.delenv("OTEL_SERVICE_NAME", raising=False)
|
||||||
|
monkeypatch.delenv("LOGFIRE_SERVICE_NAME", raising=False)
|
||||||
|
|
||||||
|
telemetry.configure(service_name="haiku-rag")
|
||||||
|
|
||||||
|
assert captured_configure["service_version"] == metadata.version("haiku.rag-slim")
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrubbing_defaults_to_enabled(captured_configure):
|
||||||
|
telemetry.configure(service_name="haiku-rag")
|
||||||
|
|
||||||
|
# None is logfire's "scrubbing enabled" default.
|
||||||
|
assert captured_configure["scrubbing"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrubbing_can_be_disabled(captured_configure):
|
||||||
|
telemetry.configure(service_name="evals", scrubbing=False)
|
||||||
|
|
||||||
|
assert captured_configure["scrubbing"] is False
|
||||||
Loading…
Reference in a new issue