Skip to content
Open
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
13 changes: 11 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,19 @@ lmnr datasets pull <id> # Pull dataset
## Environment Variables

```
LMNR_PROJECT_API_KEY # API key (can also pass to initialize())
LMNR_BASE_URL # API base URL (default: https://api.lmnr.ai)
LMNR_PROJECT_API_KEY # API key (can also pass to initialize())
LMNR_BASE_URL # API base URL (default: https://api.lmnr.ai)
LMNR_DISABLE_GIT_METADATA # truthy value disables git state collection at initialize()
```

## Git metadata (`sdk/git_metadata.py`)

- `Laminar.initialize()` auto-collects `git.commit` / `git.branch` / `git.dirty` (best-effort `git` subprocess with a 1.5s timeout; CI env-var fallback: `GITHUB_SHA`, `VERCEL_GIT_COMMIT_SHA`, etc.) and merges it into `__global_metadata` at the LOWEST precedence — `LMNR_TRACE_METADATA` and the `metadata=` arg both override git keys. Opt out via `disable_git_metadata=True` or `LMNR_DISABLE_GIT_METADATA`. Eval runs get the same keys in their run metadata via `_with_git_metadata` (composed with `_with_debugger_session_metadata` at the `client.evals.init` call site).
- Collection is cached for the process lifetime in `_collect_git_metadata_cached`; tests that vary cwd/env MUST call `reset_git_metadata_cache()` (the `tests/test_git_metadata.py` autouse fixture does). The disabled check runs OUTSIDE the cache, on every `collect_git_metadata()` call, so the `disable_git_metadata=True` opt-out recorded by `initialize()` (via `set_git_metadata_disabled`) also suppresses eval-run metadata collected later — Bugbot finding on PR #311.
- `tests/conftest.py` sets `LMNR_DISABLE_GIT_METADATA=true` at import time for the whole session (many tests re-run `Laminar.initialize()` themselves) — without it the SDK repo's own git state leaks onto test spans and breaks exact-metadata assertions (e.g. `test_ctx_prop_laminar_span_context`). `tests/test_git_metadata.py` deletes the env var in its autouse fixture and restores `__global_metadata` on teardown.
- Cross-language parity surface with `lmnr-ts/packages/lmnr/src/git-metadata.ts` — keep key names, CI env-var list, precedence, and the truthy set line-comparable.
- `git.dirty` uses `git status --porcelain --untracked-files=no` (tracked changes only); `git.branch` is omitted on a detached HEAD (`rev-parse --abbrev-ref HEAD` returning literal `HEAD`).

## Instrumentation tests (VCR)

- Tests under `tests/test_instrumentations/**` replay via VCR cassettes in `cassettes/<module-name>/`. Sensitive headers are filtered by the root `vcr_config` (`authorization`, `api-key`, `x-api-key`, `x-goog-api-key`).
Expand Down
20 changes: 19 additions & 1 deletion src/lmnr/sdk/evaluations.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from lmnr.sdk.client.synchronous.sync_client import LaminarClient
from lmnr.sdk.datasets import EvaluationDataset, LaminarDataset
from lmnr.sdk.eval_control import EVALUATION_INSTANCES, PREPARE_ONLY
from lmnr.sdk.git_metadata import collect_git_metadata
from lmnr.sdk.laminar import Laminar as L
from lmnr.sdk.log import get_default_logger
from lmnr.sdk.types import (
Expand Down Expand Up @@ -113,6 +114,21 @@ def _with_debugger_session_metadata(
return {**(metadata or {}), SESSION_METADATA_KEY: session_id}


def _with_git_metadata(
metadata: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""Stamp git state (`git.commit` / `git.branch` / `git.dirty`) into eval
run metadata, so the evaluation entity itself — not just its traces —
records which code produced it. Collection is best-effort and cached at
the process level (see `git_metadata.py`); explicit user metadata wins on
key collision. Returns the metadata unchanged when nothing was collected.
"""
git_metadata = collect_git_metadata()
if not git_metadata:
return metadata
return {**git_metadata, **(metadata or {})}
Comment thread
cursor[bot] marked this conversation as resolved.


def get_average_scores(results: list[EvaluationResultDatapoint]) -> dict[str, Numeric]:
per_score_values = {}
for result in results:
Expand Down Expand Up @@ -348,7 +364,9 @@ async def _run(self) -> EvaluationRunResult:
evaluation = await self.client.evals.init(
name=self.name,
group_name=self.group_name,
metadata=_with_debugger_session_metadata(self.metadata),
metadata=_with_git_metadata(
_with_debugger_session_metadata(self.metadata)
),
)
evaluation_id = evaluation.id
project_id = evaluation.projectId
Expand Down
128 changes: 128 additions & 0 deletions src/lmnr/sdk/git_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Best-effort collection of git state for trace metadata.

Collected once per process at `Laminar.initialize()` and merged into the
global trace metadata at the LOWEST precedence, so both `LMNR_TRACE_METADATA`
and the explicit `metadata=` init argument can override any of the keys. The
keys land on every span as `lmnr.association.properties.metadata.git.*` and
flow into `traces.metadata` server-side with no backend changes.

Collection must NEVER crash or block initialization: every git subprocess is
wrapped in a broad try/except with a short timeout, and a gitless environment
(CI checkout without `.git`, git binary missing, sandboxed runtime) degrades
to well-known CI environment variables, then to nothing.

Set `LMNR_DISABLE_GIT_METADATA` to a truthy value ("true", "1", "yes", "on")
to disable collection entirely.

This is a cross-language parity surface with the TS SDK
`lmnr-ts/packages/lmnr/src/git-metadata.ts` — keep the two line-comparable.
"""

import os
import subprocess
from functools import lru_cache

from lmnr.sdk.debug.config import _is_truthy

GIT_COMMIT_METADATA_KEY = "git.commit"
GIT_BRANCH_METADATA_KEY = "git.branch"
GIT_DIRTY_METADATA_KEY = "git.dirty"

_GIT_TIMEOUT_SECONDS = 1.5

# (commit env var, branch env var) per CI/deploy platform, checked in order.
# Used only when git itself is unavailable — e.g. gitless CI checkouts.
_CI_ENV_VARS: list[tuple[str, str]] = [
("GITHUB_SHA", "GITHUB_REF_NAME"),
("VERCEL_GIT_COMMIT_SHA", "VERCEL_GIT_COMMIT_REF"),
("CI_COMMIT_SHA", "CI_COMMIT_REF_NAME"),
("CIRCLE_SHA1", "CIRCLE_BRANCH"),
("RENDER_GIT_COMMIT", "RENDER_GIT_BRANCH"),
("RAILWAY_GIT_COMMIT_SHA", "RAILWAY_GIT_BRANCH"),
]


# Process-level opt-out recorded by `Laminar.initialize(disable_git_metadata=
# True)`. Kept here (not on the Laminar class) so EVERY collection point —
# global trace metadata AND eval run metadata — honors the same flag without
# re-plumbing the init argument through each call site.
_disabled: bool = False


def set_git_metadata_disabled(disabled: bool) -> None:
"""Record the initialize()-time opt-out for later collection points."""
global _disabled
_disabled = disabled


def _git_metadata_disabled() -> bool:
return _disabled or _is_truthy(os.environ.get("LMNR_DISABLE_GIT_METADATA"))


def _run_git(*args: str) -> str | None:
"""Run a git command; return its stripped stdout, None on ANY failure."""
try:
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT_SECONDS,
)
except Exception:
return None
if result.returncode != 0:
return None
return result.stdout.strip()


def collect_git_metadata() -> dict[str, str | bool]:
"""Collect `git.commit` / `git.branch` / `git.dirty`, best-effort.

Returns {} when collection is disabled — via the `disable_git_metadata`
argument to `Laminar.initialize()` or the LMNR_DISABLE_GIT_METADATA env
var. The disabled check runs on every call (NOT inside the cache) so an
opt-out recorded after a prior collection still applies.

`git.branch` is omitted on a detached HEAD, and `git.dirty` counts only
tracked-file changes (untracked build artifacts should not flip it).
"""
if _git_metadata_disabled():
return {}
return _collect_git_metadata_cached()


def reset_git_metadata_cache() -> None:
"""Reset the process-level collection cache. Exposed for tests only."""
_collect_git_metadata_cached.cache_clear()


@lru_cache(maxsize=1)
def _collect_git_metadata_cached() -> dict[str, str | bool]:
"""The actual collection, cached for the process lifetime (git state is
fixed once the process is running; re-running subprocesses per
initialize()/evaluate() call would only add latency). Tests that vary cwd
or env must call `reset_git_metadata_cache()`.
"""
metadata: dict[str, str | bool] = {}
commit = _run_git("rev-parse", "HEAD")
if commit:
metadata[GIT_COMMIT_METADATA_KEY] = commit
branch = _run_git("rev-parse", "--abbrev-ref", "HEAD")
if branch and branch != "HEAD":
metadata[GIT_BRANCH_METADATA_KEY] = branch
status = _run_git("status", "--porcelain", "--untracked-files=no")
if status is not None:
metadata[GIT_DIRTY_METADATA_KEY] = bool(status)
return metadata

# Not a git repo, no git binary, or an unborn HEAD — fall back to CI env
# vars (a gap in Braintrust's approach: gitless CI checkouts get nothing).
for commit_var, branch_var in _CI_ENV_VARS:
env_commit = os.environ.get(commit_var)
if env_commit:
metadata[GIT_COMMIT_METADATA_KEY] = env_commit
env_branch = os.environ.get(branch_var)
if env_branch:
metadata[GIT_BRANCH_METADATA_KEY] = env_branch
break
return metadata
21 changes: 20 additions & 1 deletion src/lmnr/sdk/laminar.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@
from lmnr.opentelemetry_lib.tracing.span import LaminarSpan
from lmnr.opentelemetry_lib.tracing.tracer import get_tracer_with_context
from lmnr.opentelemetry_lib.tracing.utils import set_association_props_in_context
from lmnr.sdk.git_metadata import (
collect_git_metadata,
set_git_metadata_disabled,
)
from lmnr.sdk.utils import (
from_env,
get_otel_env_var,
Expand Down Expand Up @@ -230,6 +234,7 @@ def initialize(
session_recording_options: SessionRecordingOptions | None = None,
force_http: bool = False,
metadata: dict[str, AttributeValue] | None = None,
disable_git_metadata: bool = False,
):
"""Initialize Laminar context across the application.
This method must be called before using any other Laminar methods or
Expand Down Expand Up @@ -282,6 +287,11 @@ def initialize(
Defaults to None (uses default masking behavior).
force_http (bool, optional): If set to True, the HTTP OTEL exporter will be\
used instead of the gRPC OTEL exporter. Defaults to False.
disable_git_metadata (bool, optional): If set to True, git state\
(`git.commit`, `git.branch`, `git.dirty`) is not collected into\
the global trace metadata. Can also be disabled with the\
LMNR_DISABLE_GIT_METADATA environment variable.\
Defaults to False.
Raises:
ValueError: If project API key is not set
"""
Expand Down Expand Up @@ -347,7 +357,16 @@ def initialize(
env_metadata = json.loads(env_metadata_str)
except Exception:
pass
cls.__global_metadata = {**env_metadata, **(metadata or {})}
# Git state merges at the LOWEST precedence so both LMNR_TRACE_METADATA
# and the explicit `metadata=` argument can override any git.* key.
# The opt-out is recorded process-wide so eval run metadata
# (`_with_git_metadata` in evaluations.py) honors it too.
set_git_metadata_disabled(disable_git_metadata)
cls.__global_metadata = {
**collect_git_metadata(),
**env_metadata,
**(metadata or {}),
}

if not os.getenv("OTEL_ATTRIBUTE_COUNT_LIMIT"):
# each message is at least 2 attributes: role and content,
Expand Down
9 changes: 9 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from typing import Generator
import pytest
from unittest.mock import patch
Expand All @@ -14,6 +15,14 @@

pytest_plugins = ("pytest_asyncio",)

# Git collection is disabled for the WHOLE test session (not just the
# session-scoped initialize below): many tests re-run Laminar.initialize()
# themselves, and each such call would otherwise stamp the SDK repo's real
# git state (commit, branch, dirty flag) into the global metadata, breaking
# tests that assert on exact metadata contents. `tests/test_git_metadata.py`
# removes this env var in its fixtures to exercise the collection path.
os.environ["LMNR_DISABLE_GIT_METADATA"] = "true"


@pytest.fixture(scope="session")
def span_exporter() -> SpanExporter:
Expand Down
Loading