Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: move langfuse wrapper to a module in exchange instead of a package #138

Merged
merged 17 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from 16 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ celerybeat.pid
.env.*
.venv

# exception for local langfuse init vars
!**/packages/exchange/.env.langfuse.local

# Spyder project settings
.spyderproject
.spyproject
Expand Down
15 changes: 15 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ or, as a shortcut,
just test
```

### Enable traces in Goose with [locally hosted Langfuse](https://langfuse.com/docs/deployment/self-host)
> [!NOTE]
> This integration is experimental and we don't currently have integration tests for it.

Developers can use locally hosted Langfuse tracing by applying the custom `observe_wrapper` decorator defined in `packages/exchange/src/langfuse_wrapper.py` to functions for automatic integration with Langfuse.

- Run `just langfuse-server` to start your local Langfuse server. It requires Docker.
- Go to http://localhost:3000 and log in with the default email/password output by the shell script (values can also be found in the `.env.langfuse.local` file).
- Run Goose with the --tracing flag enabled i.e., `goose session start --tracing`
- View your traces at http://localhost:3000

To extend tracing to additional functions, import `from exchange.langfuse_wrapper import observe_wrapper` and use the `observe_wrapper()` decorator on functions you wish to enable tracing for. `observe_wrapper` functions the same way as Langfuse's observe decorator.

Read more about Langfuse's decorator-based tracing [here](https://langfuse.com/docs/sdk/python/decorators).

## Exchange

The lower level generation behind goose is powered by the [`exchange`][ai-exchange] package, also in this repo.
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,34 @@ goose session resume

To see more documentation on the CLI commands currently available to Goose check out the documentation [here][cli]. If you’d like to develop your own CLI commands for Goose, check out the [Contributing document][contributing].

### Tracing with Langfuse
> [!NOTE]
> This Langfuse integration is experimental and we don't currently have integration tests for it.

The exchange package provides a [Langfuse](https://langfuse.com/) wrapper module. The wrapper serves to initialize Langfuse appropriately if the Langfuse server is running locally and otherwise to skip applying the Langfuse observe descorators.

#### Start your local Langfuse server

Run `just langfuse-server` to start your local Langfuse server. It requires Docker.

Read more about local Langfuse deployments [here](https://langfuse.com/docs/deployment/local).

#### Exchange and Goose integration

Import `from exchange.langfuse_wrapper import observe_wrapper` and use the `observe_wrapper()` decorator on functions you wish to enable tracing for. `observe_wrapper` functions the same way as Langfuse's observe decorator.

Read more about Langfuse's decorator-based tracing [here](https://langfuse.com/docs/sdk/python/decorators).

In Goose, initialization requires certain environment variables to be present:

- `LANGFUSE_PUBLIC_KEY`: Your Langfuse public key
- `LANGFUSE_SECRET_KEY`: Your Langfuse secret key
- `LANGFUSE_BASE_URL`: The base URL of your Langfuse instance

By default your local deployment and Goose will use the values in `.env.langfuse.local`.



### Next steps

Learn how to modify your Goose profiles.yaml file to add and remove functionality (toolkits) and providing context to get the most out of Goose in our [Getting Started Guide][getting-started].
Expand Down
4 changes: 3 additions & 1 deletion docs/plugins/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ Lists the version of Goose and any associated plugins.

**Usage:**
```sh
goose session start [--profile PROFILE] [--plan PLAN]
goose session start [--profile PROFILE] [--plan PLAN] [--log-level [DEBUG|INFO|WARNING|ERROR|CRITICAL]] [--tracing]
```

Starts a new Goose session.

If you want to enable locally hosted Langfuse tracing, pass the --tracing flag after starting your local Langfuse server as outlined in the [Contributing Guide's][contributing] Development guidelines.

#### `resume`

**Usage:**
Expand Down
3 changes: 3 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,6 @@ tag-push:
# get commit messages for a release
release-notes:
git log --pretty=format:"- %s" v$(just tag_version)..HEAD

langfuse-server:
./scripts/setup_langfuse.sh
16 changes: 16 additions & 0 deletions packages/exchange/.env.langfuse.local
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# These variables are default initialization variables for locally hosted Langfuse server
LANGFUSE_INIT_PROJECT_NAME=goose-local
LANGFUSE_INIT_PROJECT_PUBLIC_KEY=publickey-local
LANGFUSE_INIT_PROJECT_SECRET_KEY=secretkey-local
[email protected]
LANGFUSE_INIT_USER_NAME=localdev
LANGFUSE_INIT_USER_PASSWORD=localpwd

LANGFUSE_INIT_ORG_ID=local-id
LANGFUSE_INIT_ORG_NAME=local-org
LANGFUSE_INIT_PROJECT_ID=goose

# These variables are used by Goose
LANGFUSE_PUBLIC_KEY=publickey-local
LANGFUSE_SECRET_KEY=secretkey-local
LANGFUSE_HOST=http://localhost:3000
2 changes: 2 additions & 0 deletions packages/exchange/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ dependencies = [
"tiktoken>=0.7.0",
"httpx>=0.27.0",
"tenacity>=9.0.0",
"python-dotenv>=1.0.1",
"langfuse>=2.38.2"
]

[tool.hatch.build.targets.wheel]
Expand Down
2 changes: 2 additions & 0 deletions packages/exchange/src/exchange/exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Any, Dict, List, Mapping, Tuple

from attrs import define, evolve, field, Factory
from exchange.langfuse_wrapper import observe_wrapper
from tiktoken import get_encoding

from exchange.checkpoint import Checkpoint, CheckpointData
Expand Down Expand Up @@ -127,6 +128,7 @@ def reply(self, max_tool_use: int = 128) -> Message:

return response

@observe_wrapper()
def call_function(self, tool_use: ToolUse) -> ToolResult:
"""Call the function indicated by the tool use"""
tool = self._toolmap.get(tool_use.name)
Expand Down
84 changes: 84 additions & 0 deletions packages/exchange/src/exchange/langfuse_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""
Langfuse Integration Module

This module provides integration with Langfuse, a tool for monitoring and tracing LLM applications.

Usage:
Import this module to enable Langfuse integration.
It automatically checks for Langfuse credentials in the .env.langfuse file and for a running Langfuse server.
If these are found, it will set up the necessary client and context for tracing.

Note:
Run setup_langfuse.sh which automates the steps for running local Langfuse.
"""

import os
from typing import Callable
from dotenv import load_dotenv
from langfuse.decorators import langfuse_context
import sys
from io import StringIO
from pathlib import Path
from functools import wraps # Add this import


def find_package_root(start_path: Path, marker_file: str = "pyproject.toml") -> Path:
while start_path != start_path.parent:
if (start_path / marker_file).exists():
return start_path
start_path = start_path.parent
return None


def auth_check() -> bool:
# Temporarily redirect stdout and stderr to suppress print statements from Langfuse
temp_stderr = StringIO()
sys.stderr = temp_stderr

# Load environment variables
load_dotenv(LANGFUSE_ENV_FILE, override=True)

auth_val = langfuse_context.auth_check()

# Restore stderr
sys.stderr = sys.__stderr__
return auth_val


CURRENT_DIR = Path(__file__).parent
PACKAGE_ROOT = find_package_root(CURRENT_DIR)

LANGFUSE_ENV_FILE = os.path.join(PACKAGE_ROOT, ".env.langfuse.local")
HAS_LANGFUSE_CREDENTIALS = False
load_dotenv(LANGFUSE_ENV_FILE, override=True)

HAS_LANGFUSE_CREDENTIALS = auth_check()


def observe_wrapper(*args, **kwargs) -> Callable: # noqa
"""
A decorator that wraps a function with Langfuse context observation if credentials are available.

If Langfuse credentials were found, the function will be wrapped with Langfuse's observe method.
Otherwise, the function will be returned as-is.

Args:
*args: Positional arguments to pass to langfuse_context.observe.
**kwargs: Keyword arguments to pass to langfuse_context.observe.

Returns:
Callable: The wrapped function if credentials are available, otherwise the original function.
"""

def _wrapper(fn: Callable) -> Callable:
if HAS_LANGFUSE_CREDENTIALS:

@wraps(fn)
def wrapped_fn(*fargs, **fkwargs): # noqa
return langfuse_context.observe(*args, **kwargs)(fn)(*fargs, **fkwargs)

return wrapped_fn
else:
return fn

return _wrapper
2 changes: 2 additions & 0 deletions packages/exchange/src/exchange/providers/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from exchange.providers.base import Provider, Usage
from tenacity import retry, wait_fixed, stop_after_attempt
from exchange.providers.utils import retry_if_status, raise_for_status
from exchange.langfuse_wrapper import observe_wrapper

ANTHROPIC_HOST = "https://api.anthropic.com/v1/messages"

Expand Down Expand Up @@ -123,6 +124,7 @@ def messages_to_anthropic_spec(messages: List[Message]) -> List[Dict[str, Any]]:
messages_spec.append(converted)
return messages_spec

@observe_wrapper(as_type="generation")
def complete(
self,
model: str,
Expand Down
2 changes: 2 additions & 0 deletions packages/exchange/src/exchange/providers/bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from tenacity import retry, wait_fixed, stop_after_attempt
from exchange.providers.utils import raise_for_status, retry_if_status
from exchange.tool import Tool
from exchange.langfuse_wrapper import observe_wrapper

SERVICE = "bedrock-runtime"
UTC = timezone.utc
Expand Down Expand Up @@ -175,6 +176,7 @@ def from_env(cls: Type["BedrockProvider"]) -> "BedrockProvider":
)
return cls(client=client)

@observe_wrapper(as_type="generation")
def complete(
self,
model: str,
Expand Down
3 changes: 2 additions & 1 deletion packages/exchange/src/exchange/providers/databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
tools_to_openai_spec,
)
from exchange.tool import Tool

from exchange.langfuse_wrapper import observe_wrapper

retry_procedure = retry(
wait=wait_fixed(2),
Expand Down Expand Up @@ -69,6 +69,7 @@ def get_usage(data: dict) -> Usage:
total_tokens=total_tokens,
)

@observe_wrapper(as_type="generation")
def complete(
self,
model: str,
Expand Down
3 changes: 3 additions & 0 deletions packages/exchange/src/exchange/providers/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from exchange.providers.base import Provider, Usage
from tenacity import retry, wait_fixed, stop_after_attempt
from exchange.providers.utils import raise_for_status, retry_if_status, encode_image
from exchange.langfuse_wrapper import observe_wrapper


GOOGLE_HOST = "https://generativelanguage.googleapis.com/v1beta"

Expand Down Expand Up @@ -132,6 +134,7 @@ def messages_to_google_spec(messages: List[Message]) -> List[Dict[str, Any]]:

return messages_spec

@observe_wrapper(as_type="generation")
def complete(
self,
model: str,
Expand Down
2 changes: 2 additions & 0 deletions packages/exchange/src/exchange/providers/groq.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
from typing import Any, Dict, List, Tuple, Type

from exchange.langfuse_wrapper import observe_wrapper
import httpx

from exchange.message import Message
Expand Down Expand Up @@ -65,6 +66,7 @@ def get_usage(data: dict) -> Usage:
total_tokens=total_tokens,
)

@observe_wrapper(as_type="generation")
def complete(
self,
model: str,
Expand Down
2 changes: 2 additions & 0 deletions packages/exchange/src/exchange/providers/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from exchange.tool import Tool
from tenacity import retry, wait_fixed, stop_after_attempt
from exchange.providers.utils import retry_if_status
from exchange.langfuse_wrapper import observe_wrapper

OPENAI_HOST = "https://api.openai.com/"

Expand Down Expand Up @@ -65,6 +66,7 @@ def get_usage(data: dict) -> Usage:
total_tokens=total_tokens,
)

@observe_wrapper(as_type="generation")
def complete(
self,
model: str,
Expand Down
46 changes: 46 additions & 0 deletions packages/exchange/tests/test_langfuse_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import pytest
from unittest.mock import patch, MagicMock
from exchange.langfuse_wrapper import observe_wrapper


@pytest.fixture
def mock_langfuse_context():
with patch("exchange.langfuse_wrapper.langfuse_context") as mock:
yield mock


@patch("exchange.langfuse_wrapper.HAS_LANGFUSE_CREDENTIALS", True)
def test_function_is_wrapped(mock_langfuse_context):
mock_observe = MagicMock(side_effect=lambda *args, **kwargs: lambda fn: fn)
mock_langfuse_context.observe = mock_observe

def original_function(x: int, y: int) -> int:
return x + y

# test function before we decorate it with
# @observe_wrapper("arg1", kwarg1="kwarg1")
assert not hasattr(original_function, "__wrapped__")

# ensure we args get passed along (e.g. @observe(capture_input=False, capture_output=False))
decorated_function = observe_wrapper("arg1", kwarg1="kwarg1")(original_function)
assert hasattr(decorated_function, "__wrapped__")
assert decorated_function.__wrapped__ is original_function, "Function is not properly wrapped"

assert decorated_function(2, 3) == 5
mock_observe.assert_called_once()
mock_observe.assert_called_with("arg1", kwarg1="kwarg1")


@patch("exchange.langfuse_wrapper.HAS_LANGFUSE_CREDENTIALS", False)
def test_function_is_not_wrapped(mock_langfuse_context):
mock_observe = MagicMock(return_value=lambda f: f)
mock_langfuse_context.observe = mock_observe

@observe_wrapper("arg1", kwarg1="kwarg1")
def hello() -> str:
return "Hello"

assert not hasattr(hello, "__wrapped__")
assert hello() == "Hello"

mock_observe.assert_not_called()
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"click>=8.1.7",
"prompt-toolkit>=3.0.47",
"keyring>=25.4.1",
"langfuse>=2.38.2",
]
author = [{ name = "Block", email = "[email protected]" }]
packages = [{ include = "goose", from = "src" }]
Expand Down
Loading