From 9c34d299fb3ad1837f5a3f03c29d507826ec4d75 Mon Sep 17 00:00:00 2001 From: nina-kollman <59646487+nina-kollman@users.noreply.github.com> Date: Sun, 30 Nov 2025 09:48:45 +0200 Subject: [PATCH 01/13] added chat --- .../sample_app/chats/gemini_chatbot.py | 226 +++++++++++++++++ .../sample_app/gemini_chatbot_with_tools.py | 231 ++++++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 packages/sample-app/sample_app/chats/gemini_chatbot.py create mode 100644 packages/sample-app/sample_app/gemini_chatbot_with_tools.py diff --git a/packages/sample-app/sample_app/chats/gemini_chatbot.py b/packages/sample-app/sample_app/chats/gemini_chatbot.py new file mode 100644 index 0000000000..9c68b36720 --- /dev/null +++ b/packages/sample-app/sample_app/chats/gemini_chatbot.py @@ -0,0 +1,226 @@ +import os +import uuid +from datetime import datetime +import google.genai as genai +from google.genai import types +from traceloop.sdk import Traceloop +from traceloop.sdk.decorators import workflow + +# Initialize Traceloop for observability +Traceloop.init(app_name="gemini_chatbot") + +# Initialize Gemini client +client = genai.Client(api_key=os.environ.get("GENAI_API_KEY")) + + +# Define tools for the chatbot +def get_weather(location: str) -> str: + """Get the current weather for a location.""" + # Simulated weather data + weather_data = { + "San Francisco": "Sunny, 68°F", + "New York": "Cloudy, 55°F", + "London": "Rainy, 52°F", + "Tokyo": "Clear, 62°F", + } + return weather_data.get(location, f"Weather data not available for {location}") + + +def get_current_time(timezone: str = "UTC") -> str: + """Get the current time in a specific timezone.""" + # Simplified - just return current UTC time + return f"Current time ({timezone}): {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + + +def search_knowledge_base(query: str) -> str: + """Search the knowledge base for information.""" + # Simulated knowledge base + knowledge = { + "company policy": "Our company policy includes 15 days of vacation per year.", + "support hours": "Support is available Monday-Friday, 9 AM - 5 PM EST.", + "pricing": "Our pricing starts at $29/month for the basic plan.", + } + + for key, value in knowledge.items(): + if key in query.lower(): + return value + + return "I couldn't find specific information about that in our knowledge base." + + +# Define function declarations for Gemini +weather_tool = types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name="get_weather", + description="Get the current weather for a specific location", + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "location": types.Schema( + type=types.Type.STRING, + description="The city name, e.g., 'San Francisco'" + ) + }, + required=["location"] + ) + ) + ] +) + +time_tool = types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name="get_current_time", + description="Get the current time in a specific timezone", + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "timezone": types.Schema( + type=types.Type.STRING, + description="The timezone name, e.g., 'UTC', 'PST'" + ) + }, + required=[] + ) + ) + ] +) + +knowledge_tool = types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name="search_knowledge_base", + description="Search the company knowledge base for information about policies, support, or pricing", + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "query": types.Schema( + type=types.Type.STRING, + description="The search query" + ) + }, + required=["query"] + ) + ) + ] +) + + +# Function to execute tool calls +def execute_function(function_name: str, args: dict) -> str: + """Execute the requested function with given arguments.""" + if function_name == "get_weather": + return get_weather(args.get("location", "")) + elif function_name == "get_current_time": + return get_current_time(args.get("timezone", "UTC")) + elif function_name == "search_knowledge_base": + return search_knowledge_base(args.get("query", "")) + else: + return f"Unknown function: {function_name}" + + +@workflow("chatbot_conversation") +def process_message(chat_id: str, user_message: str, conversation_history: list) -> tuple[str, list]: + """Process a single message with tool support and chat_id association.""" + + # Set chat_id as an association property + Traceloop.set_association_properties({"chat_id": chat_id}) + + # Add user message to conversation history + conversation_history.append({ + "role": "user", + "parts": [{"text": user_message}] + }) + + # Keep trying until we get a final response (handle tool calls) + while True: + # Generate content with tools + response = client.models.generate_content( + model="gemini-2.0-flash-exp", + contents=conversation_history, + config=types.GenerateContentConfig( + tools=[weather_tool, time_tool, knowledge_tool], + temperature=0.7, + ) + ) + + # Check if the model wants to use a tool + if response.candidates[0].content.parts[0].function_call: + function_call = response.candidates[0].content.parts[0].function_call + function_name = function_call.name + function_args = dict(function_call.args) + + print(f"[Tool Call]: {function_name}({function_args})") + + # Execute the function + function_result = execute_function(function_name, function_args) + print(f"[Tool Result]: {function_result}") + + # Add the model's function call to history + conversation_history.append({ + "role": "model", + "parts": [{"function_call": function_call}] + }) + + # Add the function result to history + conversation_history.append({ + "role": "user", + "parts": [{ + "function_response": types.FunctionResponse( + name=function_name, + response={"result": function_result} + ) + }] + }) + else: + # Got a text response, we're done with this turn + assistant_message = response.text + + # Add assistant response to conversation history + conversation_history.append({ + "role": "model", + "parts": [{"text": assistant_message}] + }) + + return assistant_message, conversation_history + + +def main(): + """Main function for interactive chatbot.""" + + # Generate a unique chat_id for this conversation + chat_id = str(uuid.uuid4()) + + print(f"Starting chatbot conversation (Chat ID: {chat_id})") + print("Type 'exit', 'quit', or 'bye' to end the conversation") + print("=" * 80) + + conversation_history = [] + + while True: + # Get user input + user_message = input("\nYou: ").strip() + + # Check for exit commands + if user_message.lower() in ['exit', 'quit', 'bye']: + print("\nGoodbye! Chat session ended.") + break + + # Skip empty messages + if not user_message: + continue + + # Process the message + try: + assistant_message, conversation_history = process_message( + chat_id, user_message, conversation_history + ) + print(f"\nAssistant: {assistant_message}") + except Exception as e: + print(f"\nError: {e}") + print("Please try again.") + + +if __name__ == "__main__": + main() diff --git a/packages/sample-app/sample_app/gemini_chatbot_with_tools.py b/packages/sample-app/sample_app/gemini_chatbot_with_tools.py new file mode 100644 index 0000000000..f5587f5a3c --- /dev/null +++ b/packages/sample-app/sample_app/gemini_chatbot_with_tools.py @@ -0,0 +1,231 @@ +import os +import uuid +from datetime import datetime +import google.genai as genai +from google.genai import types +from traceloop.sdk import Traceloop +from traceloop.sdk.decorators import workflow +from opentelemetry import trace + +# Initialize Traceloop for observability +Traceloop.init(app_name="gemini_chatbot") + +# Initialize Gemini client +client = genai.Client(api_key=os.environ.get("GENAI_API_KEY")) + + +# Define tools for the chatbot +def get_weather(location: str) -> str: + """Get the current weather for a location.""" + # Simulated weather data + weather_data = { + "San Francisco": "Sunny, 68°F", + "New York": "Cloudy, 55°F", + "London": "Rainy, 52°F", + "Tokyo": "Clear, 62°F", + } + return weather_data.get(location, f"Weather data not available for {location}") + + +def get_current_time(timezone: str = "UTC") -> str: + """Get the current time in a specific timezone.""" + # Simplified - just return current UTC time + return f"Current time ({timezone}): {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + + +def search_knowledge_base(query: str) -> str: + """Search the knowledge base for information.""" + # Simulated knowledge base + knowledge = { + "company policy": "Our company policy includes 15 days of vacation per year.", + "support hours": "Support is available Monday-Friday, 9 AM - 5 PM EST.", + "pricing": "Our pricing starts at $29/month for the basic plan.", + } + + for key, value in knowledge.items(): + if key in query.lower(): + return value + + return "I couldn't find specific information about that in our knowledge base." + + +# Define function declarations for Gemini +weather_tool = types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name="get_weather", + description="Get the current weather for a specific location", + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "location": types.Schema( + type=types.Type.STRING, + description="The city name, e.g., 'San Francisco'" + ) + }, + required=["location"] + ) + ) + ] +) + +time_tool = types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name="get_current_time", + description="Get the current time in a specific timezone", + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "timezone": types.Schema( + type=types.Type.STRING, + description="The timezone name, e.g., 'UTC', 'PST'" + ) + }, + required=[] + ) + ) + ] +) + +knowledge_tool = types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name="search_knowledge_base", + description="Search the company knowledge base for information about policies, support, or pricing", + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "query": types.Schema( + type=types.Type.STRING, + description="The search query" + ) + }, + required=["query"] + ) + ) + ] +) + + +# Function to execute tool calls +def execute_function(function_name: str, args: dict) -> str: + """Execute the requested function with given arguments.""" + if function_name == "get_weather": + return get_weather(args.get("location", "")) + elif function_name == "get_current_time": + return get_current_time(args.get("timezone", "UTC")) + elif function_name == "search_knowledge_base": + return search_knowledge_base(args.get("query", "")) + else: + return f"Unknown function: {function_name}" + + +@workflow("chatbot_conversation") +def process_message(chat_id: str, user_message: str, conversation_history: list) -> tuple[str, list]: + """Process a single message with tool support and chat_id association.""" + + # Set chat_id as an attribute on the current span for association + tracer = trace.get_tracer(__name__) + current_span = trace.get_current_span() + if current_span: + current_span.set_attribute("chat_id", chat_id) + current_span.set_attribute("association.properties.chat_id", chat_id) + + # Add user message to conversation history + conversation_history.append({ + "role": "user", + "parts": [{"text": user_message}] + }) + + # Keep trying until we get a final response (handle tool calls) + while True: + # Generate content with tools + response = client.models.generate_content( + model="gemini-2.0-flash-exp", + contents=conversation_history, + config=types.GenerateContentConfig( + tools=[weather_tool, time_tool, knowledge_tool], + temperature=0.7, + ) + ) + + # Check if the model wants to use a tool + if response.candidates[0].content.parts[0].function_call: + function_call = response.candidates[0].content.parts[0].function_call + function_name = function_call.name + function_args = dict(function_call.args) + + print(f"[Tool Call]: {function_name}({function_args})") + + # Execute the function + function_result = execute_function(function_name, function_args) + print(f"[Tool Result]: {function_result}") + + # Add the model's function call to history + conversation_history.append({ + "role": "model", + "parts": [{"function_call": function_call}] + }) + + # Add the function result to history + conversation_history.append({ + "role": "user", + "parts": [{ + "function_response": types.FunctionResponse( + name=function_name, + response={"result": function_result} + ) + }] + }) + else: + # Got a text response, we're done with this turn + assistant_message = response.text + + # Add assistant response to conversation history + conversation_history.append({ + "role": "model", + "parts": [{"text": assistant_message}] + }) + + return assistant_message, conversation_history + + +def main(): + """Main function for interactive chatbot.""" + + # Generate a unique chat_id for this conversation + chat_id = str(uuid.uuid4()) + + print(f"Starting chatbot conversation (Chat ID: {chat_id})") + print("Type 'exit', 'quit', or 'bye' to end the conversation") + print("=" * 80) + + conversation_history = [] + + while True: + # Get user input + user_message = input("\nYou: ").strip() + + # Check for exit commands + if user_message.lower() in ['exit', 'quit', 'bye']: + print("\nGoodbye! Chat session ended.") + break + + # Skip empty messages + if not user_message: + continue + + # Process the message + try: + assistant_message, conversation_history = process_message( + chat_id, user_message, conversation_history + ) + print(f"\nAssistant: {assistant_message}") + except Exception as e: + print(f"\nError: {e}") + print("Please try again.") + + +if __name__ == "__main__": + main() From 43d78b609e37071c8cda7d726f3b4f528c3884d1 Mon Sep 17 00:00:00 2001 From: nina-kollman <59646487+nina-kollman@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:11:31 +0200 Subject: [PATCH 02/13] add the set conversation id --- .../tests/test_association_properties.py | 34 +++++++++++++++++++ .../traceloop-sdk/traceloop/sdk/__init__.py | 5 +++ .../traceloop/sdk/tracing/tracing.py | 4 +++ 3 files changed, 43 insertions(+) diff --git a/packages/traceloop-sdk/tests/test_association_properties.py b/packages/traceloop-sdk/tests/test_association_properties.py index a2c22503e4..87770c8506 100644 --- a/packages/traceloop-sdk/tests/test_association_properties.py +++ b/packages/traceloop-sdk/tests/test_association_properties.py @@ -6,6 +6,40 @@ from traceloop.sdk.decorators import task, workflow +def test_set_conversation_id(exporter): + @workflow(name="test_conversation_workflow") + def test_workflow(): + return test_task() + + @task(name="test_conversation_task") + def test_task(): + return + + Traceloop.set_conversation_id("conv-12345") + test_workflow() + + spans = exporter.get_finished_spans() + assert [span.name for span in spans] == [ + "test_conversation_task.task", + "test_conversation_workflow.workflow", + ] + + task_span = spans[0] + workflow_span = spans[1] + assert ( + workflow_span.attributes[ + f"{SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}.conversation_id" + ] + == "conv-12345" + ) + assert ( + task_span.attributes[ + f"{SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}.conversation_id" + ] + == "conv-12345" + ) + + def test_association_properties(exporter): @workflow(name="test_workflow") def test_workflow(): diff --git a/packages/traceloop-sdk/traceloop/sdk/__init__.py b/packages/traceloop-sdk/traceloop/sdk/__init__.py index 9a64f36c26..e111b12182 100644 --- a/packages/traceloop-sdk/traceloop/sdk/__init__.py +++ b/packages/traceloop-sdk/traceloop/sdk/__init__.py @@ -27,6 +27,7 @@ from traceloop.sdk.tracing.tracing import ( TracerWrapper, set_association_properties, + set_conversation_id, set_external_prompt_tracing_context, ) from typing import Dict @@ -201,6 +202,10 @@ def init( def set_association_properties(properties: dict) -> None: set_association_properties(properties) + @staticmethod + def set_conversation_id(conversation_id: str) -> None: + set_conversation_id(conversation_id) + def set_prompt(template: str, variables: dict, version: int): set_external_prompt_tracing_context(template, variables, version) diff --git a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py index c7299f354a..02dbc6ccb3 100644 --- a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py +++ b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py @@ -242,6 +242,10 @@ def _set_association_properties_attributes(span, properties: dict) -> None: ) +def set_conversation_id(conversation_id: str) -> None: + set_association_properties({"conversation_id": conversation_id}) + + def set_workflow_name(workflow_name: str) -> None: attach(set_value("workflow_name", workflow_name)) From 70431e7cab5895ae5c0ddbb3e86d1cbb786708aa Mon Sep 17 00:00:00 2001 From: nina-kollman <59646487+nina-kollman@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:31:59 +0200 Subject: [PATCH 03/13] change test location --- .../sample_app/chats/gemini_chatbot.py | 6 +- .../tests/test_association_properties.py | 34 ----------- .../tests/test_conversation_id.py | 56 +++++++++++++++++++ .../traceloop/sdk/tracing/tracing.py | 6 +- 4 files changed, 64 insertions(+), 38 deletions(-) create mode 100644 packages/traceloop-sdk/tests/test_conversation_id.py diff --git a/packages/sample-app/sample_app/chats/gemini_chatbot.py b/packages/sample-app/sample_app/chats/gemini_chatbot.py index 9c68b36720..dec3b1e0fd 100644 --- a/packages/sample-app/sample_app/chats/gemini_chatbot.py +++ b/packages/sample-app/sample_app/chats/gemini_chatbot.py @@ -121,11 +121,11 @@ def execute_function(function_name: str, args: dict) -> str: @workflow("chatbot_conversation") -def process_message(chat_id: str, user_message: str, conversation_history: list) -> tuple[str, list]: +def process_message(conversation_id: str, user_message: str, conversation_history: list) -> tuple[str, list]: """Process a single message with tool support and chat_id association.""" - # Set chat_id as an association property - Traceloop.set_association_properties({"chat_id": chat_id}) + # Set a conversation_id to identify the conversation + Traceloop.set_conversation_id(conversation_id) # Add user message to conversation history conversation_history.append({ diff --git a/packages/traceloop-sdk/tests/test_association_properties.py b/packages/traceloop-sdk/tests/test_association_properties.py index 87770c8506..a2c22503e4 100644 --- a/packages/traceloop-sdk/tests/test_association_properties.py +++ b/packages/traceloop-sdk/tests/test_association_properties.py @@ -6,40 +6,6 @@ from traceloop.sdk.decorators import task, workflow -def test_set_conversation_id(exporter): - @workflow(name="test_conversation_workflow") - def test_workflow(): - return test_task() - - @task(name="test_conversation_task") - def test_task(): - return - - Traceloop.set_conversation_id("conv-12345") - test_workflow() - - spans = exporter.get_finished_spans() - assert [span.name for span in spans] == [ - "test_conversation_task.task", - "test_conversation_workflow.workflow", - ] - - task_span = spans[0] - workflow_span = spans[1] - assert ( - workflow_span.attributes[ - f"{SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}.conversation_id" - ] - == "conv-12345" - ) - assert ( - task_span.attributes[ - f"{SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}.conversation_id" - ] - == "conv-12345" - ) - - def test_association_properties(exporter): @workflow(name="test_workflow") def test_workflow(): diff --git a/packages/traceloop-sdk/tests/test_conversation_id.py b/packages/traceloop-sdk/tests/test_conversation_id.py new file mode 100644 index 0000000000..5724879269 --- /dev/null +++ b/packages/traceloop-sdk/tests/test_conversation_id.py @@ -0,0 +1,56 @@ +from traceloop.sdk import Traceloop +from traceloop.sdk.decorators import task, workflow + + +def test_set_conversation_id(exporter): + """Test that conversation_id is set on all spans when called before workflow.""" + @workflow(name="test_conversation_workflow") + def test_workflow(): + return test_task() + + @task(name="test_conversation_task") + def test_task(): + return + + Traceloop.set_conversation_id("conv-12345") + test_workflow() + + spans = exporter.get_finished_spans() + assert [span.name for span in spans] == [ + "test_conversation_task.task", + "test_conversation_workflow.workflow", + ] + + task_span = spans[0] + workflow_span = spans[1] + + # Check that conversation_id is set directly without the prefix + assert workflow_span.attributes["conversation_id"] == "conv-12345" + assert task_span.attributes["conversation_id"] == "conv-12345" + + +def test_set_conversation_id_within_workflow(exporter): + """Test that conversation_id is set when called within a workflow.""" + @workflow(name="test_conversation_within_workflow") + def test_workflow(): + Traceloop.set_conversation_id("conv-67890") + return test_task() + + @task(name="test_conversation_within_task") + def test_task(): + return + + test_workflow() + + spans = exporter.get_finished_spans() + assert [span.name for span in spans] == [ + "test_conversation_within_task.task", + "test_conversation_within_workflow.workflow", + ] + + task_span = spans[0] + workflow_span = spans[1] + + # Both spans should have conversation_id set + assert workflow_span.attributes["conversation_id"] == "conv-67890" + assert task_span.attributes["conversation_id"] == "conv-67890" diff --git a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py index 02dbc6ccb3..292b56a91c 100644 --- a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py +++ b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py @@ -243,7 +243,7 @@ def _set_association_properties_attributes(span, properties: dict) -> None: def set_conversation_id(conversation_id: str) -> None: - set_association_properties({"conversation_id": conversation_id}) + attach(set_value("conversation_id", conversation_id)) def set_workflow_name(workflow_name: str) -> None: @@ -317,6 +317,10 @@ def default_span_processor_on_start(span: Span, parent_context: Context | None = if entity_path is not None: span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_PATH, str(entity_path)) + conversation_id = get_value("conversation_id") + if conversation_id is not None: + span.set_attribute("conversation_id", str(conversation_id)) + association_properties = get_value("association_properties") if association_properties is not None and isinstance(association_properties, dict): _set_association_properties_attributes(span, association_properties) From cd37f9a1c040563a119baaf47f23f9de9fa81fc8 Mon Sep 17 00:00:00 2001 From: nina-kollman <59646487+nina-kollman@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:34:35 +0200 Subject: [PATCH 04/13] add --- packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py index 292b56a91c..5b638b6d26 100644 --- a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py +++ b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py @@ -243,8 +243,14 @@ def _set_association_properties_attributes(span, properties: dict) -> None: def set_conversation_id(conversation_id: str) -> None: + """Set the conversation_id directly on spans without the association properties prefix.""" attach(set_value("conversation_id", conversation_id)) + # Also set it directly on the current span + span = trace.get_current_span() + if span and span.is_recording(): + span.set_attribute("conversation_id", conversation_id) + def set_workflow_name(workflow_name: str) -> None: attach(set_value("workflow_name", workflow_name)) From a7ae838057a0e5658aadb10d3bdddc848543b430 Mon Sep 17 00:00:00 2001 From: nina-kollman <59646487+nina-kollman@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:35:40 +0200 Subject: [PATCH 05/13] remove script --- .../sample_app/gemini_chatbot_with_tools.py | 231 ------------------ 1 file changed, 231 deletions(-) delete mode 100644 packages/sample-app/sample_app/gemini_chatbot_with_tools.py diff --git a/packages/sample-app/sample_app/gemini_chatbot_with_tools.py b/packages/sample-app/sample_app/gemini_chatbot_with_tools.py deleted file mode 100644 index f5587f5a3c..0000000000 --- a/packages/sample-app/sample_app/gemini_chatbot_with_tools.py +++ /dev/null @@ -1,231 +0,0 @@ -import os -import uuid -from datetime import datetime -import google.genai as genai -from google.genai import types -from traceloop.sdk import Traceloop -from traceloop.sdk.decorators import workflow -from opentelemetry import trace - -# Initialize Traceloop for observability -Traceloop.init(app_name="gemini_chatbot") - -# Initialize Gemini client -client = genai.Client(api_key=os.environ.get("GENAI_API_KEY")) - - -# Define tools for the chatbot -def get_weather(location: str) -> str: - """Get the current weather for a location.""" - # Simulated weather data - weather_data = { - "San Francisco": "Sunny, 68°F", - "New York": "Cloudy, 55°F", - "London": "Rainy, 52°F", - "Tokyo": "Clear, 62°F", - } - return weather_data.get(location, f"Weather data not available for {location}") - - -def get_current_time(timezone: str = "UTC") -> str: - """Get the current time in a specific timezone.""" - # Simplified - just return current UTC time - return f"Current time ({timezone}): {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" - - -def search_knowledge_base(query: str) -> str: - """Search the knowledge base for information.""" - # Simulated knowledge base - knowledge = { - "company policy": "Our company policy includes 15 days of vacation per year.", - "support hours": "Support is available Monday-Friday, 9 AM - 5 PM EST.", - "pricing": "Our pricing starts at $29/month for the basic plan.", - } - - for key, value in knowledge.items(): - if key in query.lower(): - return value - - return "I couldn't find specific information about that in our knowledge base." - - -# Define function declarations for Gemini -weather_tool = types.Tool( - function_declarations=[ - types.FunctionDeclaration( - name="get_weather", - description="Get the current weather for a specific location", - parameters=types.Schema( - type=types.Type.OBJECT, - properties={ - "location": types.Schema( - type=types.Type.STRING, - description="The city name, e.g., 'San Francisco'" - ) - }, - required=["location"] - ) - ) - ] -) - -time_tool = types.Tool( - function_declarations=[ - types.FunctionDeclaration( - name="get_current_time", - description="Get the current time in a specific timezone", - parameters=types.Schema( - type=types.Type.OBJECT, - properties={ - "timezone": types.Schema( - type=types.Type.STRING, - description="The timezone name, e.g., 'UTC', 'PST'" - ) - }, - required=[] - ) - ) - ] -) - -knowledge_tool = types.Tool( - function_declarations=[ - types.FunctionDeclaration( - name="search_knowledge_base", - description="Search the company knowledge base for information about policies, support, or pricing", - parameters=types.Schema( - type=types.Type.OBJECT, - properties={ - "query": types.Schema( - type=types.Type.STRING, - description="The search query" - ) - }, - required=["query"] - ) - ) - ] -) - - -# Function to execute tool calls -def execute_function(function_name: str, args: dict) -> str: - """Execute the requested function with given arguments.""" - if function_name == "get_weather": - return get_weather(args.get("location", "")) - elif function_name == "get_current_time": - return get_current_time(args.get("timezone", "UTC")) - elif function_name == "search_knowledge_base": - return search_knowledge_base(args.get("query", "")) - else: - return f"Unknown function: {function_name}" - - -@workflow("chatbot_conversation") -def process_message(chat_id: str, user_message: str, conversation_history: list) -> tuple[str, list]: - """Process a single message with tool support and chat_id association.""" - - # Set chat_id as an attribute on the current span for association - tracer = trace.get_tracer(__name__) - current_span = trace.get_current_span() - if current_span: - current_span.set_attribute("chat_id", chat_id) - current_span.set_attribute("association.properties.chat_id", chat_id) - - # Add user message to conversation history - conversation_history.append({ - "role": "user", - "parts": [{"text": user_message}] - }) - - # Keep trying until we get a final response (handle tool calls) - while True: - # Generate content with tools - response = client.models.generate_content( - model="gemini-2.0-flash-exp", - contents=conversation_history, - config=types.GenerateContentConfig( - tools=[weather_tool, time_tool, knowledge_tool], - temperature=0.7, - ) - ) - - # Check if the model wants to use a tool - if response.candidates[0].content.parts[0].function_call: - function_call = response.candidates[0].content.parts[0].function_call - function_name = function_call.name - function_args = dict(function_call.args) - - print(f"[Tool Call]: {function_name}({function_args})") - - # Execute the function - function_result = execute_function(function_name, function_args) - print(f"[Tool Result]: {function_result}") - - # Add the model's function call to history - conversation_history.append({ - "role": "model", - "parts": [{"function_call": function_call}] - }) - - # Add the function result to history - conversation_history.append({ - "role": "user", - "parts": [{ - "function_response": types.FunctionResponse( - name=function_name, - response={"result": function_result} - ) - }] - }) - else: - # Got a text response, we're done with this turn - assistant_message = response.text - - # Add assistant response to conversation history - conversation_history.append({ - "role": "model", - "parts": [{"text": assistant_message}] - }) - - return assistant_message, conversation_history - - -def main(): - """Main function for interactive chatbot.""" - - # Generate a unique chat_id for this conversation - chat_id = str(uuid.uuid4()) - - print(f"Starting chatbot conversation (Chat ID: {chat_id})") - print("Type 'exit', 'quit', or 'bye' to end the conversation") - print("=" * 80) - - conversation_history = [] - - while True: - # Get user input - user_message = input("\nYou: ").strip() - - # Check for exit commands - if user_message.lower() in ['exit', 'quit', 'bye']: - print("\nGoodbye! Chat session ended.") - break - - # Skip empty messages - if not user_message: - continue - - # Process the message - try: - assistant_message, conversation_history = process_message( - chat_id, user_message, conversation_history - ) - print(f"\nAssistant: {assistant_message}") - except Exception as e: - print(f"\nError: {e}") - print("Please try again.") - - -if __name__ == "__main__": - main() From 045c862281cfc366a16becf8215ad4594d70be9b Mon Sep 17 00:00:00 2001 From: nina-kollman <59646487+nina-kollman@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:21:01 +0200 Subject: [PATCH 06/13] added enum --- .../sample_app/chats/gemini_chatbot.py | 6 +- .../traceloop-sdk/tests/test_associations.py | 123 ++++++++++++++++++ .../tests/test_conversation_id.py | 56 -------- .../traceloop-sdk/traceloop/sdk/__init__.py | 4 - .../traceloop/sdk/tracing/associations.py | 53 ++++++++ .../traceloop/sdk/tracing/tracing.py | 18 +-- 6 files changed, 184 insertions(+), 76 deletions(-) create mode 100644 packages/traceloop-sdk/tests/test_associations.py delete mode 100644 packages/traceloop-sdk/tests/test_conversation_id.py create mode 100644 packages/traceloop-sdk/traceloop/sdk/tracing/associations.py diff --git a/packages/sample-app/sample_app/chats/gemini_chatbot.py b/packages/sample-app/sample_app/chats/gemini_chatbot.py index dec3b1e0fd..c940996e9b 100644 --- a/packages/sample-app/sample_app/chats/gemini_chatbot.py +++ b/packages/sample-app/sample_app/chats/gemini_chatbot.py @@ -3,7 +3,7 @@ from datetime import datetime import google.genai as genai from google.genai import types -from traceloop.sdk import Traceloop +from traceloop.sdk import Traceloop, AssociationProperty from traceloop.sdk.decorators import workflow # Initialize Traceloop for observability @@ -124,8 +124,8 @@ def execute_function(function_name: str, args: dict) -> str: def process_message(conversation_id: str, user_message: str, conversation_history: list) -> tuple[str, list]: """Process a single message with tool support and chat_id association.""" - # Set a conversation_id to identify the conversation - Traceloop.set_conversation_id(conversation_id) + # Set a conversation_id to identify the conversation using the associations API + Traceloop.associations.set([(AssociationProperty.CONVERSATION_ID, conversation_id)]) # Add user message to conversation history conversation_history.append({ diff --git a/packages/traceloop-sdk/tests/test_associations.py b/packages/traceloop-sdk/tests/test_associations.py new file mode 100644 index 0000000000..1fa5c55719 --- /dev/null +++ b/packages/traceloop-sdk/tests/test_associations.py @@ -0,0 +1,123 @@ +from traceloop.sdk import Traceloop +from traceloop.sdk.tracing.associations import AssociationProperty +from traceloop.sdk.decorators import task, workflow + + +def test_associations_create_single(exporter): + """Test creating a single association.""" + @workflow(name="test_single_association") + def test_workflow(): + return test_task() + + @task(name="test_single_task") + def test_task(): + return + + Traceloop.associations.set([(AssociationProperty.CONVERSATION_ID, "conv-123")]) + test_workflow() + + spans = exporter.get_finished_spans() + assert [span.name for span in spans] == [ + "test_single_task.task", + "test_single_association.workflow", + ] + + task_span = spans[0] + workflow_span = spans[1] + + assert workflow_span.attributes["conversation_id"] == "conv-123" + assert task_span.attributes["conversation_id"] == "conv-123" + + +def test_associations_create_multiple(exporter): + """Test creating multiple associations at once.""" + @workflow(name="test_multiple_associations") + def test_workflow(): + return test_task() + + @task(name="test_multiple_task") + def test_task(): + return + + Traceloop.associations.set([ + (AssociationProperty.USER_ID, "user-456"), + (AssociationProperty.SESSION_ID, "session-789"), + (AssociationProperty.CUSTOMER_ID, "customer-999"), + ]) + test_workflow() + + spans = exporter.get_finished_spans() + assert [span.name for span in spans] == [ + "test_multiple_task.task", + "test_multiple_associations.workflow", + ] + + task_span = spans[0] + workflow_span = spans[1] + + # Check all associations are present + assert workflow_span.attributes["user_id"] == "user-456" + assert workflow_span.attributes["session_id"] == "session-789" + assert workflow_span.attributes["customer_id"] == "customer-999" + + assert task_span.attributes["user_id"] == "user-456" + assert task_span.attributes["session_id"] == "session-789" + assert task_span.attributes["customer_id"] == "customer-999" + + +def test_associations_within_workflow(exporter): + """Test creating associations within a workflow.""" + @workflow(name="test_associations_within") + def test_workflow(): + Traceloop.associations.set([ + (AssociationProperty.CONVERSATION_ID, "conv-abc"), + (AssociationProperty.THREAD_ID, "thread-xyz"), + ]) + return test_task() + + @task(name="test_within_task") + def test_task(): + return + + test_workflow() + + spans = exporter.get_finished_spans() + assert [span.name for span in spans] == [ + "test_within_task.task", + "test_associations_within.workflow", + ] + + task_span = spans[0] + workflow_span = spans[1] + + # Both spans should have all associations + assert workflow_span.attributes["conversation_id"] == "conv-abc" + assert workflow_span.attributes["thread_id"] == "thread-xyz" + + assert task_span.attributes["conversation_id"] == "conv-abc" + assert task_span.attributes["thread_id"] == "thread-xyz" + + +def test_all_association_properties(exporter): + """Test that all AssociationProperty enum values work correctly.""" + @workflow(name="test_all_properties") + def test_workflow(): + return + + Traceloop.associations.set([ + (AssociationProperty.CONVERSATION_ID, "conv-1"), + (AssociationProperty.CUSTOMER_ID, "customer-2"), + (AssociationProperty.USER_ID, "user-3"), + (AssociationProperty.SESSION_ID, "session-4"), + (AssociationProperty.THREAD_ID, "thread-5"), + ]) + test_workflow() + + spans = exporter.get_finished_spans() + workflow_span = spans[0] + + assert workflow_span.attributes["conversation_id"] == "conv-1" + assert workflow_span.attributes["customer_id"] == "customer-2" + assert workflow_span.attributes["user_id"] == "user-3" + assert workflow_span.attributes["session_id"] == "session-4" + assert workflow_span.attributes["thread_id"] == "thread-5" diff --git a/packages/traceloop-sdk/tests/test_conversation_id.py b/packages/traceloop-sdk/tests/test_conversation_id.py deleted file mode 100644 index 5724879269..0000000000 --- a/packages/traceloop-sdk/tests/test_conversation_id.py +++ /dev/null @@ -1,56 +0,0 @@ -from traceloop.sdk import Traceloop -from traceloop.sdk.decorators import task, workflow - - -def test_set_conversation_id(exporter): - """Test that conversation_id is set on all spans when called before workflow.""" - @workflow(name="test_conversation_workflow") - def test_workflow(): - return test_task() - - @task(name="test_conversation_task") - def test_task(): - return - - Traceloop.set_conversation_id("conv-12345") - test_workflow() - - spans = exporter.get_finished_spans() - assert [span.name for span in spans] == [ - "test_conversation_task.task", - "test_conversation_workflow.workflow", - ] - - task_span = spans[0] - workflow_span = spans[1] - - # Check that conversation_id is set directly without the prefix - assert workflow_span.attributes["conversation_id"] == "conv-12345" - assert task_span.attributes["conversation_id"] == "conv-12345" - - -def test_set_conversation_id_within_workflow(exporter): - """Test that conversation_id is set when called within a workflow.""" - @workflow(name="test_conversation_within_workflow") - def test_workflow(): - Traceloop.set_conversation_id("conv-67890") - return test_task() - - @task(name="test_conversation_within_task") - def test_task(): - return - - test_workflow() - - spans = exporter.get_finished_spans() - assert [span.name for span in spans] == [ - "test_conversation_within_task.task", - "test_conversation_within_workflow.workflow", - ] - - task_span = spans[0] - workflow_span = spans[1] - - # Both spans should have conversation_id set - assert workflow_span.attributes["conversation_id"] == "conv-67890" - assert task_span.attributes["conversation_id"] == "conv-67890" diff --git a/packages/traceloop-sdk/traceloop/sdk/__init__.py b/packages/traceloop-sdk/traceloop/sdk/__init__.py index e111b12182..1d0bf5abbb 100644 --- a/packages/traceloop-sdk/traceloop/sdk/__init__.py +++ b/packages/traceloop-sdk/traceloop/sdk/__init__.py @@ -27,7 +27,6 @@ from traceloop.sdk.tracing.tracing import ( TracerWrapper, set_association_properties, - set_conversation_id, set_external_prompt_tracing_context, ) from typing import Dict @@ -202,9 +201,6 @@ def init( def set_association_properties(properties: dict) -> None: set_association_properties(properties) - @staticmethod - def set_conversation_id(conversation_id: str) -> None: - set_conversation_id(conversation_id) def set_prompt(template: str, variables: dict, version: int): set_external_prompt_tracing_context(template, variables, version) diff --git a/packages/traceloop-sdk/traceloop/sdk/tracing/associations.py b/packages/traceloop-sdk/traceloop/sdk/tracing/associations.py new file mode 100644 index 0000000000..27f060a3ba --- /dev/null +++ b/packages/traceloop-sdk/traceloop/sdk/tracing/associations.py @@ -0,0 +1,53 @@ +from enum import Enum +from typing import Sequence +from opentelemetry import trace +from opentelemetry.context import attach, set_value, get_value + + +class AssociationProperty(str, Enum): + """Standard association properties for tracing.""" + + CONVERSATION_ID = "conversation_id" + CUSTOMER_ID = "customer_id" + USER_ID = "user_id" + SESSION_ID = "session_id" + THREAD_ID = "thread_id" + + +# Type alias for a single association +Association = tuple[AssociationProperty, str] + + +def set(associations: Sequence[Association]) -> None: + """ + Set associations that will be added directly to all spans in the current context. + + Args: + associations: A sequence of (property, value) tuples + + Example: + # Single association + Traceloop.associations.set([(AssociationProperty.CONVERSATION_ID, "conv-123")]) + + # Multiple associations + Traceloop.associations.set([ + (AssociationProperty.USER_ID, "user-456"), + (AssociationProperty.SESSION_ID, "session-789") + ]) + """ + # Store all associations in context + current_associations = get_value("associations") or {} + for prop, value in associations: + if isinstance(prop, AssociationProperty): + current_associations[prop.value] = value + else: + current_associations[str(prop)] = value + + attach(set_value("associations", current_associations)) + + # Also set directly on the current span + span = trace.get_current_span() + if span and span.is_recording(): + for prop, value in associations: + prop_name = prop.value if isinstance(prop, AssociationProperty) else str(prop) + span.set_attribute(prop_name, value) diff --git a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py index 5b638b6d26..31ed442697 100644 --- a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py +++ b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py @@ -242,16 +242,6 @@ def _set_association_properties_attributes(span, properties: dict) -> None: ) -def set_conversation_id(conversation_id: str) -> None: - """Set the conversation_id directly on spans without the association properties prefix.""" - attach(set_value("conversation_id", conversation_id)) - - # Also set it directly on the current span - span = trace.get_current_span() - if span and span.is_recording(): - span.set_attribute("conversation_id", conversation_id) - - def set_workflow_name(workflow_name: str) -> None: attach(set_value("workflow_name", workflow_name)) @@ -323,9 +313,11 @@ def default_span_processor_on_start(span: Span, parent_context: Context | None = if entity_path is not None: span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_PATH, str(entity_path)) - conversation_id = get_value("conversation_id") - if conversation_id is not None: - span.set_attribute("conversation_id", str(conversation_id)) + # Handle associations API + associations = get_value("associations") + if associations is not None and isinstance(associations, dict): + for key, value in associations.items(): + span.set_attribute(key, str(value)) association_properties = get_value("association_properties") if association_properties is not None and isinstance(association_properties, dict): From f6f8d60f4437fe92332dab5b587b6c7047b36222 Mon Sep 17 00:00:00 2001 From: nina-kollman <59646487+nina-kollman@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:26:31 +0200 Subject: [PATCH 07/13] init file| --- packages/traceloop-sdk/traceloop/sdk/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/traceloop-sdk/traceloop/sdk/__init__.py b/packages/traceloop-sdk/traceloop/sdk/__init__.py index 1d0bf5abbb..7ffd52d6ce 100644 --- a/packages/traceloop-sdk/traceloop/sdk/__init__.py +++ b/packages/traceloop-sdk/traceloop/sdk/__init__.py @@ -29,6 +29,8 @@ set_association_properties, set_external_prompt_tracing_context, ) +from traceloop.sdk.tracing import associations +from traceloop.sdk.tracing.associations import AssociationProperty from typing import Dict from traceloop.sdk.client.client import Client @@ -201,6 +203,8 @@ def init( def set_association_properties(properties: dict) -> None: set_association_properties(properties) + # Associations namespace + associations = associations def set_prompt(template: str, variables: dict, version: int): set_external_prompt_tracing_context(template, variables, version) From 36dc637257e37bcf0bc575b8605ba1e479d6bff4 Mon Sep 17 00:00:00 2001 From: nina-kollman <59646487+nina-kollman@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:00:05 +0200 Subject: [PATCH 08/13] on client --- .../sample_app/chats/gemini_chatbot.py | 4 +- .../traceloop-sdk/tests/test_associations.py | 82 +++++++++++++++---- .../traceloop-sdk/traceloop/sdk/__init__.py | 5 +- .../traceloop/sdk/client/client.py | 5 ++ .../traceloop/sdk/tracing/associations.py | 1 - 5 files changed, 74 insertions(+), 23 deletions(-) diff --git a/packages/sample-app/sample_app/chats/gemini_chatbot.py b/packages/sample-app/sample_app/chats/gemini_chatbot.py index c940996e9b..1e2016445a 100644 --- a/packages/sample-app/sample_app/chats/gemini_chatbot.py +++ b/packages/sample-app/sample_app/chats/gemini_chatbot.py @@ -7,7 +7,7 @@ from traceloop.sdk.decorators import workflow # Initialize Traceloop for observability -Traceloop.init(app_name="gemini_chatbot") +traceloop = Traceloop.init(app_name="gemini_chatbot") # Initialize Gemini client client = genai.Client(api_key=os.environ.get("GENAI_API_KEY")) @@ -125,7 +125,7 @@ def process_message(conversation_id: str, user_message: str, conversation_histor """Process a single message with tool support and chat_id association.""" # Set a conversation_id to identify the conversation using the associations API - Traceloop.associations.set([(AssociationProperty.CONVERSATION_ID, conversation_id)]) + traceloop.associations.set([(AssociationProperty.CONVERSATION_ID, conversation_id)]) # Add user message to conversation history conversation_history.append({ diff --git a/packages/traceloop-sdk/tests/test_associations.py b/packages/traceloop-sdk/tests/test_associations.py index 1fa5c55719..eff35e773f 100644 --- a/packages/traceloop-sdk/tests/test_associations.py +++ b/packages/traceloop-sdk/tests/test_associations.py @@ -1,10 +1,54 @@ -from traceloop.sdk import Traceloop -from traceloop.sdk.tracing.associations import AssociationProperty +import pytest +from traceloop.sdk import Traceloop, AssociationProperty from traceloop.sdk.decorators import task, workflow - - -def test_associations_create_single(exporter): +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + +@pytest.fixture +def client_with_exporter(): + """ + Fixture that initializes Traceloop with API key. + Client is only created when NO custom exporter/processor is provided. + """ + # Initialize with API key and Traceloop endpoint - this creates a client + client = Traceloop.init( + app_name="test_associations", + api_key="test-api-key", + api_endpoint="https://api.traceloop.com", + disable_batch=True, + # NO exporter or processor - so client gets created + ) + + # Get spans from the tracer provider for assertions + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + + tracer_provider = trace.get_tracer_provider() + if isinstance(tracer_provider, TracerProvider): + # Get the span processor's exporter + span_processors = tracer_provider._active_span_processor._span_processors + # Find the exporter from the processors + for processor in span_processors: + if hasattr(processor, 'span_exporter'): + exporter = processor.span_exporter + break + else: + # Fallback: create a mock exporter + exporter = InMemorySpanExporter() + else: + exporter = InMemorySpanExporter() + + yield client, exporter + + # Cleanup + if hasattr(exporter, 'clear'): + exporter.clear() + + +def test_associations_create_single(client_with_exporter): """Test creating a single association.""" + client, exporter = client_with_exporter + @workflow(name="test_single_association") def test_workflow(): return test_task() @@ -13,7 +57,7 @@ def test_workflow(): def test_task(): return - Traceloop.associations.set([(AssociationProperty.CONVERSATION_ID, "conv-123")]) + client.associations.set([(AssociationProperty.CONVERSATION_ID, "conv-123")]) test_workflow() spans = exporter.get_finished_spans() @@ -29,8 +73,10 @@ def test_task(): assert task_span.attributes["conversation_id"] == "conv-123" -def test_associations_create_multiple(exporter): +def test_associations_create_multiple(client_with_exporter): """Test creating multiple associations at once.""" + client, exporter = client_with_exporter + @workflow(name="test_multiple_associations") def test_workflow(): return test_task() @@ -39,7 +85,7 @@ def test_workflow(): def test_task(): return - Traceloop.associations.set([ + client.associations.set([ (AssociationProperty.USER_ID, "user-456"), (AssociationProperty.SESSION_ID, "session-789"), (AssociationProperty.CUSTOMER_ID, "customer-999"), @@ -65,13 +111,15 @@ def test_task(): assert task_span.attributes["customer_id"] == "customer-999" -def test_associations_within_workflow(exporter): +def test_associations_within_workflow(client_with_exporter): """Test creating associations within a workflow.""" + client, exporter = client_with_exporter + @workflow(name="test_associations_within") def test_workflow(): - Traceloop.associations.set([ + client.associations.set([ (AssociationProperty.CONVERSATION_ID, "conv-abc"), - (AssociationProperty.THREAD_ID, "thread-xyz"), + (AssociationProperty.USER_ID, "user-xyz"), ]) return test_task() @@ -92,24 +140,25 @@ def test_task(): # Both spans should have all associations assert workflow_span.attributes["conversation_id"] == "conv-abc" - assert workflow_span.attributes["thread_id"] == "thread-xyz" + assert workflow_span.attributes["user_id"] == "user-xyz" assert task_span.attributes["conversation_id"] == "conv-abc" - assert task_span.attributes["thread_id"] == "thread-xyz" + assert task_span.attributes["user_id"] == "user-xyz" -def test_all_association_properties(exporter): +def test_all_association_properties(client_with_exporter): """Test that all AssociationProperty enum values work correctly.""" + client, exporter = client_with_exporter + @workflow(name="test_all_properties") def test_workflow(): return - Traceloop.associations.set([ + client.associations.set([ (AssociationProperty.CONVERSATION_ID, "conv-1"), (AssociationProperty.CUSTOMER_ID, "customer-2"), (AssociationProperty.USER_ID, "user-3"), (AssociationProperty.SESSION_ID, "session-4"), - (AssociationProperty.THREAD_ID, "thread-5"), ]) test_workflow() @@ -120,4 +169,3 @@ def test_workflow(): assert workflow_span.attributes["customer_id"] == "customer-2" assert workflow_span.attributes["user_id"] == "user-3" assert workflow_span.attributes["session_id"] == "session-4" - assert workflow_span.attributes["thread_id"] == "thread-5" diff --git a/packages/traceloop-sdk/traceloop/sdk/__init__.py b/packages/traceloop-sdk/traceloop/sdk/__init__.py index 7ffd52d6ce..59b284efca 100644 --- a/packages/traceloop-sdk/traceloop/sdk/__init__.py +++ b/packages/traceloop-sdk/traceloop/sdk/__init__.py @@ -34,6 +34,8 @@ from typing import Dict from traceloop.sdk.client.client import Client +__all__ = ["Traceloop", "Client", "Instruments", "associations", "AssociationProperty"] + class Traceloop: AUTO_CREATED_KEY_PATH = str( @@ -203,9 +205,6 @@ def init( def set_association_properties(properties: dict) -> None: set_association_properties(properties) - # Associations namespace - associations = associations - def set_prompt(template: str, variables: dict, version: int): set_external_prompt_tracing_context(template, variables, version) diff --git a/packages/traceloop-sdk/traceloop/sdk/client/client.py b/packages/traceloop-sdk/traceloop/sdk/client/client.py index b3d2510f63..6265b6a51c 100644 --- a/packages/traceloop-sdk/traceloop/sdk/client/client.py +++ b/packages/traceloop-sdk/traceloop/sdk/client/client.py @@ -1,12 +1,15 @@ import sys import os +from typing import TYPE_CHECKING from traceloop.sdk.annotation.user_feedback import UserFeedback from traceloop.sdk.datasets.datasets import Datasets from traceloop.sdk.experiment.experiment import Experiment from traceloop.sdk.client.http import HTTPClient from traceloop.sdk.version import __version__ +from traceloop.sdk.tracing import associations import httpx +from types import ModuleType class Client: @@ -25,6 +28,7 @@ class Client: user_feedback: UserFeedback datasets: Datasets experiment: Experiment + associations: ModuleType _http: HTTPClient _async_http: httpx.AsyncClient @@ -65,3 +69,4 @@ def __init__( experiment_slug = os.getenv("TRACELOOP_EXP_SLUG") # TODO: Fix type - Experiment constructor should accept Optional[str] self.experiment = Experiment(self._http, self._async_http, experiment_slug) # type: ignore[arg-type] + self.associations = associations diff --git a/packages/traceloop-sdk/traceloop/sdk/tracing/associations.py b/packages/traceloop-sdk/traceloop/sdk/tracing/associations.py index 27f060a3ba..f9d78ad55c 100644 --- a/packages/traceloop-sdk/traceloop/sdk/tracing/associations.py +++ b/packages/traceloop-sdk/traceloop/sdk/tracing/associations.py @@ -11,7 +11,6 @@ class AssociationProperty(str, Enum): CUSTOMER_ID = "customer_id" USER_ID = "user_id" SESSION_ID = "session_id" - THREAD_ID = "thread_id" # Type alias for a single association From c0b28182d3334c9c279a8c08faa28af803483d79 Mon Sep 17 00:00:00 2001 From: nina-kollman <59646487+nina-kollman@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:13:06 +0200 Subject: [PATCH 09/13] change to class --- .../traceloop-sdk/traceloop/sdk/__init__.py | 5 +- .../sdk/associations/associations.py | 54 +++++++++++++++++++ .../traceloop/sdk/client/client.py | 8 ++- .../traceloop/sdk/tracing/associations.py | 52 ------------------ 4 files changed, 59 insertions(+), 60 deletions(-) create mode 100644 packages/traceloop-sdk/traceloop/sdk/associations/associations.py delete mode 100644 packages/traceloop-sdk/traceloop/sdk/tracing/associations.py diff --git a/packages/traceloop-sdk/traceloop/sdk/__init__.py b/packages/traceloop-sdk/traceloop/sdk/__init__.py index 59b284efca..486c10c888 100644 --- a/packages/traceloop-sdk/traceloop/sdk/__init__.py +++ b/packages/traceloop-sdk/traceloop/sdk/__init__.py @@ -29,12 +29,11 @@ set_association_properties, set_external_prompt_tracing_context, ) -from traceloop.sdk.tracing import associations -from traceloop.sdk.tracing.associations import AssociationProperty +from traceloop.sdk.associations.associations import Associations, AssociationProperty from typing import Dict from traceloop.sdk.client.client import Client -__all__ = ["Traceloop", "Client", "Instruments", "associations", "AssociationProperty"] +__all__ = ["Traceloop", "Client", "Instruments", "Associations", "AssociationProperty"] class Traceloop: diff --git a/packages/traceloop-sdk/traceloop/sdk/associations/associations.py b/packages/traceloop-sdk/traceloop/sdk/associations/associations.py new file mode 100644 index 0000000000..37e239e378 --- /dev/null +++ b/packages/traceloop-sdk/traceloop/sdk/associations/associations.py @@ -0,0 +1,54 @@ +from enum import Enum +from typing import Sequence +from opentelemetry import trace +from opentelemetry.context import attach, set_value, get_value + + +class AssociationProperty(str, Enum): + """Standard association properties for tracing.""" + + CONVERSATION_ID = "conversation_id" + CUSTOMER_ID = "customer_id" + USER_ID = "user_id" + SESSION_ID = "session_id" + + +# Type alias for a single association +Association = tuple[AssociationProperty, str] + + +class Associations: + """Class for managing trace associations.""" + + @staticmethod + def set(associations: Sequence[Association]) -> None: + """ + Set associations that will be added directly to all spans in the current context. + + Args: + associations: A sequence of (property, value) tuples + + Example: + # Single association + traceloop.associations.set([(AssociationProperty.CONVERSATION_ID, "conv-123")]) + + # Multiple associations + traceloop.associations.set([ + (AssociationProperty.USER_ID, "user-456"), + (AssociationProperty.SESSION_ID, "session-789") + ]) + """ + # Store all associations in context + current_associations = get_value("associations") or {} + for prop, value in associations: + if isinstance(prop, AssociationProperty): + current_associations[prop.value] = value + + attach(set_value("associations", current_associations)) + + # Also set directly on the current span + span = trace.get_current_span() + if span and span.is_recording(): + for prop, value in associations: + prop_name = prop.value if isinstance(prop, AssociationProperty) else str(prop) + span.set_attribute(prop_name, value) diff --git a/packages/traceloop-sdk/traceloop/sdk/client/client.py b/packages/traceloop-sdk/traceloop/sdk/client/client.py index 6265b6a51c..42e4bd23da 100644 --- a/packages/traceloop-sdk/traceloop/sdk/client/client.py +++ b/packages/traceloop-sdk/traceloop/sdk/client/client.py @@ -1,15 +1,13 @@ import sys import os -from typing import TYPE_CHECKING from traceloop.sdk.annotation.user_feedback import UserFeedback from traceloop.sdk.datasets.datasets import Datasets from traceloop.sdk.experiment.experiment import Experiment from traceloop.sdk.client.http import HTTPClient from traceloop.sdk.version import __version__ -from traceloop.sdk.tracing import associations +from traceloop.sdk.associations.associations import Associations import httpx -from types import ModuleType class Client: @@ -28,7 +26,7 @@ class Client: user_feedback: UserFeedback datasets: Datasets experiment: Experiment - associations: ModuleType + associations: Associations _http: HTTPClient _async_http: httpx.AsyncClient @@ -69,4 +67,4 @@ def __init__( experiment_slug = os.getenv("TRACELOOP_EXP_SLUG") # TODO: Fix type - Experiment constructor should accept Optional[str] self.experiment = Experiment(self._http, self._async_http, experiment_slug) # type: ignore[arg-type] - self.associations = associations + self.associations = Associations() diff --git a/packages/traceloop-sdk/traceloop/sdk/tracing/associations.py b/packages/traceloop-sdk/traceloop/sdk/tracing/associations.py deleted file mode 100644 index f9d78ad55c..0000000000 --- a/packages/traceloop-sdk/traceloop/sdk/tracing/associations.py +++ /dev/null @@ -1,52 +0,0 @@ -from enum import Enum -from typing import Sequence -from opentelemetry import trace -from opentelemetry.context import attach, set_value, get_value - - -class AssociationProperty(str, Enum): - """Standard association properties for tracing.""" - - CONVERSATION_ID = "conversation_id" - CUSTOMER_ID = "customer_id" - USER_ID = "user_id" - SESSION_ID = "session_id" - - -# Type alias for a single association -Association = tuple[AssociationProperty, str] - - -def set(associations: Sequence[Association]) -> None: - """ - Set associations that will be added directly to all spans in the current context. - - Args: - associations: A sequence of (property, value) tuples - - Example: - # Single association - Traceloop.associations.set([(AssociationProperty.CONVERSATION_ID, "conv-123")]) - - # Multiple associations - Traceloop.associations.set([ - (AssociationProperty.USER_ID, "user-456"), - (AssociationProperty.SESSION_ID, "session-789") - ]) - """ - # Store all associations in context - current_associations = get_value("associations") or {} - for prop, value in associations: - if isinstance(prop, AssociationProperty): - current_associations[prop.value] = value - else: - current_associations[str(prop)] = value - - attach(set_value("associations", current_associations)) - - # Also set directly on the current span - span = trace.get_current_span() - if span and span.is_recording(): - for prop, value in associations: - prop_name = prop.value if isinstance(prop, AssociationProperty) else str(prop) - span.set_attribute(prop_name, value) From 0e8ac3eab0db4c88e6856e5b37be4d22198a891e Mon Sep 17 00:00:00 2001 From: nina-kollman <59646487+nina-kollman@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:20:56 +0200 Subject: [PATCH 10/13] add init --- packages/sample-app/sample_app/chats/gemini_chatbot.py | 3 ++- packages/traceloop-sdk/traceloop/sdk/__init__.py | 3 --- .../traceloop-sdk/traceloop/sdk/associations/__init__.py | 7 +++++++ 3 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 packages/traceloop-sdk/traceloop/sdk/associations/__init__.py diff --git a/packages/sample-app/sample_app/chats/gemini_chatbot.py b/packages/sample-app/sample_app/chats/gemini_chatbot.py index 1e2016445a..3b4f931239 100644 --- a/packages/sample-app/sample_app/chats/gemini_chatbot.py +++ b/packages/sample-app/sample_app/chats/gemini_chatbot.py @@ -3,7 +3,8 @@ from datetime import datetime import google.genai as genai from google.genai import types -from traceloop.sdk import Traceloop, AssociationProperty +from traceloop.sdk import Traceloop +from traceloop.sdk.associations import AssociationProperty from traceloop.sdk.decorators import workflow # Initialize Traceloop for observability diff --git a/packages/traceloop-sdk/traceloop/sdk/__init__.py b/packages/traceloop-sdk/traceloop/sdk/__init__.py index 486c10c888..9a64f36c26 100644 --- a/packages/traceloop-sdk/traceloop/sdk/__init__.py +++ b/packages/traceloop-sdk/traceloop/sdk/__init__.py @@ -29,12 +29,9 @@ set_association_properties, set_external_prompt_tracing_context, ) -from traceloop.sdk.associations.associations import Associations, AssociationProperty from typing import Dict from traceloop.sdk.client.client import Client -__all__ = ["Traceloop", "Client", "Instruments", "Associations", "AssociationProperty"] - class Traceloop: AUTO_CREATED_KEY_PATH = str( diff --git a/packages/traceloop-sdk/traceloop/sdk/associations/__init__.py b/packages/traceloop-sdk/traceloop/sdk/associations/__init__.py new file mode 100644 index 0000000000..6ecded6e9d --- /dev/null +++ b/packages/traceloop-sdk/traceloop/sdk/associations/__init__.py @@ -0,0 +1,7 @@ +from traceloop.sdk.associations.associations import ( + Associations, + AssociationProperty, + Association, +) + +__all__ = ["Associations", "AssociationProperty", "Association"] From 23d088faf74a0178aff019152d963e2e31ce0127 Mon Sep 17 00:00:00 2001 From: nina-kollman <59646487+nina-kollman@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:24:45 +0200 Subject: [PATCH 11/13] trace --- packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py index 31ed442697..d6e2f0a7f4 100644 --- a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py +++ b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py @@ -313,9 +313,9 @@ def default_span_processor_on_start(span: Span, parent_context: Context | None = if entity_path is not None: span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_PATH, str(entity_path)) - # Handle associations API + # Handle associations associations = get_value("associations") - if associations is not None and isinstance(associations, dict): + if associations is not None: for key, value in associations.items(): span.set_attribute(key, str(value)) From 645269c5356081c1f43bc5a3bea8dc2f1f3044ec Mon Sep 17 00:00:00 2001 From: nina-kollman <59646487+nina-kollman@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:37:34 +0200 Subject: [PATCH 12/13] add all --- packages/traceloop-sdk/traceloop/sdk/__init__.py | 3 +++ .../traceloop/sdk/associations/associations.py | 8 +++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/traceloop-sdk/traceloop/sdk/__init__.py b/packages/traceloop-sdk/traceloop/sdk/__init__.py index 9a64f36c26..d6a1373091 100644 --- a/packages/traceloop-sdk/traceloop/sdk/__init__.py +++ b/packages/traceloop-sdk/traceloop/sdk/__init__.py @@ -29,9 +29,12 @@ set_association_properties, set_external_prompt_tracing_context, ) +from traceloop.sdk.associations import Associations, AssociationProperty from typing import Dict from traceloop.sdk.client.client import Client +__all__ = ["Traceloop", "Client", "Instruments", "Associations", "AssociationProperty"] + class Traceloop: AUTO_CREATED_KEY_PATH = str( diff --git a/packages/traceloop-sdk/traceloop/sdk/associations/associations.py b/packages/traceloop-sdk/traceloop/sdk/associations/associations.py index 37e239e378..1f939004dd 100644 --- a/packages/traceloop-sdk/traceloop/sdk/associations/associations.py +++ b/packages/traceloop-sdk/traceloop/sdk/associations/associations.py @@ -39,10 +39,9 @@ def set(associations: Sequence[Association]) -> None: ]) """ # Store all associations in context - current_associations = get_value("associations") or {} + current_associations: dict[str, str] = get_value("associations") or {} # type: ignore for prop, value in associations: - if isinstance(prop, AssociationProperty): - current_associations[prop.value] = value + current_associations[prop.value] = value attach(set_value("associations", current_associations)) @@ -50,5 +49,4 @@ def set(associations: Sequence[Association]) -> None: span = trace.get_current_span() if span and span.is_recording(): for prop, value in associations: - prop_name = prop.value if isinstance(prop, AssociationProperty) else str(prop) - span.set_attribute(prop_name, value) + span.set_attribute(prop.value, value) From 505c403e2d564938d868fe4d00e1fc6718545d81 Mon Sep 17 00:00:00 2001 From: nina-kollman <59646487+nina-kollman@users.noreply.github.com> Date: Tue, 2 Dec 2025 18:13:48 +0200 Subject: [PATCH 13/13] remove all --- packages/traceloop-sdk/traceloop/sdk/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/traceloop-sdk/traceloop/sdk/__init__.py b/packages/traceloop-sdk/traceloop/sdk/__init__.py index d6a1373091..018ffb5113 100644 --- a/packages/traceloop-sdk/traceloop/sdk/__init__.py +++ b/packages/traceloop-sdk/traceloop/sdk/__init__.py @@ -29,11 +29,9 @@ set_association_properties, set_external_prompt_tracing_context, ) -from traceloop.sdk.associations import Associations, AssociationProperty from typing import Dict from traceloop.sdk.client.client import Client -__all__ = ["Traceloop", "Client", "Instruments", "Associations", "AssociationProperty"] class Traceloop: