diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 000000000..3c4ee07b8 --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,245 @@ +# Codex Guidelines for Langfuse Python + +This is the canonical root agent guide for the repo. The root `AGENTS.md` +should remain only as a discovery symlink so tools that require that filename +continue to work while `.agents/` stays the source of truth. + +Langfuse Python SDK guidance for fast, safe code changes. + +## Maintenance Contract + +- `AGENTS.md` is a living document. +- Update this file in the same PR when repo-level architecture, workflows, + verification requirements, release processes, or agent setup conventions + materially change. +- Update this file when user feedback adds a durable repo-level instruction that + future agents should follow. +- Keep root guidance concise, specific, and easy to verify. If repo-wide + guidance grows large or becomes task-specific, move that detail into shared + skills or future nested `AGENTS.md` files closer to the relevant code. +- If no durable guidance changed, do not edit AGENTS files. + +## Project Overview + +This repository contains the Langfuse Python SDK, a client library for +accessing the Langfuse observability platform. The SDK integrates with +OpenTelemetry for tracing, provides automatic instrumentation for popular LLM +frameworks, and exposes a generated API client for the Langfuse platform. + +## Project Structure + +```text +langfuse-python/ +├─ langfuse/_client/ # Core SDK implementation built on OpenTelemetry +├─ langfuse/api/ # Generated Fern API client (do not hand-edit) +├─ langfuse/_task_manager/ # Background upload and ingestion helpers +├─ langfuse/langchain/ # LangChain integration +├─ tests/ # Test suite +├─ static/ # Test fixtures and sample content +└─ .agents/ # Canonical shared agent instructions and config +``` + +High-signal entry points: + +- `langfuse/_client/client.py`: core Langfuse client with OTel integration +- `langfuse/_client/span.py`: observation/span abstractions +- `langfuse/_client/observe.py`: decorator-based instrumentation +- `langfuse/openai.py`: OpenAI instrumentation helpers +- `langfuse/langchain/CallbackHandler.py`: LangChain integration +- `langfuse/api/`: generated API surface copied from the main Langfuse repo + +## Instruction Design + +- Root `AGENTS.md` should cover durable repo-wide expectations only: setup, + verification, architecture, security, generated files, and handoff rules. +- Prefer concrete, testable instructions over vague phrasing. Name the exact + command, path, module, or condition whenever possible. +- Keep stable tone/role guidance separate from task-specific examples. For + complex prompts or reusable workflows, place examples in skills or referenced + docs instead of bloating the root guide. +- Add nearby nested guidance only when a subdirectory truly needs different + rules. Put the override as close as possible to the specialized code. +- Use shared skills for recurring task-specific workflows that should not be + loaded into context on every task. + +## Build, Test, and Development Commands + +- Agent environment bootstrap: `bash .agents/scripts/codex/setup.sh` +- Install dependencies: `bash .agents/scripts/install.sh --all-extras` +- Sync generated agent shims: `python3 .agents/scripts/sync-agent-shims.py` +- Verify generated agent shims: `python3 .agents/scripts/sync-agent-shims.py --check` +- Install pre-commit hooks: `poetry run pre-commit install` +- Run all tests: `poetry run pytest -s -v --log-cli-level=INFO` +- Run tests in parallel: `poetry run pytest -s -v --log-cli-level=INFO -n auto` +- Run one test: `poetry run pytest -s -v --log-cli-level=INFO tests/test_core_sdk.py::test_flush` +- Format code: `poetry run ruff format .` +- Lint code: `poetry run ruff check .` +- Type-check: `poetry run mypy langfuse --no-error-summary` +- Run pre-commit across the repo: `poetry run pre-commit run --all-files` +- Build package: `poetry build` +- Generate docs: `poetry run pdoc -o docs/ --docformat google --logo "https://langfuse.com/langfuse_logo.svg" langfuse` + +Minimum verification matrix: + +| Change scope | Minimum verification | +| --- | --- | +| `langfuse/_client/**` | `poetry run ruff check .` + `poetry run mypy langfuse --no-error-summary` + targeted pytest coverage | +| `langfuse/api/**` | verify source update path from main repo + `poetry run ruff format .` + targeted API tests | +| Integration modules (`langfuse/openai.py`, `langfuse/langchain/**`) | targeted tests for the touched integration + lint + latest official provider docs review if behavior or API usage changed | +| Test-only changes | targeted pytest coverage for the updated tests | +| Agent setup files (`.agents/**`) | `python3 .agents/scripts/sync-agent-shims.py` + `python3 .agents/scripts/sync-agent-shims.py --check` | + +CI notes: + +- Linting runs via `astral-sh/ruff-action`. +- Type checking runs on Python 3.13 with Poetry, `.venv` caching, and the agent + shim sync/check step. +- The main test matrix runs on Python 3.10 through 3.14. +- Integration CI clones the main `langfuse/langfuse` repo, boots Dockerized + services, seeds the server with `pnpm`, and then runs this SDK's pytest suite + against that local server. +- If a change plausibly depends on server behavior, call out whether it was only + covered by unit tests locally and whether full CI is the real end-to-end + verification path. + +## Architecture + +### Core Components + +- `langfuse/_client/`: main SDK implementation built on OpenTelemetry + - `client.py`: core Langfuse client + - `span.py`: span, generation, and event classes + - `observe.py`: decorator for automatic instrumentation + - `datasets.py`: dataset management functionality +- `langfuse/api/`: auto-generated Fern API client +- `langfuse/_task_manager/`: background processing for uploads and ingestion +- `langfuse/openai.py`: OpenAI instrumentation +- `langfuse/langchain/`: LangChain integration + +### Key Design Patterns + +- The SDK is built on OpenTelemetry for observability. +- Spans are the core tracing primitive. +- Attributes carry trace metadata. See `LangfuseOtelSpanAttributes`. +- The client batches work and flushes asynchronously to the Langfuse API. + +## Generated Files + +- `langfuse/api/**` is generated from the main Langfuse repo. Do not edit it by + hand unless the task is explicitly about generated client updates. +- `docs/` output from `pdoc` is generated. Regenerate it instead of editing + rendered output directly. +- Agent/tool shims at `.mcp.json`, `.claude/settings.json`, `.claude/skills/*`, + `.cursor/mcp.json`, `.cursor/environment.json`, `.vscode/mcp.json`, + `.codex/config.toml`, and `.codex/environments/environment.toml` are local + generated artifacts. Update `.agents/config.json` or `.agents/skills/**` + instead of editing them by hand. +- `AGENTS.md` and `CLAUDE.md` at the repo root are compatibility symlinks. Edit + `.agents/AGENTS.md`, not the symlink target path directly. + +## Configuration + +Environment variables are defined in +`langfuse/_client/environment_variables.py`. + +Common ones: + +- `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY`: API credentials +- `LANGFUSE_HOST`: API endpoint, defaults to `https://cloud.langfuse.com` +- `LANGFUSE_DEBUG`: enable debug logging +- `LANGFUSE_TRACING_ENABLED`: enable or disable tracing +- `LANGFUSE_SAMPLE_RATE`: sampling rate for traces + +Security/config notes: + +- Keep credentials and machine-specific secrets in environment variables or + local untracked files, never in committed agent config. +- The shared Claude settings intentionally deny reading `./.env` and + `./.env.*`, and they do not auto-approve Bash commands by default. If a task + genuinely requires inspecting local env overrides or shell access, get + explicit user approval first instead of weakening the default policy. +- For authenticated MCP servers or provider-specific config additions, prefer + secret injection via environment variables rather than committed inline + tokens. + +## Testing Guidelines + +- Keep tests independent and parallel-safe. +- Do not weaken or delete meaningful assertions just to make tests pass. +- When fixing a bug, write or update the regression test first when feasible. +- E2E tests involving external APIs are often skipped in CI. Document when + manual coverage is still needed. +- Use `respx` and `pytest-httpserver` for HTTP mocking when possible. +- Prefer the narrowest useful test invocation first, then widen coverage when a + change touches shared tracing, batching, or provider integrations. + +## API Generation + +The `langfuse/api/` directory is generated from the Langfuse OpenAPI +specification via Fern. + +Update flow: + +1. Generate the Python SDK in the main `langfuse/langfuse` repo. +2. Copy the generated files from `generated/python` into `langfuse/api/`. +3. Run `poetry run ruff format .`. +4. Run targeted verification for any touched endpoints or types. + +## Release Guidelines + +- Releases are automated via GitHub Actions. +- The release workflow updates `pyproject.toml` and `langfuse/version.py`, + builds the package, publishes to PyPI, and creates a GitHub release. +- Do not change release/versioning flow without updating this file and + `CONTRIBUTING.md`. + +## Agent-specific Notes + +- `.agents/AGENTS.md` is the canonical root guide. +- Root `AGENTS.md` is a symlink to `.agents/AGENTS.md`. +- Root `CLAUDE.md` is a compatibility symlink to `AGENTS.md`. +- Shared agent/tool config lives in `.agents/config.json`. +- Shared agent setup documentation lives in `.agents/README.md`. +- Shared skills live under `.agents/skills/`. +- `.agents/scripts/` is the home for repo-owned agent bootstrap and sync + tooling. +- `python3 .agents/scripts/sync-agent-shims.py` regenerates tool-specific config + shims for Claude, Cursor, VS Code, Codex, and shared MCP discovery files. +- Tool-specific directories such as `.claude/`, `.cursor/`, `.codex/`, and + `.vscode/` remain because those tools discover project settings from fixed + paths. +- Cursor discovery should continue to work through the generated + `.cursor/environment.json` and `.cursor/mcp.json` shims plus the root + `AGENTS.md` symlink. Do not hand-edit those generated files. +- This file should stay concise. Anthropic recommends keeping persistent project + memory under roughly 200 lines, and both Anthropic and OpenAI guidance favor + specific, well-structured instructions over long prose. +- If future `.cursor/rules/*.mdc` files are added, keep them as thin wrappers + around shared `AGENTS.md` guidance or shared skills instead of making them the + only source of durable repo guidance. +- Shared skill index: [`skills/README.md`](skills/README.md) +- When changing OpenAI or Anthropic integrations, prompts, or documented usage: + check the latest official provider docs first, keep prompts simple and direct, + preserve clear separation between stable instructions and task-specific + examples, and mention any provider-facing verification you did not run. + +Official references to start from: + +- OpenAI AGENTS guide: +- OpenAI prompting guide: +- OpenAI reasoning best practices: +- Anthropic Claude Code memory guide: +- Anthropic Claude Code MCP guide: +- Anthropic prompting best practices: + +## Git Notes + +- Do not use destructive git commands such as `reset --hard` unless explicitly + requested. +- Do not revert unrelated working tree changes. +- Keep commits focused and atomic. + +## Python Code Rules + +- Exception messages must not inline f-string literals directly in the `raise`. + Assign the string to a variable first if formatting is required. diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 000000000..d6366ed56 --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,129 @@ +# Shared Agent Setup + +This directory is the neutral, repo-owned source of truth for agent behavior in +Langfuse Python. + +Use `.agents/` for configuration and guidance that should apply across tools. +Do not put durable shared guidance only in `.claude/`, `.codex/`, `.cursor/`, +or `.vscode/`. + +## Layout + +- `AGENTS.md`: canonical shared root instructions +- `config.json`: shared bootstrap and MCP configuration used to generate + tool-specific shims +- `scripts/`: shared bootstrap and sync helpers for agent tooling +- `skills/`: shared, tool-neutral implementation guidance for recurring + workflows + +## `config.json` + +`.agents/config.json` contains five kinds of data: + +- `shared`: defaults used across tools +- `mcpServers`: project MCP servers and how to connect to them +- `claude`: Claude-specific generated settings inputs +- `codex`: Codex-specific generated settings inputs +- `cursor`: Cursor-specific generated settings inputs + +Current shape: + +```json +{ + "shared": { + "setupScript": "bash .agents/scripts/codex/setup.sh", + "devCommand": "poetry run bash", + "devTerminalDescription": "Interactive development shell inside the Poetry environment" + }, + "mcpServers": { + "langfuse-docs": { + "transport": "http", + "url": "https://langfuse.com/api/mcp" + } + }, + "claude": { + "settings": {} + }, + "codex": { + "environment": { + "version": 1, + "name": "langfuse-python" + } + }, + "cursor": { + "environment": { + "agentCanUpdateSnapshot": false + } + } +} +``` + +## How Shims Are Generated + +`.agents/scripts/sync-agent-shims.py` reads `.agents/config.json` and writes the +tool discovery files that those products require. + +Generated local artifacts: + +- `.claude/settings.json` +- `.claude/skills/*` +- `.cursor/environment.json` +- `.cursor/mcp.json` +- `.vscode/mcp.json` +- `.mcp.json` +- `.codex/config.toml` +- `.codex/environments/environment.toml` + +The repo root discovery files remain committed as symlinks: + +- `AGENTS.md` -> `.agents/AGENTS.md` +- `CLAUDE.md` -> `AGENTS.md` + +This keeps provider discovery stable while `.agents/` remains the source of +truth. + +## When To Edit `config.json` + +Edit `.agents/config.json` when you need to: + +- add, remove, or update a shared MCP server +- change the shared setup/bootstrap command +- change the default terminal command or terminal label used by generated shims +- adjust generated Claude, Cursor, or Codex settings that are intentionally + modeled in the shared config + +Do not edit generated shim files by hand. Edit the canonical files in +`.agents/` instead. + +## Workflow + +After editing `.agents/config.json` or `.agents/skills/**`: + +1. Run `python3 scripts/agents/sync-agent-shims.py` +2. Run `python3 .agents/scripts/sync-agent-shims.py --check` +3. Verify you did not stage generated files under `.claude/skills/` or any of + the generated MCP/runtime config paths +4. Update `.agents/AGENTS.md` or `CONTRIBUTING.md` if the shared workflow + materially changed + +`bash .agents/scripts/install.sh --all-extras` is the canonical repo install +flow and wires `poetry install` to `bash .agents/scripts/postinstall.sh`. +`bash .agents/scripts/codex/setup.sh` uses the same path during agent +environment bootstrap. + +## Adding Shared Skills + +Shared skills live under `.agents/skills/`. + +Use them for durable, reusable guidance such as: + +- repeated SDK maintenance workflows +- review checklists that should apply across tools +- repo-specific runbooks that should not live only in a provider-specific folder + +Do not use skills for one-off notes or tool runtime configuration. + +`python3 .agents/scripts/sync-agent-shims.py` projects shared skills into +`.claude/skills/` so Claude can discover the same repo-owned skills. + +For the skill authoring workflow, see [skills/README.md](skills/README.md). diff --git a/.agents/config.json b/.agents/config.json new file mode 100644 index 000000000..132455a83 --- /dev/null +++ b/.agents/config.json @@ -0,0 +1,36 @@ +{ + "shared": { + "setupScript": "bash .agents/scripts/codex/setup.sh", + "devCommand": "poetry run bash", + "devTerminalDescription": "Interactive development shell inside the Poetry environment" + }, + "mcpServers": { + "langfuse-docs": { + "transport": "http", + "url": "https://langfuse.com/api/mcp" + } + }, + "claude": { + "settings": { + "permissions": { + "allow": [], + "deny": [ + "Read(./.env)", + "Read(./.env.*)" + ] + }, + "enableAllProjectMcpServers": true + } + }, + "codex": { + "environment": { + "version": 1, + "name": "langfuse-python" + } + }, + "cursor": { + "environment": { + "agentCanUpdateSnapshot": false + } + } +} diff --git a/.agents/scripts/codex/setup.sh b/.agents/scripts/codex/setup.sh new file mode 100644 index 000000000..ecb0ed653 --- /dev/null +++ b/.agents/scripts/codex/setup.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +poetry config virtualenvs.create true --local +poetry config virtualenvs.in-project true --local + +bash .agents/scripts/install.sh --all-extras diff --git a/.agents/scripts/install.sh b/.agents/scripts/install.sh new file mode 100644 index 000000000..e9ec60641 --- /dev/null +++ b/.agents/scripts/install.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +poetry install "$@" + +bash .agents/scripts/postinstall.sh diff --git a/.agents/scripts/postinstall.sh b/.agents/scripts/postinstall.sh new file mode 100644 index 000000000..45ce21602 --- /dev/null +++ b/.agents/scripts/postinstall.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ ! -f ".agents/scripts/sync-agent-shims.py" ]]; then + echo "Skipping agent shim sync: .agents/scripts/sync-agent-shims.py is not present in this install context." + exit 0 +fi + +python3 .agents/scripts/sync-agent-shims.py +python3 .agents/scripts/sync-agent-shims.py --check diff --git a/.agents/scripts/sync-agent-shims.py b/.agents/scripts/sync-agent-shims.py new file mode 100644 index 000000000..f7df3cd9e --- /dev/null +++ b/.agents/scripts/sync-agent-shims.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +from pathlib import Path +from typing import Any + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true") + parser.add_argument("--repo-root", type=Path) + return parser.parse_args() + + +def load_config(repo_root: Path) -> dict[str, Any]: + source_path = repo_root / ".agents" / "config.json" + return json.loads(source_path.read_text(encoding="utf-8")) + + +def sort_object(value: dict[str, Any]) -> dict[str, Any]: + return {key: value[key] for key in sorted(value)} + + +def format_shared_json_config(servers: dict[str, Any]) -> str: + mcp_servers: dict[str, Any] = {} + + for name, server in sort_object(servers).items(): + if server["transport"] == "stdio": + mcp_servers[name] = { + "command": server["command"], + "args": server.get("args", []), + **({"env": server["env"]} if server.get("env") else {}), + } + else: + mcp_servers[name] = { + "type": "http", + "url": server["url"], + **({"headers": server["headers"]} if server.get("headers") else {}), + } + + return json.dumps({"mcpServers": mcp_servers}, indent=2) + "\n" + + +def format_vscode_config(servers: dict[str, Any]) -> str: + mcp_servers: dict[str, Any] = {} + + for name, server in sort_object(servers).items(): + if server["transport"] == "stdio": + mcp_servers[name] = { + "type": "stdio", + "command": server["command"], + "args": server.get("args", []), + **({"env": server["env"]} if server.get("env") else {}), + } + else: + mcp_servers[name] = { + "type": "http", + "url": server["url"], + **({"headers": server["headers"]} if server.get("headers") else {}), + } + + return json.dumps({"servers": mcp_servers}, indent=2) + "\n" + + +def format_codex_toml(servers: dict[str, Any]) -> str: + lines: list[str] = [] + + for name, server in sort_object(servers).items(): + lines.append(f"[mcp_servers.{name}]") + + if server["transport"] == "stdio": + lines.append(f"command = {json.dumps(server['command'])}") + + args = server.get("args", []) + if args: + lines.append("args = [") + for arg in args: + lines.append(f" {json.dumps(arg)},") + lines.append("]") + else: + lines.append("args = []") + + if server.get("env"): + lines.append(f"[mcp_servers.{name}.env]") + for env_name, env_value in sort_object(server["env"]).items(): + lines.append(f"{env_name} = {json.dumps(env_value)}") + else: + lines.append(f"url = {json.dumps(server['url'])}") + if server.get("headers"): + lines.append(f"[mcp_servers.{name}.headers]") + for header_name, header_value in sort_object(server["headers"]).items(): + lines.append( + f"{json.dumps(header_name)} = {json.dumps(header_value)}" + ) + + lines.append("") + + return "\n".join(lines) + + +def format_claude_settings(config: dict[str, Any]) -> str: + return json.dumps(config["claude"]["settings"], indent=2) + "\n" + + +def format_cursor_environment(config: dict[str, Any]) -> str: + return ( + json.dumps( + { + "agentCanUpdateSnapshot": config["cursor"]["environment"][ + "agentCanUpdateSnapshot" + ], + "install": config["shared"]["setupScript"], + "terminals": [ + { + "name": "Development Terminal", + "command": config["shared"]["devCommand"], + "description": config["shared"]["devTerminalDescription"], + } + ], + }, + indent=2, + ) + + "\n" + ) + + +def format_codex_environment_toml(config: dict[str, Any]) -> str: + lines = [ + "# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY", + f"version = {config['codex']['environment']['version']}", + f"name = {json.dumps(config['codex']['environment']['name'])}", + "", + "[setup]", + f"script = {json.dumps(config['shared']['setupScript'])}", + "", + ] + + return "\n".join(lines) + + +def write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def remove_path(path: Path) -> None: + if not path.exists() and not path.is_symlink(): + return + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() + + +def expected_symlink_target(path: Path, target: Path) -> Path: + return Path(os.path.relpath(target, start=path.parent)) + + +def is_matching_symlink(path: Path, target: Path) -> bool: + if not path.is_symlink(): + return False + return Path(os.readlink(path)) == expected_symlink_target(path, target) + + +def is_within_repo(path: Path, repo_root: Path) -> bool: + try: + path.resolve().relative_to(repo_root.resolve()) + return True + except ValueError: + return False + + +def find_unexpected_children(path: Path, expected_children: set[str]) -> list[Path]: + if not path.exists(): + return [] + return [child for child in path.iterdir() if child.name not in expected_children] + + +def print_error(message: str) -> None: + print(message, file=sys.stderr) + + +def is_writable(path: Path) -> bool: + target = path if path.exists() else path.parent + return os.access(target, os.W_OK) + + +def sync_file_outputs( + file_outputs: list[dict[str, Any]], check_mode: bool +) -> tuple[bool, list[str]]: + has_mismatch = False + warnings: list[str] = [] + + for output in file_outputs: + path: Path = output["path"] + content: str = output["content"] + optional: bool = output.get("optional", False) + + if check_mode: + if optional and not is_writable(path): + warnings.append(f"Skipping optional config check: {path}") + continue + try: + current = path.read_text(encoding="utf-8") + except FileNotFoundError: + if optional: + warnings.append(f"Skipping optional config check: {path}") + continue + has_mismatch = True + print_error( + f'Missing generated config: {path}. Run "python3 .agents/scripts/sync-agent-shims.py".' + ) + continue + + if current != content: + has_mismatch = True + print_error(f"Out of sync: {path}") + continue + + try: + write_text(path, content) + print(f"Updated {path}") + except OSError: + if optional: + warnings.append(f"Skipping optional config generation: {path}") + continue + raise + + return has_mismatch, warnings + + +def sync_symlink_outputs( + symlink_outputs: list[dict[str, Path]], check_mode: bool +) -> bool: + has_mismatch = False + + for output in symlink_outputs: + path = output["path"] + target = output["target"] + + if check_mode: + if not is_matching_symlink(path, target): + has_mismatch = True + print_error( + f'Out of sync symlink: {path}. Run "python3 .agents/scripts/sync-agent-shims.py".' + ) + continue + + path.parent.mkdir(parents=True, exist_ok=True) + + if is_matching_symlink(path, target): + continue + + remove_path(path) + path.symlink_to( + expected_symlink_target(path, target), target_is_directory=target.is_dir() + ) + print(f"Linked {path}") + + return has_mismatch + + +def sync_managed_directories( + managed_directories: list[dict[str, Any]], repo_root: Path, check_mode: bool +) -> bool: + has_mismatch = False + + for directory in managed_directories: + path: Path = directory["path"] + expected_children: set[str] = directory["expected_children"] + + if path.is_symlink(): + message = f"Managed generated shim directory must not be a symlink: {path}" + if check_mode: + has_mismatch = True + print_error(message) + continue + raise RuntimeError(message) + + if path.exists() and not is_within_repo(path, repo_root): + message = ( + f"Managed generated shim directory resolves outside the repository: {path}" + ) + if check_mode: + has_mismatch = True + print_error(message) + continue + raise RuntimeError(message) + + unexpected_children = find_unexpected_children(path, expected_children) + + if not unexpected_children: + continue + + if check_mode: + has_mismatch = True + for child in unexpected_children: + print_error(f"Unexpected generated shim: {child}") + continue + + for child in unexpected_children: + remove_path(child) + print(f"Removed stale generated shim {child}") + + return has_mismatch + + +def main() -> int: + args = parse_args() + repo_root = ( + args.repo_root.resolve() + if args.repo_root is not None + else Path(__file__).resolve().parents[2] + ) + + config = load_config(repo_root) + servers = config["mcpServers"] + check_mode = args.check + + file_outputs = [ + { + "path": repo_root / ".claude" / "settings.json", + "content": format_claude_settings(config), + }, + { + "path": repo_root / ".mcp.json", + "content": format_shared_json_config(servers), + }, + { + "path": repo_root / ".codex" / "environments" / "environment.toml", + "content": format_codex_environment_toml(config), + "optional": True, + }, + { + "path": repo_root / ".cursor" / "mcp.json", + "content": format_shared_json_config(servers), + }, + { + "path": repo_root / ".cursor" / "environment.json", + "content": format_cursor_environment(config), + }, + { + "path": repo_root / ".vscode" / "mcp.json", + "content": format_vscode_config(servers), + }, + { + "path": repo_root / ".codex" / "config.toml", + "content": format_codex_toml(servers), + "optional": True, + }, + ] + + skills_root = repo_root / ".agents" / "skills" + shared_skill_names = sorted( + entry.name + for entry in (skills_root.iterdir() if skills_root.exists() else []) + if entry.is_dir() and (entry / "SKILL.md").exists() + ) + + symlink_outputs = [ + { + "path": repo_root / "AGENTS.md", + "target": repo_root / ".agents" / "AGENTS.md", + }, + { + "path": repo_root / "CLAUDE.md", + "target": repo_root / "AGENTS.md", + }, + *[ + { + "path": repo_root / ".claude" / "skills" / name, + "target": skills_root / name, + } + for name in shared_skill_names + ], + ] + + managed_directories = [ + { + "path": repo_root / ".claude" / "skills", + "expected_children": set(shared_skill_names), + } + ] + + has_mismatch, warnings = sync_file_outputs(file_outputs, check_mode) + for warning in warnings: + print(warning) + + has_mismatch = sync_symlink_outputs(symlink_outputs, check_mode) or has_mismatch + has_mismatch = ( + sync_managed_directories(managed_directories, repo_root, check_mode) + or has_mismatch + ) + + return 1 if check_mode and has_mismatch else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/README.md b/.agents/skills/README.md new file mode 100644 index 000000000..fc665be21 --- /dev/null +++ b/.agents/skills/README.md @@ -0,0 +1,31 @@ +# Shared Skills + +Shared repo skills for any coding agent working in Langfuse Python. + +Use these from `AGENTS.md`. Claude Code reaches the same shared instructions via +the root `CLAUDE.md` compatibility symlink. Shared skills should stay focused on +reusable implementation guidance rather than runtime automation. + +For the shared agent config and generated shim model, start with +[`../README.md`](../README.md). + +Shared skills should use progressive disclosure: + +- keep `SKILL.md` short +- link to focused `references/` docs instead of copying long guidance into one + file +- add helper scripts only when they materially reduce repeated work + +There are no repo-level shared skills yet. Add one when a workflow is repeated +often enough that it should be standardized across tools. + +## Adding a New Shared Skill + +1. Create a folder under `.agents/skills//`. +2. Add a short `SKILL.md` entrypoint. +3. Add `references/` docs or helper scripts only when they are needed. +4. Keep the skill tightly scoped to one domain or workflow. +5. Link the skill from `.agents/AGENTS.md` if it is broadly relevant. +6. Run `python3 scripts/agents/sync-agent-shims.py`. +7. Run `python3 scripts/agents/sync-agent-shims.py --check`. +8. Run `poetry run pytest tests/test_sync_agent_shims.py`. diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 43ef52f6b..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(find:*)", - "Bash(rg:*)", - "Bash(grep:*)", - "Bash(ls:*)" - ], - "deny": [] - } -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 113733a84..0d2753bf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: restore-keys: | mypy- - name: Install dependencies - run: poetry install --only=main,dev + run: bash .agents/scripts/install.sh --only=main,dev - name: Run mypy type checking run: poetry run mypy langfuse --no-error-summary @@ -185,7 +185,7 @@ jobs: venv-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }}-${{ github.sha }} - name: Install the project dependencies - run: poetry install --all-extras + run: bash .agents/scripts/install.sh --all-extras - name: Run the automated tests run: | diff --git a/.gitignore b/.gitignore index bebafdd05..1054e4207 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,13 @@ docs tests/mocks/llama-index-storage *.local.* + +# Agent shims generated from .agents/config.json +.mcp.json +.claude/settings.json +.claude/skills/ +.cursor/mcp.json +.cursor/environment.json +.vscode/mcp.json +.codex/config.toml +.codex/environments/environment.toml diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 000000000..41280d2fa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +.agents/AGENTS.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 6dc8afbb2..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,135 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -This is the Langfuse Python SDK, a client library for accessing the Langfuse observability platform. The SDK provides integration with OpenTelemetry (OTel) for tracing, automatic instrumentation for popular LLM frameworks (OpenAI, Langchain, etc.), and direct API access to Langfuse's features. - -## Development Commands - -### Setup -```bash -# Install Poetry plugins (one-time setup) -poetry self add poetry-dotenv-plugin - -# Install all dependencies including optional extras -poetry install --all-extras - -# Setup pre-commit hooks -poetry run pre-commit install -``` - -### Testing -```bash -# Run all tests with verbose output -poetry run pytest -s -v --log-cli-level=INFO - -# Run a specific test -poetry run pytest -s -v --log-cli-level=INFO tests/test_core_sdk.py::test_flush - -# Run tests in parallel (faster) -poetry run pytest -s -v --log-cli-level=INFO -n auto -``` - -### Code Quality -```bash -# Format code with Ruff -poetry run ruff format . - -# Run linting (development config) -poetry run ruff check . - -# Run type checking -poetry run mypy . - -# Run pre-commit hooks manually -poetry run pre-commit run --all-files -``` - -### Building and Releasing -```bash -# Build the package locally (for testing) -poetry build - -# Generate documentation -poetry run pdoc -o docs/ --docformat google --logo "https://langfuse.com/langfuse_logo.svg" langfuse -``` - -Releases are automated via GitHub Actions. To release: -1. Go to Actions > "Release Python SDK" workflow -2. Click "Run workflow" -3. Select version bump type (patch/minor/major/prerelease) -4. For prereleases, select the type (alpha/beta/rc) - -The workflow handles versioning, building, PyPI publishing (via OIDC), and GitHub release creation. - -## Architecture - -### Core Components - -- **`langfuse/_client/`**: Main SDK implementation built on OpenTelemetry - - `client.py`: Core Langfuse client with OTel integration - - `span.py`: LangfuseSpan, LangfuseGeneration, LangfuseEvent classes - - `observe.py`: Decorator for automatic instrumentation - - `datasets.py`: Dataset management functionality - -- **`langfuse/api/`**: Auto-generated Fern API client - - Contains all API resources and types - - Generated from OpenAPI spec - do not manually edit these files - -- **`langfuse/_task_manager/`**: Background processing - - Media upload handling and queue management - - Score ingestion consumer - -- **Integration modules**: - - `langfuse/openai.py`: OpenAI instrumentation - - `langfuse/langchain/`: Langchain integration via CallbackHandler - -### Key Design Patterns - -The SDK is built on OpenTelemetry for observability, using: -- Spans for tracing LLM operations -- Attributes for metadata (see `LangfuseOtelSpanAttributes`) -- Resource management for efficient batching and flushing - -The client follows an async-first design with automatic batching of events and background flushing to the Langfuse API. - -## Configuration - -Environment variables (defined in `_client/environment_variables.py`): -- `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY`: API credentials -- `LANGFUSE_HOST`: API endpoint (defaults to https://cloud.langfuse.com) -- `LANGFUSE_DEBUG`: Enable debug logging -- `LANGFUSE_TRACING_ENABLED`: Enable/disable tracing -- `LANGFUSE_SAMPLE_RATE`: Sampling rate for traces - -## Testing Notes - -- Create `.env` file based on `.env.template` for integration tests -- E2E tests with external APIs (OpenAI, SERP) are typically skipped in CI -- Remove `@pytest.mark.skip` decorators in test files to run external API tests -- Tests use `respx` for HTTP mocking and `pytest-httpserver` for test servers - -## Important Files - -- `pyproject.toml`: Poetry configuration, dependencies, and tool settings -- `langfuse/version.py`: Version string (updated by CI release workflow) - -## API Generation - -The `langfuse/api/` directory is auto-generated from the Langfuse OpenAPI specification using Fern. To update: - -1. Generate new SDK in main Langfuse repo -2. Copy generated files from `generated/python` to `langfuse/api/` -3. Run `poetry run ruff format .` to format the generated code - -## Testing Guidelines - -### Approach to Test Changes -- Don't remove functionality from existing unit tests just to make tests pass. Only change the test, if underlying code changes warrant a test change. - -## Python Code Rules - -### Exception Handling -- Exception must not use an f-string literal, assign to variable first diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 14a42a10d..f8a57a24c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,9 +11,49 @@ poetry self add poetry-dotenv-plugin ### Install dependencies ``` -poetry install --all-extras +bash .agents/scripts/install.sh --all-extras ``` +### Shared Agent Setup + +This repository keeps the shared agent setup in source control so contributors +using Claude, Cursor, Codex, or VS Code can work against the same instructions +and MCP/bootstrap configuration. + +- Canonical shared docs: + - `.agents/AGENTS.md` +- Root discovery symlinks: + - `AGENTS.md` + - `CLAUDE.md` +- Shared agent setup overview: + - `.agents/README.md` +- Shared skills: + - `.agents/skills/` +- Shared tool/bootstrap/MCP config: + - `.agents/config.json` +- Tool-specific MCP configs generated locally from that catalog and not + committed: + - `.mcp.json` + - `.cursor/mcp.json` + - `.vscode/mcp.json` + - `.codex/config.toml` +- Tool-specific runtime shims generated locally from the shared config and not + committed: + - `.claude/settings.json` + - `.cursor/environment.json` + - `.codex/environments/environment.toml` +- Tool-specific skill projections generated locally and not committed: + - `.claude/skills/*` +- Shared bootstrap for agent environments: + - `bash .agents/scripts/codex/setup.sh` + +When you change the shared agent setup: + +1. Edit `.agents/config.json` or `.agents/skills/**` +2. Run `python3 .agents/scripts/sync-agent-shims.py` +3. Run `python3 .agents/scripts/sync-agent-shims.py --check` +4. Do not commit the generated MCP config files or runtime shims + ### Add Pre-commit ``` @@ -84,7 +124,7 @@ Note: The generated SDK reference is currently work in progress. The SDK reference is generated via pdoc. You need to have all extra dependencies installed to generate the reference. ```sh -poetry install --all-extras +bash .agents/scripts/install.sh --all-extras ``` To update the reference, run the following command: